Merge branch 'main' into cal/fix-content-rows-issues

This commit is contained in:
Calum H.
2026-07-23 20:58:46 +01:00
committed by GitHub
194 changed files with 5222 additions and 3203 deletions
+1
View File
@@ -6,6 +6,7 @@ on:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
SQLX_OFFLINE: true
jobs:
+75 -22
View File
@@ -16,10 +16,10 @@ on:
workflow_dispatch:
workflow_call:
inputs:
environment:
required: true
type: string
description: 'The environment to deploy to (staging-preview or production-preview)'
preview:
required: false
type: boolean
description: 'Whether this is a preview deployment'
head_sha:
required: false
type: string
@@ -28,9 +28,13 @@ on:
required: false
type: string
description: 'The branch name to deploy (defaults to github.ref_name)'
branch_alias:
required: false
type: string
description: 'The persistent preview alias for the branch'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.environment || 'push' }}
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.preview && 'preview' || 'push' }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
jobs:
@@ -49,23 +53,31 @@ jobs:
- name: Configure environment
id: meta
env:
ENV_INPUT: ${{ inputs.environment }}
PREVIEW_INPUT: ${{ inputs.preview }}
REF: ${{ github.ref }}
DEPLOY_SHA: ${{ inputs.head_sha || github.sha }}
BRANCH_ALIAS: ${{ inputs.branch_alias }}
run: |
echo "cmd=deploy" >> $GITHUB_OUTPUT
SHA_SHORT="${DEPLOY_SHA::8}"
if [ "$ENV_INPUT" = "staging-preview" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend-staging.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$ENV_INPUT" = "production-preview" ]; then
if [ "$PREVIEW_INPUT" = "true" ]; then
URL="https://git-$SHA_SHORT-frontend.modrinth.workers.dev"
STAGING_URL="https://git-$SHA_SHORT-staging-frontend.modrinth.workers.dev"
echo "env=production" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
echo "url=$URL" >> $GITHUB_OUTPUT
echo "staging_url=$STAGING_URL" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true --var CF_PAGES_URL:$URL" >> $GITHUB_OUTPUT
STAGING_VARS=$(jq -r '.env.staging.vars | to_entries[] | "--var \(.key):\(.value)"' ./apps/frontend/wrangler.jsonc | tr '\n' ' ')
echo "staging_cmd=versions upload --preview-alias git-$SHA_SHORT-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:$STAGING_URL" >> $GITHUB_OUTPUT
if [ -n "$BRANCH_ALIAS" ]; then
echo "branch_alias_url=https://$BRANCH_ALIAS-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "staging_branch_alias_url=https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "staging_branch_alias_cmd=versions upload --preview-alias $BRANCH_ALIAS-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
fi
elif [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
@@ -120,7 +132,7 @@ jobs:
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
CF_PAGES_URL: ${{ steps.meta.outputs.url }}
BUILD_ENV: ${{ steps.meta.outputs.env }}
PREVIEW: ${{ inputs.environment != '' && 'true' || 'false' }}
PREVIEW: ${{ inputs.preview && 'true' || 'false' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Create Sentry release and upload sourcemaps
@@ -146,6 +158,39 @@ jobs:
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.cmd }}
- name: Deploy staging Cloudflare Worker preview
if: ${{ inputs.preview }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_cmd }}
- name: Deploy persistent Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
- name: Deploy persistent staging Cloudflare Worker preview
if: ${{ inputs.preview && inputs.branch_alias != '' }}
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
run: |
@@ -155,14 +200,22 @@ jobs:
--data '{"hosts": ["modrinth.com", "www.modrinth.com", "staging.modrinth.com"]}' \
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
- name: Write deployment URL to file
if: ${{ inputs.environment != '' }}
- name: Write deployment URLs to files
if: ${{ inputs.preview }}
run: |
echo "${{ steps.meta.outputs.url }}" > deployment-url-${{ inputs.environment }}.txt
echo "${{ steps.meta.outputs.url }}" > deployment-url-production.txt
echo "${{ steps.meta.outputs.staging_url }}" > deployment-url-staging.txt
- name: Upload deployment URL
if: ${{ inputs.environment != '' }}
- name: Upload production deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-${{ inputs.environment }}
path: deployment-url-${{ inputs.environment }}.txt
name: deployment-url-production
path: deployment-url-production.txt
- name: Upload staging deployment URL
if: ${{ inputs.preview }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: deployment-url-staging
path: deployment-url-staging.txt
+56 -20
View File
@@ -12,23 +12,33 @@ on:
- '.github/workflows/frontend-preview.yml'
jobs:
metadata:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
outputs:
branch-alias: ${{ steps.meta.outputs.branch_alias }}
steps:
- name: Configure branch alias
id: meta
run: echo "branch_alias=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
deploy:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
uses: ./.github/workflows/frontend-deploy.yml
secrets: inherit
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.environment }}
group: ${{ github.workflow }}-${{ github.ref }}-deploy
cancel-in-progress: true
strategy:
matrix:
environment: [staging-preview, production-preview]
with:
environment: ${{ matrix.environment }}
preview: true
head_sha: ${{ github.event.pull_request.head.sha }}
head_ref: ${{ github.event.pull_request.head.ref }}
branch_alias: ${{ needs.metadata.outputs.branch-alias }}
deploy-storybook:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
needs: metadata
runs-on: blacksmith-2vcpu-ubuntu-2404
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-storybook
@@ -86,6 +96,18 @@ jobs:
wranglerVersion: '4.54.0'
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
- name: Deploy persistent Storybook preview
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
env:
NODE_OPTIONS: --max-old-space-size=6144
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
workingDirectory: ./packages/ui
packageManager: pnpm
wranglerVersion: '4.54.0'
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
comment:
if: |
!cancelled()
@@ -93,7 +115,7 @@ jobs:
&& github.event.pull_request.head.repo.full_name == github.repository
&& needs.deploy.result == 'success'
runs-on: ubuntu-latest
needs: [deploy, deploy-storybook]
needs: [metadata, deploy, deploy-storybook]
steps:
- name: Download deployment URLs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -105,25 +127,29 @@ jobs:
id: urls
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BRANCH_ALIAS: ${{ needs.metadata.outputs.branch-alias }}
STORYBOOK_RESULT: ${{ needs.deploy-storybook.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
STAGING_SHA_URL=$(cat deployment-url-staging.txt)
PRODUCTION_SHA_URL=$(cat deployment-url-production.txt)
STAGING_BRANCH_URL="https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev"
PRODUCTION_BRANCH_URL="https://$BRANCH_ALIAS-frontend.modrinth.workers.dev"
if [ "$STORYBOOK_RESULT" = "success" ]; then
STORYBOOK_PREVIEW_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
STORYBOOK_SHA_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
STORYBOOK_BRANCH_URL="https://$BRANCH_ALIAS-storybook.modrinth.workers.dev"
else
STORYBOOK_PREVIEW_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
STORYBOOK_SHA_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
STORYBOOK_BRANCH_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
fi
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
echo "Staging preview URL: $STAGING_PREVIEW_URL"
echo "Storybook preview URL: $STORYBOOK_PREVIEW_URL"
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "storybook-preview-url=$STORYBOOK_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "staging-sha-url=$STAGING_SHA_URL" >> $GITHUB_OUTPUT
echo "production-sha-url=$PRODUCTION_SHA_URL" >> $GITHUB_OUTPUT
echo "storybook-sha-url=$STORYBOOK_SHA_URL" >> $GITHUB_OUTPUT
echo "staging-branch-url=$STAGING_BRANCH_URL" >> $GITHUB_OUTPUT
echo "production-branch-url=$PRODUCTION_BRANCH_URL" >> $GITHUB_OUTPUT
echo "storybook-branch-url=$STORYBOOK_BRANCH_URL" >> $GITHUB_OUTPUT
- name: Find comment
if: github.event_name == 'pull_request'
@@ -146,9 +172,19 @@ jobs:
Last deployed commit is [${{ github.event.pull_request.head.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.event.pull_request.head.sha }})
### Persistent
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-preview-url }} |
| production | ${{ steps.urls.outputs.production-preview-url }} |
| storybook | ${{ steps.urls.outputs.storybook-preview-url }} |
| staging | ${{ steps.urls.outputs.staging-branch-url }} |
| production | ${{ steps.urls.outputs.production-branch-url }} |
| storybook | ${{ steps.urls.outputs.storybook-branch-url }} |
### Latest commit
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-sha-url }} |
| production | ${{ steps.urls.outputs.production-sha-url }} |
| storybook | ${{ steps.urls.outputs.storybook-sha-url }} |
edit-mode: replace
+8 -1
View File
@@ -82,6 +82,13 @@ jobs:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
CARGO_INCREMENTAL: 0
# Redis config for tests
REDIS_TOPOLOGY: cluster
REDIS_CONNECTION_TYPE: multiplexed
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
# Avoid stack overflows in tests
RUST_MIN_STACK: 16777216
steps:
- name: Check out code
@@ -185,7 +192,7 @@ jobs:
- name: Start services
if: steps.check-labrinth.outputs.needs_services == 'true'
run: docker compose up --wait
run: docker compose --profile clustered-redis up --wait
- name: Setup labrinth environment and database
if: steps.check-labrinth.outputs.needs_services == 'true'
Generated
+71 -43
View File
@@ -244,7 +244,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
"socket2 0.6.1",
"socket2 0.6.5",
"time",
"tracing",
"url",
@@ -458,6 +458,12 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "arcstr"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@@ -2478,6 +2484,12 @@ dependencies = [
"spin 0.10.0",
]
[[package]]
name = "crc16"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -2801,7 +2813,7 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deadpool"
version = "0.12.3"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
dependencies = [
"deadpool-runtime",
"num_cpus",
@@ -2811,8 +2823,8 @@ dependencies = [
[[package]]
name = "deadpool-redis"
version = "0.22.1"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
version = "0.23.0"
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
dependencies = [
"deadpool",
"redis",
@@ -2822,7 +2834,7 @@ dependencies = [
[[package]]
name = "deadpool-runtime"
version = "0.1.5"
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
dependencies = [
"tokio",
]
@@ -4794,7 +4806,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.1",
"socket2 0.5.10",
"system-configuration",
"tokio",
"tower-service",
@@ -5135,17 +5147,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "io-uring"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"libc",
]
[[package]]
name = "ipconfig"
version = "0.3.2"
@@ -5470,7 +5471,6 @@ dependencies = [
"color-thief",
"const_format",
"dashmap",
"deadpool-redis",
"derive_more 2.1.1",
"dotenv-build",
"dotenvy",
@@ -5490,7 +5490,6 @@ dependencies = [
"json-patch 4.1.0",
"labrinth",
"lettre",
"lz4_flex",
"meilisearch-sdk",
"modrinth-content-management",
"modrinth-util",
@@ -5540,6 +5539,7 @@ dependencies = [
"webauthn-rs-proto",
"webp",
"woothee",
"xredis",
"yaserde",
"zip 6.0.0",
"zxcvbn",
@@ -5605,7 +5605,7 @@ dependencies = [
"quoted_printable",
"rustls 0.23.32",
"rustls-native-certs 0.8.1",
"socket2 0.6.1",
"socket2 0.6.5",
"tokio",
"tokio-rustls 0.26.4",
"url",
@@ -5643,9 +5643,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
[[package]]
name = "libc"
version = "0.2.177"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libfuzzer-sys"
@@ -6059,14 +6059,14 @@ dependencies = [
[[package]]
name = "mio"
version = "1.0.4"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8014,7 +8014,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.32",
"socket2 0.6.1",
"socket2 0.5.10",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -8051,7 +8051,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.1",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
]
@@ -8345,26 +8345,32 @@ dependencies = [
[[package]]
name = "redis"
version = "0.32.7"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
checksum = "b0b9503711b03773e43b31668c7b5bd279ee7cd9b7d18cff7c23a42cc1d08e5a"
dependencies = [
"ahash 0.8.12",
"arcstr",
"async-lock",
"bytes",
"cfg-if",
"combine",
"crc16",
"futures-util",
"itoa",
"log",
"num-bigint",
"percent-encoding",
"pin-project-lite",
"r2d2",
"rand 0.10.1",
"ryu",
"sha1_smol",
"socket2 0.6.1",
"socket2 0.6.5",
"tokio",
"tokio-util",
"url",
"xxhash-rust",
]
[[package]]
@@ -9817,12 +9823,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.1"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -11272,30 +11278,27 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.47.1"
version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"backtrace",
"bytes",
"io-uring",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2 0.6.1",
"socket2 0.6.5",
"tokio-macros",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.5.0"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
@@ -11499,7 +11502,7 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"socket2 0.6.1",
"socket2 0.6.5",
"sync_wrapper",
"tokio",
"tokio-stream",
@@ -13429,6 +13432,31 @@ version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
[[package]]
name = "xredis"
version = "0.1.0"
dependencies = [
"ariadne",
"chrono",
"dashmap",
"deadpool-redis",
"futures",
"lz4_flex",
"prometheus",
"redis",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tracing",
]
[[package]]
name = "xxhash-rust"
version = "0.8.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72"
[[package]]
name = "yaserde"
version = "0.12.0"
+4 -2
View File
@@ -15,6 +15,7 @@ members = [
"packages/modrinth-util",
"packages/neverbounce",
"packages/path-util",
"packages/xredis",
]
[workspace.package]
@@ -72,7 +73,7 @@ daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
data-url = "0.3.2"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "c7209b086829572f368f1194aba54bdea8dc540b", version = "0.23.0" }
derive_more = "2.1.1"
directories = "6.0.0"
dirs = "6.0.0"
@@ -154,7 +155,7 @@ quote = { version = "1.0" }
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
rdkafka = { version = "0.36.2", features = ["cmake-build"] }
redis = "0.32.7"
redis = "1.4.1"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
rgb = "0.8.52"
@@ -241,6 +242,7 @@ windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
winreg = "0.55.0"
woothee = "0.13.0"
xredis = { path = "packages/xredis" }
yaserde = "0.12.0"
zbus = "5.11.0"
zip = { version = "6.0.0", default-features = false, features = [
+1 -5
View File
@@ -364,11 +364,7 @@ function getFeatureFlagOverrides() {
function getDomain() {
if (process.env.NODE_ENV === 'production') {
// @ts-ignore
if (process.env.CF_PAGES_URL || globalThis.CF_PAGES_URL) {
// @ts-ignore
return process.env.CF_PAGES_URL ?? globalThis.CF_PAGES_URL
} else if (process.env.HEROKU_APP_NAME) {
if (process.env.HEROKU_APP_NAME) {
return `https://${process.env.HEROKU_APP_NAME}.herokuapp.com`
} else if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`
@@ -3,6 +3,7 @@ import type { Labrinth } from '@modrinth/api-client'
import { FolderSearchIcon, StarIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
defineMessages,
injectModrinthClient,
injectNotificationManager,
@@ -94,6 +95,7 @@ const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const clearModalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('clearModalRef')
const { formatMessage } = useVIntl()
const rows = ref<ScanRow[]>([])
@@ -217,6 +219,10 @@ async function fetchAllScans() {
}
}
function showConfirmClearGroups() {
clearModalRef.value?.show()
}
async function clearAllGroups() {
if (isBusy.value) {
return
@@ -270,6 +276,14 @@ defineExpose({ show, hide })
</script>
<template>
<ConfirmModal
ref="clearModalRef"
title="Clear all permission groups?"
description="This will clear **all** groups for this project. This action cannot be undone."
proceed-label="Clear"
@proceed="clearAllGroups"
/>
<NewModal
ref="modalRef"
width="60vw"
@@ -288,11 +302,11 @@ defineExpose({ show, hide })
}}
</span>
<div class="flex items-center gap-2">
<ButtonStyled circular>
<ButtonStyled circular color="red" color-fill="none">
<button
v-tooltip="formatMessage(messages.clearAllGroups)"
:disabled="isBusy || rows.length === 0"
@click="clearAllGroups"
@click="showConfirmClearGroups"
>
<TrashIcon aria-hidden="true" />
</button>
@@ -2315,24 +2315,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Vytvořeno {date}"
},
"project.about.details.licensed": {
"message": "License {license}"
},
"project.about.details.published": {
"message": "Zveřejněno {date}"
},
"project.about.details.submitted": {
"message": "Odesláno {date}"
},
"project.about.details.title": {
"message": "Detaily"
},
"project.about.details.updated": {
"message": "Aktualizováno {date}"
},
"project.actions.create-server": {
"message": "Vytvořit server"
},
@@ -2360,9 +2342,6 @@
"project.description.title": {
"message": "Popisek"
},
"project.details.licensed": {
"message": "Licencováno"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} nepodporuje {gameVersion} pro {platform}"
},
@@ -2393,15 +2372,6 @@
"project.gallery.title": {
"message": "Galerie"
},
"project.license.error": {
"message": "Text licence se nepodařilo načíst."
},
"project.license.loading": {
"message": "Načítání textu licence..."
},
"project.license.title": {
"message": "Licence"
},
"project.moderation.admonition.approved.body.private": {
"message": "Váš projekt je soukromý, což znamená, že k němu máte přístup pouze vy a lidé, které pozvete."
},
@@ -1724,18 +1724,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Oprettet {date}"
},
"project.about.details.published": {
"message": "Offentliggjort {date}"
},
"project.about.details.title": {
"message": "Detaljer"
},
"project.about.details.updated": {
"message": "Opdateret {date}"
},
"project.actions.create-server": {
"message": "Lav en server"
},
@@ -3218,24 +3218,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Erstellt {date}"
},
"project.about.details.licensed": {
"message": "Lienziert unter {license}"
},
"project.about.details.published": {
"message": "Veröffentlicht {date}"
},
"project.about.details.submitted": {
"message": "Eingereicht {date}"
},
"project.about.details.title": {
"message": "Details"
},
"project.about.details.updated": {
"message": "Aktualisiert {date}"
},
"project.actions.create-server": {
"message": "Erstelle einen Server"
},
@@ -3266,9 +3248,6 @@
"project.description.title": {
"message": "Beschreibung"
},
"project.details.licensed": {
"message": "Lizenziert unter"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Diese Spielversion ist mit dem Basisprojekt nicht kompatibel."
},
@@ -3422,15 +3401,6 @@
"project.gallery.title": {
"message": "Galerie"
},
"project.license.error": {
"message": "Lizenztext konnte nicht abgerufen werden."
},
"project.license.loading": {
"message": "Lade Lizenztext..."
},
"project.license.title": {
"message": "Lizenz"
},
"project.moderation.admonition.approved.body.private": {
"message": "Dein Projekt ist privat, was bedeutet, dass nur du und von dir eingeladene Personen darauf zugreifen können."
},
@@ -3218,24 +3218,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Erstellt {date}"
},
"project.about.details.licensed": {
"message": "Lienziert unter {license}"
},
"project.about.details.published": {
"message": "Veröffentlicht {date}"
},
"project.about.details.submitted": {
"message": "Eingereicht {date}"
},
"project.about.details.title": {
"message": "Details"
},
"project.about.details.updated": {
"message": "Aktualisiert {date}"
},
"project.actions.create-server": {
"message": "Erstelle einen Server"
},
@@ -3266,9 +3248,6 @@
"project.description.title": {
"message": "Beschreibung"
},
"project.details.licensed": {
"message": "Lizenziert unter"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Diese Spielversion ist mit dem Basisprojekt nicht kompatibel."
},
@@ -3422,15 +3401,6 @@
"project.gallery.title": {
"message": "Galerie"
},
"project.license.error": {
"message": "Lizenztext konnte nicht abgerufen werden."
},
"project.license.loading": {
"message": "Lade Lizenztext..."
},
"project.license.title": {
"message": "Lizenz"
},
"project.moderation.admonition.approved.body.private": {
"message": "Dein Projekt ist privat, was bedeutet, dass nur du und von dir eingeladene Personen darauf zugreifen können."
},
@@ -3521,24 +3521,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Created {date}"
},
"project.about.details.licensed": {
"message": "Licensed {license}"
},
"project.about.details.published": {
"message": "Published {date}"
},
"project.about.details.submitted": {
"message": "Submitted {date}"
},
"project.about.details.title": {
"message": "Details"
},
"project.about.details.updated": {
"message": "Updated {date}"
},
"project.actions.create-server": {
"message": "Create a server"
},
@@ -3575,9 +3557,6 @@
"project.description.title": {
"message": "Description"
},
"project.details.licensed": {
"message": "Licensed"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "This game version is incompatible with the base project."
},
@@ -3734,15 +3713,6 @@
"project.install-context.back-to-discover": {
"message": "Back to discover"
},
"project.license.error": {
"message": "License text could not be retrieved."
},
"project.license.loading": {
"message": "Loading license text..."
},
"project.license.title": {
"message": "License"
},
"project.moderation.admonition.approved.body.private": {
"message": "Your project is private, meaning it can only be accessed by you and people you invite."
},
@@ -3218,24 +3218,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Creado {date}"
},
"project.about.details.licensed": {
"message": "Con licencia {license}"
},
"project.about.details.published": {
"message": "Publicado {date}"
},
"project.about.details.submitted": {
"message": "Enviado {date}"
},
"project.about.details.title": {
"message": "Detalles"
},
"project.about.details.updated": {
"message": "Actualizado {date}"
},
"project.actions.create-server": {
"message": "Crear un servidor"
},
@@ -3266,9 +3248,6 @@
"project.description.title": {
"message": "Descripción"
},
"project.details.licensed": {
"message": "Con licencia"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Esta versión del juego no es compatible con el proyecto base."
},
@@ -3422,15 +3401,6 @@
"project.gallery.title": {
"message": "Galería"
},
"project.license.error": {
"message": "No se pudo recuperar el texto de la licencia."
},
"project.license.loading": {
"message": "Cargando el texto de la licencia..."
},
"project.license.title": {
"message": "Licencia"
},
"project.moderation.admonition.approved.body.private": {
"message": "Tu proyecto es privado, lo que significa que solo pueden acceder tú y las personas a las que invites."
},
@@ -3041,24 +3041,6 @@
"project-type.shader.singular": {
"message": "Sombreador"
},
"project.about.details.created": {
"message": "Creado el {date}"
},
"project.about.details.licensed": {
"message": "Con licencia {license}"
},
"project.about.details.published": {
"message": "Publicado el {date}"
},
"project.about.details.submitted": {
"message": "Enviado el {date}"
},
"project.about.details.title": {
"message": "Detalles"
},
"project.about.details.updated": {
"message": "Actualizado el {date}"
},
"project.actions.create-server": {
"message": "Crear un servidor"
},
@@ -3089,9 +3071,6 @@
"project.description.title": {
"message": "Descripción"
},
"project.details.licensed": {
"message": "Con licencia"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} no está soportado para la versión {gameVersion} de {platform}"
},
@@ -3143,15 +3122,6 @@
"project.gallery.title": {
"message": "Galería"
},
"project.license.error": {
"message": "No se pudo recuperar el texto de la licencia."
},
"project.license.loading": {
"message": "Cargando texto de la licencia..."
},
"project.license.title": {
"message": "Licencia"
},
"project.moderation.admonition.approved.body.private": {
"message": "Tu proyecto es privado, lo que significa que solo tú, o las personas que invites, pueden acceder."
},
@@ -971,21 +971,12 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.title": {
"message": "Yksityiskohdat"
},
"project.description.title": {
"message": "Kuvaus"
},
"project.details.licensed": {
"message": "Lisenssoitu"
},
"project.gallery.title": {
"message": "Galleria"
},
"project.license.title": {
"message": "Lisenssi"
},
"project.moderation.title": {
"message": "Moderaatio"
},
@@ -2360,24 +2360,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Linikha {date}"
},
"project.about.details.licensed": {
"message": "Linisensiya {license}"
},
"project.about.details.published": {
"message": "Inilathala {date}"
},
"project.about.details.submitted": {
"message": "Isinumite {date}"
},
"project.about.details.title": {
"message": "Detalye"
},
"project.about.details.updated": {
"message": "In-update {date}"
},
"project.actions.create-server": {
"message": "Lumikha ng server"
},
@@ -2408,9 +2390,6 @@
"project.description.title": {
"message": "Paglalarawan"
},
"project.details.licensed": {
"message": "Lisensiyang"
},
"project.download.game-version-unsupported-tooltip": {
"message": "Hindi masuporta ng {title} ang {gameVersion} para sa {platform}"
},
@@ -2462,15 +2441,6 @@
"project.gallery.title": {
"message": "Galeriya"
},
"project.license.error": {
"message": "Hindi makuha ang teksto ng lisensiya."
},
"project.license.loading": {
"message": "Nag-load sa teksto ng lisensiya..."
},
"project.license.title": {
"message": "Lisensiya"
},
"project.moderation.title": {
"message": "Moderation"
},
@@ -3224,24 +3224,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Créé le {date}"
},
"project.about.details.licensed": {
"message": "Sous licence {license}"
},
"project.about.details.published": {
"message": "Publié {date}"
},
"project.about.details.submitted": {
"message": "Soumis le {date}"
},
"project.about.details.title": {
"message": "Détails"
},
"project.about.details.updated": {
"message": "Mis à jour {date}"
},
"project.actions.create-server": {
"message": "Créer un serveur"
},
@@ -3272,9 +3254,6 @@
"project.description.title": {
"message": "Description"
},
"project.details.licensed": {
"message": "License"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Cette version du jeu est incompatible avec le projet de base."
},
@@ -3428,15 +3407,6 @@
"project.gallery.title": {
"message": "Galerie"
},
"project.license.error": {
"message": "Le texte de la license n'a pas pu être obtenu."
},
"project.license.loading": {
"message": "Chargement du texte de la license..."
},
"project.license.title": {
"message": "License"
},
"project.moderation.admonition.approved.body.private": {
"message": "Votre projet est privé, il n'est accessible que par vous et les personnes que vous invitez."
},
@@ -1985,24 +1985,6 @@
"project-type.shader.singular": {
"message": "שיידר"
},
"project.about.details.created": {
"message": "נוצר ב-{date}"
},
"project.about.details.licensed": {
"message": "ברישיון {license}"
},
"project.about.details.published": {
"message": "פורסם {date}"
},
"project.about.details.submitted": {
"message": "נשלח ב-{date}"
},
"project.about.details.title": {
"message": "פרטים"
},
"project.about.details.updated": {
"message": "עודכן ב-{date}"
},
"project.actions.create-server": {
"message": "צור שרת"
},
@@ -2033,9 +2015,6 @@
"project.description.title": {
"message": "תיאור"
},
"project.details.licensed": {
"message": "ברישיון"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} אינו תומך ב-{gameVersion} עבור {platform}"
},
@@ -2087,15 +2066,6 @@
"project.gallery.title": {
"message": "גלריה"
},
"project.license.error": {
"message": "לא ניתן היה לשלוף את טקסט הרישיון."
},
"project.license.loading": {
"message": "טוען את טקסט הרישיון..."
},
"project.license.title": {
"message": "רישיון"
},
"project.moderation.title": {
"message": "פיקוח ובקרה"
},
@@ -3116,24 +3116,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Létrehozva {date}"
},
"project.about.details.licensed": {
"message": "Licesz: {license}"
},
"project.about.details.published": {
"message": "Közzététel: {date}"
},
"project.about.details.submitted": {
"message": "Benyújtva {date}"
},
"project.about.details.title": {
"message": "Részletek"
},
"project.about.details.updated": {
"message": "Frissítve {date}"
},
"project.actions.create-server": {
"message": "Hozz létre egy szervert"
},
@@ -3164,9 +3146,6 @@
"project.description.title": {
"message": "Leírás"
},
"project.details.licensed": {
"message": "Licenszelt"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Ez a játékverzió nem kompatibilis az alap projekttel."
},
@@ -3317,15 +3296,6 @@
"project.gallery.title": {
"message": "Képek"
},
"project.license.error": {
"message": "A licensz szövege nem tölthető le."
},
"project.license.loading": {
"message": "Licensz szövegének betöltése..."
},
"project.license.title": {
"message": "Licensz"
},
"project.moderation.admonition.approved.body.private": {
"message": "A projekted privát, vagyis csak te és az általad meghívott személyek férhetnek hozzá."
},
@@ -2366,24 +2366,6 @@
"project-type.shader.singular": {
"message": "Efek Gambar"
},
"project.about.details.created": {
"message": "Dibuat {date}"
},
"project.about.details.licensed": {
"message": "Berlisensi {license}"
},
"project.about.details.published": {
"message": "Diterbitkan {date}"
},
"project.about.details.submitted": {
"message": "Diserahkan {date}"
},
"project.about.details.title": {
"message": "Perincian"
},
"project.about.details.updated": {
"message": "Diperbarui {date}"
},
"project.actions.create-server": {
"message": "Buat server"
},
@@ -2414,9 +2396,6 @@
"project.description.title": {
"message": "Keterangan"
},
"project.details.licensed": {
"message": "Berlisensi"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} tidak mendukung {gameVersion} untuk {platform}"
},
@@ -2468,15 +2447,6 @@
"project.gallery.title": {
"message": "Galeri"
},
"project.license.error": {
"message": "Teks lisensi tidak dapat diperoleh."
},
"project.license.loading": {
"message": "Memuat teks lisensi..."
},
"project.license.title": {
"message": "Lisensi"
},
"project.moderation.title": {
"message": "Moderasi"
},
@@ -3209,24 +3209,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Creato {date}"
},
"project.about.details.licensed": {
"message": "Licenza {license}"
},
"project.about.details.published": {
"message": "Pubblicato {date}"
},
"project.about.details.submitted": {
"message": "Inserito {date}"
},
"project.about.details.title": {
"message": "Dettagli"
},
"project.about.details.updated": {
"message": "Aggiornato {date}"
},
"project.actions.create-server": {
"message": "Crea un server"
},
@@ -3257,9 +3239,6 @@
"project.description.title": {
"message": "Descrizione"
},
"project.details.licensed": {
"message": "Licenza"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Questo progetto non è compatibile con questa versione del gioco."
},
@@ -3413,15 +3392,6 @@
"project.gallery.title": {
"message": "Galleria"
},
"project.license.error": {
"message": "Impossibile ottenere testo licenza."
},
"project.license.loading": {
"message": "Caricamento testo licenza..."
},
"project.license.title": {
"message": "Licenza"
},
"project.moderation.admonition.approved.body.private": {
"message": "Il progetto è privato: solo tu e chiunque inviti potranno accederci."
},
@@ -2741,24 +2741,6 @@
"project-type.shader.singular": {
"message": "シェーダー"
},
"project.about.details.created": {
"message": "{date}に作成"
},
"project.about.details.licensed": {
"message": "ライセンス: {license}"
},
"project.about.details.published": {
"message": "{date} に公開"
},
"project.about.details.submitted": {
"message": "提出時刻: {date}"
},
"project.about.details.title": {
"message": "詳細"
},
"project.about.details.updated": {
"message": "{date} に更新"
},
"project.actions.create-server": {
"message": "サーバーを作成"
},
@@ -2789,9 +2771,6 @@
"project.description.title": {
"message": "説明"
},
"project.details.licensed": {
"message": "ライセンス"
},
"project.download.download": {
"message": "ダウンロード"
},
@@ -2846,15 +2825,6 @@
"project.gallery.title": {
"message": "ギャラリー"
},
"project.license.error": {
"message": "ライセンステキストを取得できませんでした。"
},
"project.license.loading": {
"message": "ライセンステキストを読み込み中…"
},
"project.license.title": {
"message": "ライセンス"
},
"project.moderation.admonition.approved.header": {
"message": "プロジェクトが承認されました"
},
@@ -3218,24 +3218,6 @@
"project-type.shader.singular": {
"message": "셰이더"
},
"project.about.details.created": {
"message": "{date} 에 생성됨"
},
"project.about.details.licensed": {
"message": "{license} 라이선스"
},
"project.about.details.published": {
"message": "{date} 게시"
},
"project.about.details.submitted": {
"message": "{date} 에 제출됨"
},
"project.about.details.title": {
"message": "세부 사항"
},
"project.about.details.updated": {
"message": "{date} 업데이트"
},
"project.actions.create-server": {
"message": "서버 만들기"
},
@@ -3266,9 +3248,6 @@
"project.description.title": {
"message": "설명"
},
"project.details.licensed": {
"message": "라이선스"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "이 게임 버전은 기본 프로젝트와 호환되지 않습니다."
},
@@ -3422,15 +3401,6 @@
"project.gallery.title": {
"message": "갤러리"
},
"project.license.error": {
"message": "라이선스 텍스트를 불러올 수 없습니다."
},
"project.license.loading": {
"message": "라이선스 텍스트를 불러오는 중..."
},
"project.license.title": {
"message": "라이선스"
},
"project.moderation.admonition.approved.body.private": {
"message": "이 프로젝트는 비공개로 설정되어 있어, 본인과 본인이 초대한 사람만 접근할 수 있습니다."
},
@@ -2897,24 +2897,6 @@
"project-type.shader.singular": {
"message": "Pembayang"
},
"project.about.details.created": {
"message": "Dicipta {date}"
},
"project.about.details.licensed": {
"message": "Berlesen {license}"
},
"project.about.details.published": {
"message": "Diterbitkan {date}"
},
"project.about.details.submitted": {
"message": "Diserahkan {date}"
},
"project.about.details.title": {
"message": "Butiran"
},
"project.about.details.updated": {
"message": "Dikemas kini {date}"
},
"project.actions.create-server": {
"message": "Cipta pelayan"
},
@@ -2945,9 +2927,6 @@
"project.description.title": {
"message": "Keterangan"
},
"project.details.licensed": {
"message": "Berlesen"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} tidak menyokong {gameVersion} untuk {platform}"
},
@@ -2999,15 +2978,6 @@
"project.gallery.title": {
"message": "Galeri"
},
"project.license.error": {
"message": "Teks lesen tidak dapat diambil."
},
"project.license.loading": {
"message": "Sedang memuat teks lesen..."
},
"project.license.title": {
"message": "Lesen"
},
"project.moderation.admonition.approved.body.private": {
"message": "Projek anda adalah peribadi, yang bermakna ia hanya boleh diakses oleh anda dan orang yang anda jemput."
},
@@ -3215,24 +3215,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Gemaakt op {date}"
},
"project.about.details.licensed": {
"message": "Gelicentieerd met {license}"
},
"project.about.details.published": {
"message": "Gepubliceerd op {date}"
},
"project.about.details.submitted": {
"message": "Ingediend op {date}"
},
"project.about.details.title": {
"message": "Details"
},
"project.about.details.updated": {
"message": "Bijgewerkt op {date}"
},
"project.actions.create-server": {
"message": "Creëer een server"
},
@@ -3263,9 +3245,6 @@
"project.description.title": {
"message": "Beschrijving"
},
"project.details.licensed": {
"message": "Gelicentieerd"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Deze spelversie is niet compatibel met het basisproject."
},
@@ -3410,15 +3389,6 @@
"project.gallery.title": {
"message": "Gallerij"
},
"project.license.error": {
"message": "Licentie inhoud kon niet worden verkregen."
},
"project.license.loading": {
"message": "Licentietekst aan het laden..."
},
"project.license.title": {
"message": "Licentie"
},
"project.moderation.admonition.approved.body.unlisted": {
"message": "Jouw project is verborgen. Dit betekend dat het alleen beschikbaar is via een directe link en niet vindbaar is op Modrinth."
},
@@ -2825,24 +2825,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Oppretta {date}"
},
"project.about.details.licensed": {
"message": "Lisensiert {license}"
},
"project.about.details.published": {
"message": "Publisert {date}"
},
"project.about.details.submitted": {
"message": "Sendt inn {date}"
},
"project.about.details.title": {
"message": "Detaljer"
},
"project.about.details.updated": {
"message": "Oppdatert {date}"
},
"project.actions.create-server": {
"message": "Lag en server"
},
@@ -2873,9 +2855,6 @@
"project.description.title": {
"message": "Beskrivelse"
},
"project.details.licensed": {
"message": "Lisensert"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} støtter ikke {gameVersion} for {platform}"
},
@@ -2927,15 +2906,6 @@
"project.gallery.title": {
"message": "Galleri"
},
"project.license.error": {
"message": "Lisenstekst kunne ikke bli henta."
},
"project.license.loading": {
"message": "Laster inn linsenstekst..."
},
"project.license.title": {
"message": "Lisens"
},
"project.moderation.admonition.approved.header": {
"message": "Prosjekt godkjent"
},
@@ -3209,24 +3209,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Utworzony {date}"
},
"project.about.details.licensed": {
"message": "Pod licencją {license}"
},
"project.about.details.published": {
"message": "Opublikowano {date}"
},
"project.about.details.submitted": {
"message": "Przesłano {date}"
},
"project.about.details.title": {
"message": "Szczegóły"
},
"project.about.details.updated": {
"message": "Zaktualizowany {date}"
},
"project.actions.create-server": {
"message": "Utwórz serwer"
},
@@ -3257,9 +3239,6 @@
"project.description.title": {
"message": "Opis"
},
"project.details.licensed": {
"message": "Licencja"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Ta wersja gry jest niekompatybilna z bazowym projektem."
},
@@ -3413,15 +3392,6 @@
"project.gallery.title": {
"message": "Galeria"
},
"project.license.error": {
"message": "Nie można pobrać tekstu licencji."
},
"project.license.loading": {
"message": "Ładowanie tekstu licencji..."
},
"project.license.title": {
"message": "Licencja"
},
"project.moderation.admonition.approved.body.private": {
"message": "Twój projekt jest prywatny, co oznacza, że jest widoczny tylko dla ciebie i tych, których zaprosisz."
},
@@ -3224,24 +3224,6 @@
"project-type.shader.singular": {
"message": "Sombreador"
},
"project.about.details.created": {
"message": "Criado {date}"
},
"project.about.details.licensed": {
"message": "Licenciado como {license}"
},
"project.about.details.published": {
"message": "Publicado {date}"
},
"project.about.details.submitted": {
"message": "Enviado {date}"
},
"project.about.details.title": {
"message": "Detalhes"
},
"project.about.details.updated": {
"message": "Atualizado {date}"
},
"project.actions.create-server": {
"message": "Criar um servidor"
},
@@ -3272,9 +3254,6 @@
"project.description.title": {
"message": "Descrição"
},
"project.details.licensed": {
"message": "Licença"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Esta versão do jogo é incompatível com o projeto base."
},
@@ -3428,15 +3407,6 @@
"project.gallery.title": {
"message": "Galeria"
},
"project.license.error": {
"message": "Não foi possível obter o texto da licença."
},
"project.license.loading": {
"message": "Carregando texto da licença..."
},
"project.license.title": {
"message": "Licença"
},
"project.moderation.admonition.approved.body.private": {
"message": "Seu projeto é privado, o que significa que só você e as pessoas que você convidar podem acessá-lo."
},
@@ -2708,24 +2708,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Criado {date}"
},
"project.about.details.licensed": {
"message": "Licenciado {license}"
},
"project.about.details.published": {
"message": "Publicado {date}"
},
"project.about.details.submitted": {
"message": "Enviado {date}"
},
"project.about.details.title": {
"message": "Detalhes"
},
"project.about.details.updated": {
"message": "Atualizado {date}"
},
"project.actions.create-server": {
"message": "Criar um servidor"
},
@@ -2756,9 +2738,6 @@
"project.description.title": {
"message": "Descrição"
},
"project.details.licensed": {
"message": "Licenciado"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} não suporta {gameVersion} para {platform}"
},
@@ -2810,15 +2789,6 @@
"project.gallery.title": {
"message": "Galeria"
},
"project.license.error": {
"message": "Não foi possível obter o texto da licença."
},
"project.license.loading": {
"message": "A carregar texto da licença..."
},
"project.license.title": {
"message": "Licença"
},
"project.moderation.title": {
"message": "Moderação"
},
@@ -1367,24 +1367,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Creat pe {date}"
},
"project.about.details.licensed": {
"message": "Licențiat cu {license}"
},
"project.about.details.published": {
"message": "Publicat pe {date}"
},
"project.about.details.submitted": {
"message": "Trimis pe {date}"
},
"project.about.details.title": {
"message": "Detalii"
},
"project.about.details.updated": {
"message": "Actualizat pe {date}"
},
"project.actions.create-server": {
"message": "Creează un server"
},
@@ -1412,9 +1394,6 @@
"project.description.title": {
"message": "Descriere"
},
"project.details.licensed": {
"message": "Licență"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} nu este compatibil cu {gameVersion} pentru {platform}"
},
@@ -1463,15 +1442,6 @@
"project.gallery.title": {
"message": "Galerie"
},
"project.license.error": {
"message": "Textul licenței nu a putut fi preluat."
},
"project.license.loading": {
"message": "Se încarcă textul licenței..."
},
"project.license.title": {
"message": "Licență"
},
"project.moderation.title": {
"message": "Moderare"
},
@@ -3215,24 +3215,6 @@
"project-type.shader.singular": {
"message": "Шейдер"
},
"project.about.details.created": {
"message": "Создан {date}"
},
"project.about.details.licensed": {
"message": "Лицензия {license}"
},
"project.about.details.published": {
"message": "Размещён {date}"
},
"project.about.details.submitted": {
"message": "Отправлен {date}"
},
"project.about.details.title": {
"message": "Сведения"
},
"project.about.details.updated": {
"message": "Обновлён {date}"
},
"project.actions.create-server": {
"message": "Создать сервер"
},
@@ -3263,9 +3245,6 @@
"project.description.title": {
"message": "Описание"
},
"project.details.licensed": {
"message": "Лицензия"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Эта версия игры несовместима с зависимым контентом."
},
@@ -3419,15 +3398,6 @@
"project.gallery.title": {
"message": "Галерея"
},
"project.license.error": {
"message": "Не удалось получить текст лицензии."
},
"project.license.loading": {
"message": "Загрузка текста лицензии..."
},
"project.license.title": {
"message": "Лицензия"
},
"project.moderation.admonition.approved.body.private": {
"message": "Ваш проект приватный, доступ к нему есть только у вас и приглашённых вами людей."
},
@@ -1343,24 +1343,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Kreirano {date}"
},
"project.about.details.licensed": {
"message": "Licencirano {license}"
},
"project.about.details.published": {
"message": "Objavljeno {date}"
},
"project.about.details.submitted": {
"message": "Poslato {date}"
},
"project.about.details.title": {
"message": "Detalji"
},
"project.about.details.updated": {
"message": "Ažurirano {date}"
},
"project.actions.create-server": {
"message": "Kreiraj server"
},
@@ -2882,24 +2882,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Skapad {date}"
},
"project.about.details.licensed": {
"message": "Licensierat {license}"
},
"project.about.details.published": {
"message": "Skapades {date}"
},
"project.about.details.submitted": {
"message": "Inskickad {date}"
},
"project.about.details.title": {
"message": "Detaljer"
},
"project.about.details.updated": {
"message": "Uppdaterad {date}"
},
"project.actions.create-server": {
"message": "Skapa en server"
},
@@ -2930,9 +2912,6 @@
"project.description.title": {
"message": "Beskrivning"
},
"project.details.licensed": {
"message": "Licensierad"
},
"project.download.dependency-download-file": {
"message": "Ladda ner {filename}"
},
@@ -3005,15 +2984,6 @@
"project.gallery.title": {
"message": "Galleri"
},
"project.license.error": {
"message": "Licenstext kunde inte hämtas."
},
"project.license.loading": {
"message": "Laddar licenstext..."
},
"project.license.title": {
"message": "Licens"
},
"project.moderation.admonition.under-review.header": {
"message": "Projekt under granskning"
},
@@ -3218,24 +3218,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "{date} oluşturuldu"
},
"project.about.details.licensed": {
"message": "{license} lisanslı"
},
"project.about.details.published": {
"message": "{date} yayınlandı"
},
"project.about.details.submitted": {
"message": "{date} sunuldu"
},
"project.about.details.title": {
"message": "Detaylar"
},
"project.about.details.updated": {
"message": "{date} güncellendi"
},
"project.actions.create-server": {
"message": "Sunucu oluştur"
},
@@ -3266,9 +3248,6 @@
"project.description.title": {
"message": "Açıklama"
},
"project.details.licensed": {
"message": "Lisans:"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Bu oyun sürümü projenin bu sürümü ile uygun değil."
},
@@ -3422,15 +3401,6 @@
"project.gallery.title": {
"message": "Galeri"
},
"project.license.error": {
"message": "Lisans yazısı getirilemedi."
},
"project.license.loading": {
"message": "Lisans yazısı yükleniyor..."
},
"project.license.title": {
"message": "Lisans"
},
"project.moderation.admonition.approved.body.private": {
"message": "Projeniz özeldir, yani yalnızca siz ve davet ettiğiniz kişiler tarafından erişilebilir."
},
@@ -3221,24 +3221,6 @@
"project-type.shader.singular": {
"message": "Шейдер"
},
"project.about.details.created": {
"message": "Створено {date}"
},
"project.about.details.licensed": {
"message": "Ліцензія «{license}»"
},
"project.about.details.published": {
"message": "Опубліковано {date}"
},
"project.about.details.submitted": {
"message": "Подано на розгляд {date}"
},
"project.about.details.title": {
"message": "Деталі"
},
"project.about.details.updated": {
"message": "Оновлено {date}"
},
"project.actions.create-server": {
"message": "Створити сервер"
},
@@ -3269,9 +3251,6 @@
"project.description.title": {
"message": "Опис"
},
"project.details.licensed": {
"message": "Ліцензія:"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "Ця версія гри несумісна з базовим проєктом."
},
@@ -3425,15 +3404,6 @@
"project.gallery.title": {
"message": "Галерея"
},
"project.license.error": {
"message": "Неможливо отримати текст ліцензії."
},
"project.license.loading": {
"message": "Завантаження тексту ліцензії…"
},
"project.license.title": {
"message": "Ліцензія"
},
"project.moderation.admonition.approved.body.private": {
"message": "Ваш проєкт є приватним, тобто доступ до нього маєте лише ви та запрошені вами люди."
},
@@ -3011,24 +3011,6 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.details.created": {
"message": "Tạo vào {date}"
},
"project.about.details.licensed": {
"message": "Giấy phép: {license}"
},
"project.about.details.published": {
"message": "Xuất bản vào {date}"
},
"project.about.details.submitted": {
"message": "Gửi vào {date}"
},
"project.about.details.title": {
"message": "Chi tiết"
},
"project.about.details.updated": {
"message": "Cập nhật vào {date}"
},
"project.actions.create-server": {
"message": "Tạo máy chủ"
},
@@ -3059,9 +3041,6 @@
"project.description.title": {
"message": "Mô tả"
},
"project.details.licensed": {
"message": "Giấy phép"
},
"project.download.game-version-unsupported-tooltip": {
"message": "{title} không hỗ trợ {gameVersion} cho {platform}"
},
@@ -3113,15 +3092,6 @@
"project.gallery.title": {
"message": "Thư viện"
},
"project.license.error": {
"message": "Không thể tải nội dung giấy phép."
},
"project.license.loading": {
"message": "Đang tải nội dung giấy phép..."
},
"project.license.title": {
"message": "Giấy phép"
},
"project.moderation.admonition.approved.body.private": {
"message": "Dự án của bạn đang để chế độ riêng tư, nghĩa là chỉ có bạn và những người khác được mời có thể truy cập nó."
},
@@ -3218,24 +3218,6 @@
"project-type.shader.singular": {
"message": "光影包"
},
"project.about.details.created": {
"message": "{date}创建"
},
"project.about.details.licensed": {
"message": "许可证:{license}"
},
"project.about.details.published": {
"message": "{date}发布"
},
"project.about.details.submitted": {
"message": "{date}提交"
},
"project.about.details.title": {
"message": "详细信息"
},
"project.about.details.updated": {
"message": "{date}更新"
},
"project.actions.create-server": {
"message": "创建服务器"
},
@@ -3266,9 +3248,6 @@
"project.description.title": {
"message": "描述"
},
"project.details.licensed": {
"message": "许可证"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "此游戏版本与基础项目不兼容。"
},
@@ -3422,15 +3401,6 @@
"project.gallery.title": {
"message": "图库"
},
"project.license.error": {
"message": "获取许可证文本内容时出错。"
},
"project.license.loading": {
"message": "正在加载许可证文本……"
},
"project.license.title": {
"message": "许可证"
},
"project.moderation.admonition.approved.body.private": {
"message": "你的项目是私人的,这意味着它只能由你本人或受你邀请的人访问。"
},
@@ -3224,24 +3224,6 @@
"project-type.shader.singular": {
"message": "光影包"
},
"project.about.details.created": {
"message": "建立時間:{date}"
},
"project.about.details.licensed": {
"message": "授權條款:{license}"
},
"project.about.details.published": {
"message": "發布時間:{date}"
},
"project.about.details.submitted": {
"message": "提交時間:{date}"
},
"project.about.details.title": {
"message": "詳細資訊"
},
"project.about.details.updated": {
"message": "更新時間:{date}"
},
"project.actions.create-server": {
"message": "建立伺服器"
},
@@ -3272,9 +3254,6 @@
"project.description.title": {
"message": "描述"
},
"project.details.licensed": {
"message": "授權條款"
},
"project.download.base-game-version-incompatible-tooltip": {
"message": "這個遊戲版本與基底專案不相容。"
},
@@ -3428,15 +3407,6 @@
"project.gallery.title": {
"message": "圖庫"
},
"project.license.error": {
"message": "取得授權條款文字內容時發生錯誤。"
},
"project.license.loading": {
"message": "正在載入授權條款文字..."
},
"project.license.title": {
"message": "授權條款"
},
"project.moderation.admonition.approved.body.private": {
"message": "你的專案目前設為私人,這代表只有你和你邀請的人才能存取。"
},
+1 -218
View File
@@ -48,25 +48,6 @@
</template>
<div v-else>
<NewModal
ref="modalLicense"
:header="project.license.name ? project.license.name : formatMessage(messages.licenseTitle)"
>
<template #title>
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" no-shadow />
<span class="text-lg font-extrabold text-contrast">
{{ project.license.name ? project.license.name : formatMessage(messages.licenseTitle) }}
</span>
</template>
<div
class="markdown-body"
v-html="
renderString(licenseText).isEmpty
? formatMessage(messages.loadingLicenseText)
: renderString(licenseText)
"
/>
</NewModal>
<OpenInAppModal ref="openInAppModal" />
<div
class="over-the-top-download-animation"
@@ -472,116 +453,12 @@
:user-link="(username) => `/user/${username}`"
class="card flex-card"
/>
<!-- TODO: Finish license modal and enable -->
<ProjectSidebarDetails
v-if="false"
:project="project"
:has-versions="versions.length > 0"
:link-target="$external()"
:show-followers="isServerProject"
class="card flex-card"
/>
<div class="card flex-card">
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div v-if="projectV3Loaded && !isServerProject" class="details-list__item">
<BookTextIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.licensedLabel) }}
<a
v-if="project.license.url"
class="text-link hover:underline"
:href="project.license.url"
:target="$external()"
rel="noopener nofollow ugc"
>
{{ licenseIdDisplay }}
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
</a>
<span
v-else-if="
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
!project.license.id.includes('LicenseRef')
"
class="text-link hover:underline"
@click="(event) => getLicenseData(event)"
>
{{ licenseIdDisplay }}
</span>
<span v-else>{{ licenseIdDisplay }}</span>
</div>
</div>
<div v-if="isServerProject" class="details-list__item">
<HeartIcon aria-hidden="true" />
<div>
{{
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: project.followers,
}),
)
}}
</div>
</div>
<div
v-if="project.approved"
v-tooltip="formatDateTime(project.approved)"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{
capitalizeString(
formatMessage(detailsMessages.published, {
date: publishedDate,
}),
)
}}
</div>
</div>
<div v-else v-tooltip="formatDateTime(project.published)" class="details-list__item">
<CalendarIcon aria-hidden="true" />
<div>
{{
capitalizeString(formatMessage(detailsMessages.created, { date: createdDate }))
}}
</div>
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="formatDateTime(project.queued)"
class="details-list__item"
>
<ScaleIcon aria-hidden="true" />
<div>
{{
capitalizeString(
formatMessage(detailsMessages.submitted, {
date: submittedDate,
}),
)
}}
</div>
</div>
<div
v-if="versions.length > 0 && project.updated"
v-tooltip="formatDateTime(project.updated)"
class="details-list__item"
>
<VersionIcon aria-hidden="true" />
<div>
{{
capitalizeString(formatMessage(detailsMessages.updated, { date: updatedDate }))
}}
</div>
</div>
</div>
</div>
</div>
<div class="normal-page__content">
@@ -612,13 +489,10 @@
<script setup>
import {
BookTextIcon,
CalendarIcon,
ChartIcon,
ChevronRightIcon,
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
FolderSearchIcon,
HeartIcon,
ListIcon,
@@ -629,7 +503,6 @@ import {
ScanEyeIcon,
ServerPlusIcon,
SettingsIcon,
VersionIcon,
XIcon,
} from '@modrinth/assets'
import {
@@ -643,7 +516,6 @@ import {
injectNotificationManager,
IntlFormatted,
NavTabs,
NewModal,
OpenInAppModal,
PROJECT_DEP_MARKER_QUERY,
ProjectBackgroundGradient,
@@ -659,13 +531,11 @@ import {
SelectedProjectsFloatingBar,
TeleportOverflowMenu,
useDebugLogger,
useFormatDateTime,
useFormatPrice,
useRelativeTime,
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
@@ -733,10 +603,6 @@ const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const { formatMessage } = useVIntl()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatPrice = useFormatPrice()
const debug = useDebugLogger('DownloadModal')
@@ -773,35 +639,6 @@ function handlePlayServerProject() {
})
}
const formatRelativeTime = useRelativeTime()
const detailsMessages = defineMessages({
title: {
id: 'project.about.details.title',
defaultMessage: 'Details',
},
licensed: {
id: 'project.about.details.licensed',
defaultMessage: 'Licensed {license}',
},
created: {
id: 'project.about.details.created',
defaultMessage: 'Created {date}',
},
submitted: {
id: 'project.about.details.submitted',
defaultMessage: 'Submitted {date}',
},
published: {
id: 'project.about.details.published',
defaultMessage: 'Published {date}',
},
updated: {
id: 'project.about.details.updated',
defaultMessage: 'Updated {date}',
},
})
const messages = defineMessages({
archivedMessage: {
id: 'project.status.archived.message',
@@ -870,22 +707,6 @@ const messages = defineMessages({
id: 'project.gallery.title',
defaultMessage: 'Gallery',
},
licenseErrorMessage: {
id: 'project.license.error',
defaultMessage: 'License text could not be retrieved.',
},
licenseTitle: {
id: 'project.license.title',
defaultMessage: 'License',
},
licensedLabel: {
id: 'project.details.licensed',
defaultMessage: 'Licensed',
},
loadingLicenseText: {
id: 'project.license.loading',
defaultMessage: 'Loading license text...',
},
moderationTab: {
id: 'project.moderation.title',
defaultMessage: 'Moderation',
@@ -956,45 +777,7 @@ const messages = defineMessages({
},
})
const modalLicense = ref(null)
const modalCollection = useTemplateRef('modal_collection')
const licenseText = ref('')
const createdDate = computed(() =>
project.value.published ? formatRelativeTime(project.value.published) : 'unknown',
)
const submittedDate = computed(() =>
project.value.queued ? formatRelativeTime(project.value.queued) : 'unknown',
)
const publishedDate = computed(() =>
project.value.approved ? formatRelativeTime(project.value.approved) : 'unknown',
)
const updatedDate = computed(() =>
project.value.updated ? formatRelativeTime(project.value.updated) : 'unknown',
)
const licenseIdDisplay = computed(() => {
const id = project.value.license.id
if (id === 'LicenseRef-All-Rights-Reserved') {
return 'ARR'
} else if (id.includes('LicenseRef')) {
return id.replaceAll('LicenseRef-', '').replaceAll('-', ' ')
} else {
return id
}
})
async function getLicenseData(event) {
modalLicense.value.show(event)
try {
const text = await client.labrinth.tags_v2.getLicenseText(project.value.license.id)
licenseText.value = text.body || formatMessage(messages.licenseErrorMessage)
} catch {
licenseText.value = formatMessage(messages.licenseErrorMessage)
}
}
const collections = computed(() =>
user.value && user.value.collections ? user.value.collections : [],
@@ -0,0 +1,20 @@
export default defineEventHandler(async (event) => {
try {
const mod = 'cloudflare:workers'
const { env } = await import(/* @vite-ignore */ mod)
const cfEnv = env as any
const config = useRuntimeConfig(event)
if (cfEnv.CF_PAGES_URL) config.public.siteUrl = cfEnv.CF_PAGES_URL
if (cfEnv.BROWSER_BASE_URL) config.public.apiBaseUrl = cfEnv.BROWSER_BASE_URL
if (cfEnv.BASE_URL) config.apiBaseUrl = cfEnv.BASE_URL
if (cfEnv.PYRO_BASE_URL) {
config.public.pyroBaseUrl = cfEnv.PYRO_BASE_URL
config.pyroBaseUrl = cfEnv.PYRO_BASE_URL
}
if (cfEnv.STRIPE_PUBLISHABLE_KEY)
config.public.stripePublishableKey = cfEnv.STRIPE_PUBLISHABLE_KEY
} catch {
/* empty */
}
})
+6
View File
@@ -10,6 +10,7 @@ CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
LABRINTH_MEDAL_KEY=
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
LABRINTH_SUBSCRIPTIONS_KEY=
RATE_LIMIT_IGNORE_KEY=feedbeef
DATABASE_URL=postgresql://labrinth:labrinth@labrinth-postgres/labrinth
@@ -29,9 +30,14 @@ TYPESENSE_URL=http://localhost:8108
TYPESENSE_API_KEY=modrinth
TYPESENSE_INDEX_PREFIX=labrinth
REDIS_MODE=standalone
REDIS_CONNECTION_TYPE=pooled
# Cache fill coordination is local to each process
REDIS_CACHE_LOCKING_STRATEGY=local
REDIS_URL=redis://labrinth-redis
REDIS_MIN_CONNECTIONS=0
REDIS_MAX_CONNECTIONS=10000
REDIS_BLOCKING_MAX_CONNECTIONS=8
KAFKA_BOOTSTRAP_SERVERS=redpanda:9092
KAFKA_CLIENT_ID=labrinth
+11
View File
@@ -10,6 +10,7 @@ CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
LABRINTH_MEDAL_KEY=
LABRINTH_EXTERNAL_NOTIFICATION_KEY=beeffeed
LABRINTH_SUBSCRIPTIONS_KEY=
RATE_LIMIT_IGNORE_KEY=feedbeef
DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth
@@ -47,9 +48,19 @@ TYPESENSE_URL=http://localhost:8108
TYPESENSE_API_KEY=modrinth
TYPESENSE_INDEX_PREFIX=labrinth
REDIS_MODE=standalone
REDIS_CONNECTION_TYPE=pooled
# Cache fill coordination is local to each process
REDIS_CACHE_LOCKING_STRATEGY=local
REDIS_URL=redis://localhost
REDIS_MIN_CONNECTIONS=0
REDIS_MAX_CONNECTIONS=10000
REDIS_BLOCKING_MAX_CONNECTIONS=8
# For a clustered Redis setup (`clustered-redis` Docker Compose profile)
# REDIS_MODE=cluster
# REDIS_CONNECTION_TYPE=multiplexed
# REDIS_URL=redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
KAFKA_BOOTSTRAP_SERVERS=localhost:19092
KAFKA_CLIENT_ID=labrinth
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET email_verified = TRUE WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "cb626a36deffd73e67de2dc4789bd875675779a173fb2cd41ba365c1acca96f9"
}
+10 -4
View File
@@ -41,7 +41,6 @@ color-eyre = { workspace = true }
color-thief = { workspace = true }
const_format = { workspace = true }
dashmap = { workspace = true }
deadpool-redis.workspace = true
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
dotenvy = { workspace = true }
either = { workspace = true }
@@ -73,7 +72,6 @@ image = { workspace = true, features = [
itertools = { workspace = true }
json-patch = { workspace = true }
lettre = { workspace = true }
lz4_flex = { workspace = true }
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
modrinth-content-management = { workspace = true }
modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] }
@@ -88,7 +86,13 @@ quick-xml = { workspace = true }
rand = { workspace = true }
rand_chacha = { workspace = true }
rdkafka = { workspace = true }
redis = { workspace = true, features = ["ahash", "r2d2", "tokio-comp"] }
redis = { workspace = true, features = [
"ahash",
"cluster",
"cluster-async",
"r2d2",
"tokio-comp"
] }
regex = { workspace = true }
reqwest = { workspace = true, features = [
"http2",
@@ -141,12 +145,14 @@ webauthn-rs = { workspace = true, features = [
webauthn-rs-proto = { workspace = true }
webp = { workspace = true }
woothee = { workspace = true }
xredis = { workspace = true }
yaserde = { workspace = true, features = ["derive"] }
zip = { workspace = true }
zxcvbn = { workspace = true }
[dev-dependencies]
labrinth = { path = ".", features = ["test"] }
tokio = { workspace = true, features = ["test-util"] }
[build-dependencies]
chrono = { workspace = true }
@@ -162,7 +168,7 @@ tikv-jemallocator = { workspace = true, features = [
] }
[features]
test = []
test = ["xredis/test"]
[lints]
workspace = true
+1 -1
View File
@@ -2,7 +2,6 @@ use crate::database;
use crate::database::models::project_item::ProjectQueryResult;
use crate::database::models::version_item::VersionQueryResult;
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
use crate::database::redis::RedisPool;
use crate::database::{DBProject, DBVersion, models};
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::models::ids::FileId;
@@ -14,6 +13,7 @@ use crate::queue::file_scan::get_files_missing_attribution;
use crate::routes::ApiError;
use futures::TryStreamExt;
use itertools::Itertools;
use xredis::RedisPool;
pub fn require_verified_email(user: &User) -> Result<(), ApiError> {
if !user.email_verified.unwrap_or(false) {
+6
View File
@@ -59,6 +59,12 @@ pub enum AuthenticationError {
Url,
}
impl From<xredis::Error> for AuthenticationError {
fn from(error: xredis::Error) -> Self {
Self::Database(error.into())
}
}
impl actix_web::ResponseError for AuthenticationError {
fn status_code(&self) -> StatusCode {
match self {
+1 -1
View File
@@ -12,7 +12,6 @@ use crate::database::models::{
DBOAuthClientAuthorizationId, generate_oauth_access_token_id,
generate_oauth_client_authorization_id,
};
use crate::database::redis::RedisPool;
use crate::models;
use crate::models::ids::OAuthClientId;
use crate::models::pats::Scopes;
@@ -25,6 +24,7 @@ use rand::distributions::Alphanumeric;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
use self::errors::{OAuthError, OAuthErrorType};
+1 -1
View File
@@ -1,7 +1,6 @@
use super::AuthProvider;
use crate::auth::AuthenticationError;
use crate::database::models::{DBUser, user_item};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::pats::Scopes;
use crate::models::users::User;
@@ -10,6 +9,7 @@ use crate::routes::internal::session::get_session_metadata;
use actix_web::HttpRequest;
use actix_web::http::header::{AUTHORIZATION, HeaderValue};
use chrono::Utc;
use xredis::RedisPool;
pub async fn get_maybe_user_from_headers<'a, E>(
req: &HttpRequest,
+2 -2
View File
@@ -2,7 +2,6 @@ use crate::database;
use crate::database::PgPool;
use crate::database::models::ids::DBUserId;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::notifications::NotificationBody;
use crate::queue::analytics::cache::cache_analytics;
@@ -20,6 +19,7 @@ use actix_web::web;
use clap::ValueEnum;
use eyre::WrapErr;
use tracing::info;
use xredis::RedisPool;
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
#[clap(rename_all = "kebab_case")]
@@ -386,11 +386,11 @@ mod version_updater {
use crate::database::PgPool;
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::redis::RedisPool;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use thiserror::Error;
use tracing::warn;
use xredis::RedisPool;
#[derive(Deserialize)]
struct InputFormat<'a> {
@@ -1,17 +1,15 @@
use chrono::{DateTime, Utc};
use futures::{StreamExt, TryStreamExt};
use sqlx::types::Json;
use xredis::RedisPool;
use crate::{
database::{
models::{DBAnalyticsEventId, DatabaseError},
redis::RedisPool,
},
database::models::{DBAnalyticsEventId, DatabaseError},
models::v3::analytics_event::AnalyticsEventMeta,
};
use serde::{Deserialize, Serialize};
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v1";
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v3";
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -86,14 +84,11 @@ impl DBAnalyticsEvent {
redis: &RedisPool,
) -> Result<Vec<DBAnalyticsEvent>, DatabaseError> {
let mut redis = redis.connect().await?;
let key = redis
.key()
.metadata(ANALYTICS_EVENTS_NAMESPACE, ANALYTICS_EVENTS_ALL_KEY);
if let Some(events) = redis
.get_deserialized(
ANALYTICS_EVENTS_NAMESPACE,
ANALYTICS_EVENTS_ALL_KEY,
)
.await?
{
if let Some(events) = redis.get_deserialized(&key).await? {
return Ok(events);
}
@@ -118,23 +113,17 @@ impl DBAnalyticsEvent {
.try_collect::<Vec<_>>()
.await?;
redis
.set_serialized(
ANALYTICS_EVENTS_NAMESPACE,
ANALYTICS_EVENTS_ALL_KEY,
&events,
None,
)
.await?;
redis.set_serialized(&key, &events, None).await?;
Ok(events)
}
pub async fn clear_cache(redis: &RedisPool) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete(ANALYTICS_EVENTS_NAMESPACE, ANALYTICS_EVENTS_ALL_KEY)
.await?;
let key = redis
.key()
.metadata(ANALYTICS_EVENTS_NAMESPACE, ANALYTICS_EVENTS_ALL_KEY);
redis.delete(&key).await?;
Ok(())
}
}
+19 -24
View File
@@ -1,13 +1,13 @@
use std::collections::HashMap;
use crate::database::redis::RedisPool;
use xredis::RedisPool;
use super::DatabaseError;
use super::ids::*;
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
const TAGS_NAMESPACE: &str = "tags:v1";
const TAGS_NAMESPACE: &str = "tags:v3";
pub struct ProjectType {
pub id: ProjectTypeId,
@@ -95,9 +95,10 @@ impl Category {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "category");
let res: Option<Vec<Category>> =
redis.get_deserialized(TAGS_NAMESPACE, "category").await?;
redis.get_deserialized(&key).await?;
if let Some(res) = res {
return Ok(res);
@@ -124,10 +125,9 @@ impl Category {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "category");
redis
.set_serialized(TAGS_NAMESPACE, "category", &result, None)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -163,10 +163,10 @@ impl LinkPlatform {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "link_platform");
let res: Option<Vec<LinkPlatform>> = redis
.get_deserialized(TAGS_NAMESPACE, "link_platform")
.await?;
let res: Option<Vec<LinkPlatform>> =
redis.get_deserialized(&key).await?;
if let Some(res) = res {
return Ok(res);
@@ -188,10 +188,9 @@ impl LinkPlatform {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "link_platform");
redis
.set_serialized(TAGS_NAMESPACE, "link_platform", &result, None)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -227,10 +226,9 @@ impl ReportType {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "report_type");
let res: Option<Vec<String>> = redis
.get_deserialized(TAGS_NAMESPACE, "report_type")
.await?;
let res: Option<Vec<String>> = redis.get_deserialized(&key).await?;
if let Some(res) = res {
return Ok(res);
@@ -248,10 +246,9 @@ impl ReportType {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "report_type");
redis
.set_serialized(TAGS_NAMESPACE, "report_type", &result, None)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -287,10 +284,9 @@ impl ProjectType {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "project_type");
let res: Option<Vec<String>> = redis
.get_deserialized(TAGS_NAMESPACE, "project_type")
.await?;
let res: Option<Vec<String>> = redis.get_deserialized(&key).await?;
if let Some(res) = res {
return Ok(res);
@@ -308,10 +304,9 @@ impl ProjectType {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TAGS_NAMESPACE, "project_type");
redis
.set_serialized(TAGS_NAMESPACE, "project_type", &result, None)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -1,14 +1,14 @@
use super::ids::*;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
use crate::models::collections::CollectionStatus;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const COLLECTIONS_NAMESPACE: &str = "collections:v1";
const COLLECTIONS_NAMESPACE: &str = "collections:v3";
#[derive(Clone)]
pub struct CollectionBuilder {
@@ -204,7 +204,7 @@ impl DBCollection {
})
.await?;
Ok(collections)
Ok::<_, DatabaseError>(collections)
},
)
.await?;
@@ -217,8 +217,9 @@ impl DBCollection {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let key = redis.key().entity(COLLECTIONS_NAMESPACE, id.0);
redis.delete(COLLECTIONS_NAMESPACE, id.0).await?;
redis.delete(&key).await?;
Ok(())
}
}
+8 -10
View File
@@ -1,7 +1,6 @@
use super::ids::*;
use crate::auth::oauth::uris::OAuthRedirectUris;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::{auth::AuthProvider, routes::internal::flows::TempUser};
use chrono::Duration;
@@ -12,8 +11,9 @@ use rand_chacha::rand_core::SeedableRng;
use serde::{Deserialize, Serialize};
use url::Url;
use webauthn_rs::prelude::{DiscoverableAuthentication, PasskeyRegistration};
use xredis::RedisPool;
const FLOWS_NAMESPACE: &str = "flows:v1";
const FLOWS_NAMESPACE: &str = "flows:v3";
#[derive(Deserialize, Serialize)]
pub enum DBFlow {
@@ -75,14 +75,10 @@ impl DBFlow {
state: &str,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let key = redis.key().entity(FLOWS_NAMESPACE, state);
redis
.set_serialized(
FLOWS_NAMESPACE,
&state,
&self,
Some(expires.num_seconds()),
)
.set_serialized(&key, &self, Some(expires.num_seconds()))
.await?;
Ok(())
}
@@ -107,8 +103,9 @@ impl DBFlow {
redis: &RedisPool,
) -> Result<Option<DBFlow>, DatabaseError> {
let mut redis = redis.connect().await?;
let key = redis.key().entity(FLOWS_NAMESPACE, id);
redis.get_deserialized(FLOWS_NAMESPACE, id).await
redis.get_deserialized(&key).await.map_err(Into::into)
}
/// Gets the flow and removes it from the cache, but only removes if the flow was present and the predicate returned true
@@ -132,8 +129,9 @@ impl DBFlow {
redis: &RedisPool,
) -> Result<Option<()>, DatabaseError> {
let mut redis = redis.connect().await?;
let key = redis.key().entity(FLOWS_NAMESPACE, id);
redis.delete(FLOWS_NAMESPACE, id).await?;
redis.delete(&key).await?;
Ok(Some(()))
}
}
@@ -1,12 +1,12 @@
use super::ids::*;
use crate::database::PgTransaction;
use crate::database::redis::RedisPool;
use crate::{database::models::DatabaseError, models::images::ImageContext};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const IMAGES_NAMESPACE: &str = "images:v1";
const IMAGES_NAMESPACE: &str = "images:v3";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DBImage {
@@ -217,7 +217,7 @@ impl DBImage {
})
.await?;
Ok(images)
Ok::<_, DatabaseError>(images)
},
).await?;
@@ -229,8 +229,9 @@ impl DBImage {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let key = redis.key().entity(IMAGES_NAMESPACE, id.0);
redis.delete(IMAGES_NAMESPACE, id.0).await?;
redis.delete(&key).await?;
Ok(())
}
}
@@ -9,7 +9,7 @@ use itertools::Itertools;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::database::redis::RedisPool;
use xredis::RedisPool;
use super::{
DatabaseError, LoaderFieldEnumValueId,
@@ -220,11 +220,11 @@ impl<'a> MinecraftGameVersionBuilder<'a> {
.await?;
let mut conn = redis.connect().await?;
conn.delete(
let key = conn.key().entity(
crate::database::models::loader_fields::LOADER_FIELD_ENUM_VALUES_NAMESPACE,
game_versions_enum.id.0,
)
.await?;
);
conn.delete(&key).await?;
Ok(LoaderFieldEnumValueId(result.id))
}
@@ -4,22 +4,22 @@ use std::hash::Hasher;
use super::DatabaseError;
use super::ids::*;
use crate::database::PgTransaction;
use crate::database::redis::RedisPool;
use chrono::DateTime;
use chrono::Utc;
use dashmap::DashMap;
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const GAMES_LIST_NAMESPACE: &str = "games:v1";
const LOADER_ID: &str = "loader_id:v1";
const LOADERS_LIST_NAMESPACE: &str = "loaders:v1";
const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v1";
const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v1";
const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v1";
const GAMES_LIST_NAMESPACE: &str = "games:v3";
const LOADER_ID: &str = "loader_id:v3";
const LOADERS_LIST_NAMESPACE: &str = "loaders:v3";
const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v3";
const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v3";
const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v3";
pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str =
"loader_field_enum_values:v1";
"loader_field_enum_values:v3";
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct Game {
@@ -54,9 +54,9 @@ impl Game {
{
{
let mut redis = redis.connect().await?;
let cached_games: Option<Vec<Game>> = redis
.get_deserialized(GAMES_LIST_NAMESPACE, "games")
.await?;
let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games");
let cached_games: Option<Vec<Game>> =
redis.get_deserialized(&key).await?;
if let Some(cached_games) = cached_games {
return Ok(cached_games);
}
@@ -79,10 +79,9 @@ impl Game {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().metadata(GAMES_LIST_NAMESPACE, "games");
redis
.set_serialized(GAMES_LIST_NAMESPACE, "games", &result, None)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -109,8 +108,8 @@ impl Loader {
{
{
let mut redis = redis.connect().await?;
let cached_id: Option<i32> =
redis.get_deserialized(LOADER_ID, name).await?;
let key = redis.key().metadata(LOADER_ID, name);
let cached_id: Option<i32> = redis.get_deserialized(&key).await?;
if let Some(cached_id) = cached_id {
return Ok(Some(LoaderId(cached_id)));
}
@@ -129,9 +128,8 @@ impl Loader {
if let Some(result) = result {
let mut redis = redis.connect().await?;
redis
.set_serialized(LOADER_ID, name, &result.0, None)
.await?;
let key = redis.key().metadata(LOADER_ID, name);
redis.set_serialized(&key, &result.0, None).await?;
}
Ok(result)
@@ -146,9 +144,9 @@ impl Loader {
{
{
let mut redis = redis.connect().await?;
let cached_loaders: Option<Vec<Loader>> = redis
.get_deserialized(LOADERS_LIST_NAMESPACE, "all")
.await?;
let key = redis.key().metadata(LOADERS_LIST_NAMESPACE, "all");
let cached_loaders: Option<Vec<Loader>> =
redis.get_deserialized(&key).await?;
if let Some(cached_loaders) = cached_loaders {
return Ok(cached_loaders);
}
@@ -187,10 +185,9 @@ impl Loader {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().metadata(LOADERS_LIST_NAMESPACE, "all");
redis
.set_serialized(LOADERS_LIST_NAMESPACE, "all", &result, None)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -441,7 +438,7 @@ impl LoaderField {
})
.await?;
Ok(result)
Ok::<_, DatabaseError>(result)
},
).await?;
@@ -460,10 +457,10 @@ impl LoaderField {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().metadata(LOADER_FIELDS_NAMESPACE_ALL, "");
let cached_fields: Option<Vec<LoaderField>> = redis
.get_deserialized(LOADER_FIELDS_NAMESPACE_ALL, "")
.await?;
let cached_fields: Option<Vec<LoaderField>> =
redis.get_deserialized(&key).await?;
if let Some(cached_fields) = cached_fields {
return Ok(cached_fields);
@@ -494,10 +491,9 @@ impl LoaderField {
.collect();
let mut redis = redis.connect().await?;
let key = redis.key().metadata(LOADER_FIELDS_NAMESPACE_ALL, "");
redis
.set_serialized(LOADER_FIELDS_NAMESPACE_ALL, "", &result, None)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -513,10 +509,11 @@ impl LoaderFieldEnum {
{
{
let mut redis = redis.connect().await?;
let key = redis
.key()
.metadata(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name);
let cached_enum = redis
.get_deserialized(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name)
.await?;
let cached_enum = redis.get_deserialized(&key).await?;
if let Some(cached_enum) = cached_enum {
return Ok(cached_enum);
}
@@ -541,15 +538,11 @@ impl LoaderFieldEnum {
});
let mut redis = redis.connect().await?;
let key = redis
.key()
.metadata(LOADER_FIELD_ENUMS_ID_NAMESPACE, enum_name);
redis
.set_serialized(
LOADER_FIELD_ENUMS_ID_NAMESPACE,
enum_name,
&result,
None,
)
.await?;
redis.set_serialized(&key, &result, None).await?;
Ok(result)
}
@@ -652,7 +645,7 @@ impl LoaderFieldEnumValue {
})
.await?;
Ok(values)
Ok::<_, DatabaseError>(values)
},
).await?;
+2 -15
View File
@@ -72,25 +72,12 @@ pub enum DatabaseError {
RandomId,
#[error("Error while interacting with the cache: {0}")]
CacheError(#[from] redis::RedisError),
#[error("Redis Pool Error: {0}")]
RedisPool(#[from] deadpool_redis::PoolError),
#[error("Error while serializing with the cache: {0}")]
SerdeCacheError(#[from] serde_json::Error),
#[error("error while encoding or decoding the cache: {0}")]
PostcardCacheError(#[from] postcard::Error),
#[error(transparent)]
Redis(#[from] xredis::Error),
#[error("Schema error: {0}")]
SchemaError(String),
#[error(
"Timeout waiting on Redis cache lock ({locks_released}/{locks_waiting} released, spent {time_spent_pool_wait_ms}ms/{time_spent_total_ms}ms waiting on connections from pool)"
)]
CacheTimeout {
locks_released: usize,
locks_waiting: usize,
time_spent_pool_wait_ms: u64,
time_spent_total_ms: u64,
},
#[error(
"Timeout waiting on local cache lock ({released}/{total} released)"
)]
LocalCacheTimeout { released: usize, total: usize },
}
@@ -3,13 +3,13 @@ use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::database::redis::RedisPool;
use xredis::RedisPool;
use super::{DBOrganizationId, DBUserId, DatabaseError};
const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v1";
const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v3";
const MODERATION_NOTES_ORGANIZATIONS_NAMESPACE: &str =
"moderation_notes_organizations:v1";
"moderation_notes_organizations:v3";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBModerationNote {
@@ -32,19 +32,15 @@ impl DBModerationNote {
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let ids = user_ids
.iter()
.map(|id| id.0.to_string())
.collect::<Vec<_>>();
let cached = {
let mut redis = redis.connect().await?;
redis
.get_many_deserialized::<Self>(
MODERATION_NOTES_USERS_NAMESPACE,
&ids,
)
.await?
let keys = user_ids
.iter()
.map(|id| {
redis.key().entity(MODERATION_NOTES_USERS_NAMESPACE, id.0)
})
.collect::<Vec<_>>();
redis.get_many_deserialized::<Self>(&keys).await?
};
let mut notes = HashMap::new();
@@ -86,14 +82,10 @@ impl DBModerationNote {
};
if let Some(user_id) = note.user_id {
redis
.set_serialized(
MODERATION_NOTES_USERS_NAMESPACE,
user_id.0,
&note,
None,
)
.await?;
let key = redis
.key()
.entity(MODERATION_NOTES_USERS_NAMESPACE, user_id.0);
redis.set_serialized(&key, &note, None).await?;
notes.insert(user_id, note);
}
}
@@ -122,19 +114,17 @@ impl DBModerationNote {
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let ids = organization_ids
.iter()
.map(|id| id.0.to_string())
.collect::<Vec<_>>();
let cached = {
let mut redis = redis.connect().await?;
redis
.get_many_deserialized::<Self>(
MODERATION_NOTES_ORGANIZATIONS_NAMESPACE,
&ids,
)
.await?
let keys = organization_ids
.iter()
.map(|id| {
redis
.key()
.entity(MODERATION_NOTES_ORGANIZATIONS_NAMESPACE, id.0)
})
.collect::<Vec<_>>();
redis.get_many_deserialized::<Self>(&keys).await?
};
let mut notes = HashMap::new();
@@ -176,14 +166,11 @@ impl DBModerationNote {
};
if let Some(organization_id) = note.organization_id {
redis
.set_serialized(
MODERATION_NOTES_ORGANIZATIONS_NAMESPACE,
organization_id.0,
&note,
None,
)
.await?;
let key = redis.key().entity(
MODERATION_NOTES_ORGANIZATIONS_NAMESPACE,
organization_id.0,
);
redis.set_serialized(&key, &note, None).await?;
notes.insert(organization_id, note);
}
}
@@ -289,9 +276,10 @@ impl DBModerationNote {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete(MODERATION_NOTES_USERS_NAMESPACE, user_id.0)
.await
let key = redis
.key()
.entity(MODERATION_NOTES_USERS_NAMESPACE, user_id.0);
redis.delete(&key).await.map_err(Into::into)
}
pub async fn clear_organization_cache(
@@ -299,8 +287,10 @@ impl DBModerationNote {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete(MODERATION_NOTES_ORGANIZATIONS_NAMESPACE, organization_id.0)
.await
let key = redis.key().entity(
MODERATION_NOTES_ORGANIZATIONS_NAMESPACE,
organization_id.0,
);
redis.delete(&key).await.map_err(Into::into)
}
}
@@ -1,6 +1,6 @@
use super::ids::*;
use crate::database::PgTransaction;
use crate::database::{models::DatabaseError, redis::RedisPool};
use crate::database::models::DatabaseError;
use crate::models::notifications::{
NotificationBody, NotificationChannel, NotificationDeliveryStatus,
NotificationType,
@@ -8,8 +8,9 @@ use crate::models::notifications::{
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v1";
const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v3";
pub struct NotificationBuilder {
pub body: NotificationBody,
@@ -433,13 +434,11 @@ impl DBNotification {
{
{
let mut redis = redis.connect().await?;
let key =
redis.key().entity(USER_NOTIFICATIONS_NAMESPACE, user_id.0);
let cached_notifications: Option<Vec<DBNotification>> = redis
.get_deserialized(
USER_NOTIFICATIONS_NAMESPACE,
&user_id.0.to_string(),
)
.await?;
let cached_notifications: Option<Vec<DBNotification>> =
redis.get_deserialized(&key).await?;
if let Some(notifications) = cached_notifications {
return Ok(notifications);
@@ -491,15 +490,9 @@ impl DBNotification {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().entity(USER_NOTIFICATIONS_NAMESPACE, user_id.0);
redis
.set_serialized(
USER_NOTIFICATIONS_NAMESPACE,
user_id.0,
&db_notifications,
None,
)
.await?;
redis.set_serialized(&key, &db_notifications, None).await?;
Ok(db_notifications)
}
@@ -638,12 +631,12 @@ impl DBNotification {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let keys = user_ids
.into_iter()
.map(|id| redis.key().entity(USER_NOTIFICATIONS_NAMESPACE, id.0))
.collect::<Vec<_>>();
redis
.delete_many(user_ids.into_iter().map(|id| {
(USER_NOTIFICATIONS_NAMESPACE, Some(id.0.to_string()))
}))
.await?;
redis.delete_many(&keys).await?;
Ok(())
}
@@ -1,14 +1,14 @@
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::v3::notifications::{NotificationChannel, NotificationType};
use crate::routes::ApiError;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const TEMPLATES_NAMESPACE: &str = "notifications_templates:v1";
const TEMPLATES_NAMESPACE: &str = "notifications_templates:v3";
const TEMPLATES_HTML_DATA_NAMESPACE: &str =
"notifications_templates_html_data:v1";
"notifications_templates_html_data:v3";
const TEMPLATES_DYNAMIC_HTML_NAMESPACE: &str =
"notifications_templates_dynamic_html:v1";
"notifications_templates_dynamic_html:v3";
const HTML_DATA_CACHE_EXPIRY: i64 = 60 * 15; // 15 minutes
const TEMPLATES_CACHE_EXPIRY: i64 = 60 * 30; // 30 minutes
@@ -55,10 +55,10 @@ impl NotificationTemplate {
) -> Result<Vec<NotificationTemplate>, DatabaseError> {
{
let mut redis = redis.connect().await?;
let key =
redis.key().metadata(TEMPLATES_NAMESPACE, channel.as_str());
let maybe_cached_templates = redis
.get_deserialized(TEMPLATES_NAMESPACE, channel.as_str())
.await?;
let maybe_cached_templates = redis.get_deserialized(&key).await?;
if let Some(cached) = maybe_cached_templates {
return Ok(cached);
@@ -78,14 +78,10 @@ impl NotificationTemplate {
let templates = results.into_iter().map(Into::into).collect();
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TEMPLATES_NAMESPACE, channel.as_str());
redis
.set_serialized(
TEMPLATES_NAMESPACE,
channel.as_str(),
&templates,
Some(TEMPLATES_CACHE_EXPIRY),
)
.set_serialized(&key, &templates, Some(TEMPLATES_CACHE_EXPIRY))
.await?;
Ok(templates)
@@ -96,12 +92,8 @@ impl NotificationTemplate {
redis: &RedisPool,
) -> Result<Option<String>, DatabaseError> {
let mut redis = redis.connect().await?;
redis
.get_deserialized(
TEMPLATES_HTML_DATA_NAMESPACE,
&self.id.to_string(),
)
.await
let key = redis.key().metadata(TEMPLATES_HTML_DATA_NAMESPACE, self.id);
redis.get_deserialized(&key).await.map_err(Into::into)
}
pub async fn set_cached_html_data(
@@ -110,14 +102,11 @@ impl NotificationTemplate {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let key = redis.key().metadata(TEMPLATES_HTML_DATA_NAMESPACE, self.id);
redis
.set_serialized(
TEMPLATES_HTML_DATA_NAMESPACE,
&self.id.to_string(),
&data,
Some(HTML_DATA_CACHE_EXPIRY),
)
.set_serialized(&key, &data, Some(HTML_DATA_CACHE_EXPIRY))
.await
.map_err(Into::into)
}
}
@@ -135,9 +124,11 @@ where
}
let mut redis_conn = redis.connect().await?;
if let Some(body) = redis_conn
.get_deserialized::<HtmlBody>(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key)
.await?
let redis_key = redis_conn
.key()
.metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key);
if let Some(body) =
redis_conn.get_deserialized::<HtmlBody>(&redis_key).await?
{
return Ok(body.html);
}
@@ -146,14 +137,12 @@ where
let cached = HtmlBody { html: get().await? };
let mut redis_conn = redis.connect().await?;
let redis_key = redis_conn
.key()
.metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key);
redis_conn
.set_serialized(
TEMPLATES_DYNAMIC_HTML_NAMESPACE,
key,
&cached,
Some(HTML_DATA_CACHE_EXPIRY),
)
.set_serialized(&redis_key, &cached, Some(HTML_DATA_CACHE_EXPIRY))
.await?;
Ok(cached.html)
@@ -1,9 +1,9 @@
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::v3::notifications::NotificationType;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v1";
const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v3";
#[derive(Serialize, Deserialize)]
pub struct NotificationTypeItem {
@@ -41,10 +41,9 @@ impl NotificationTypeItem {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().metadata(NOTIFICATION_TYPES_NAMESPACE, "all");
let cached_types = redis
.get_deserialized(NOTIFICATION_TYPES_NAMESPACE, "all")
.await?;
let cached_types = redis.get_deserialized(&key).await?;
if let Some(types) = cached_types {
return Ok(types);
@@ -61,10 +60,9 @@ impl NotificationTypeItem {
let types = results.into_iter().map(Into::into).collect();
let mut redis = redis.connect().await?;
let key = redis.key().metadata(NOTIFICATION_TYPES_NAMESPACE, "all");
redis
.set_serialized(NOTIFICATION_TYPES_NAMESPACE, "all", &types, None)
.await?;
redis.set_serialized(&key, &types, None).await?;
Ok(types)
}
@@ -1,16 +1,16 @@
use crate::database::PgTransaction;
use crate::database::redis::RedisPool;
use ariadne::ids::base62_impl::parse_base62;
use dashmap::DashMap;
use futures::TryStreamExt;
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
use super::{DBTeamMember, ids::*};
use serde::{Deserialize, Serialize};
const ORGANIZATIONS_NAMESPACE: &str = "organizations:v1";
const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v1";
const ORGANIZATIONS_NAMESPACE: &str = "organizations:v3";
const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v3";
#[derive(Deserialize, Serialize, Clone, Debug)]
/// An organization of users who together control one or more projects and organizations.
@@ -159,7 +159,9 @@ impl DBOrganization {
})
.await?;
Ok(organizations)
Ok::<_, crate::database::models::DatabaseError>(
organizations,
)
},
)
.await?;
@@ -256,16 +258,17 @@ impl DBOrganization {
redis: &RedisPool,
) -> Result<(), super::DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete_many([
(ORGANIZATIONS_NAMESPACE, Some(id.0.to_string())),
(
let mut keys = vec![redis.key().entity(ORGANIZATIONS_NAMESPACE, id.0)];
if let Some(slug) = slug {
keys.push(
redis.key().entity(
ORGANIZATIONS_TITLES_NAMESPACE,
slug.map(|x| x.to_lowercase()),
slug.to_lowercase(),
),
])
.await?;
);
}
redis.delete_many(&keys).await?;
Ok(())
}
}
+26 -28
View File
@@ -1,7 +1,6 @@
use super::ids::*;
use crate::database::PgTransaction;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Utc};
@@ -10,10 +9,11 @@ use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
const PATS_NAMESPACE: &str = "pats:v1";
const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v1";
const PATS_USERS_NAMESPACE: &str = "pats_users:v1";
const PATS_NAMESPACE: &str = "pats:v3";
const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v3";
const PATS_USERS_NAMESPACE: &str = "pats_users:v3";
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBPersonalAccessToken {
@@ -141,7 +141,7 @@ impl DBPersonalAccessToken {
async move { Ok(acc) }
})
.await?;
Ok(pats)
Ok::<_, DatabaseError>(pats)
},
)
.await?;
@@ -159,13 +159,9 @@ impl DBPersonalAccessToken {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().entity(PATS_USERS_NAMESPACE, user_id.0);
let res = redis
.get_deserialized::<Vec<i64>>(
PATS_USERS_NAMESPACE,
&user_id.0.to_string(),
)
.await?;
let res = redis.get_deserialized::<Vec<i64>>(&key).await?;
if let Some(res) = res {
return Ok(res.into_iter().map(DBPatId).collect());
@@ -187,10 +183,9 @@ impl DBPersonalAccessToken {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().entity(PATS_USERS_NAMESPACE, user_id.0);
redis
.set_serialized(PATS_USERS_NAMESPACE, user_id.0, &db_pats, None)
.await?;
redis.set_serialized(&key, &db_pats, None).await?;
Ok(db_pats)
}
@@ -204,20 +199,23 @@ impl DBPersonalAccessToken {
return Ok(());
}
redis
.delete_many(clear_pats.into_iter().flat_map(
|(id, token, user_id)| {
[
(PATS_NAMESPACE, id.map(|i| i.0.to_string())),
(PATS_TOKENS_NAMESPACE, token),
(
PATS_USERS_NAMESPACE,
user_id.map(|i| i.0.to_string()),
),
]
},
))
.await?;
let keys = clear_pats
.into_iter()
.flat_map(|(id, token, user_id)| {
[
id.map(|id| redis.key().entity(PATS_NAMESPACE, id.0)),
token.map(|token| {
redis.key().entity(PATS_TOKENS_NAMESPACE, token)
}),
user_id.map(|user_id| {
redis.key().entity(PATS_USERS_NAMESPACE, user_id.0)
}),
]
.into_iter()
.flatten()
})
.collect::<Vec<_>>();
redis.delete_many(&keys).await?;
Ok(())
}
@@ -1,15 +1,15 @@
use crate::database::models::{
DBProductId, DBProductPriceId, DatabaseError, product_item,
};
use crate::database::redis::RedisPool;
use crate::models::billing::{Price, ProductMetadata};
use dashmap::DashMap;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use std::convert::TryInto;
use xredis::RedisPool;
const PRODUCTS_NAMESPACE: &str = "products:v1";
const PRODUCTS_NAMESPACE: &str = "products:v3";
pub struct DBProduct {
pub id: DBProductId,
@@ -152,9 +152,10 @@ impl QueryProductWithPrices {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().metadata(PRODUCTS_NAMESPACE, "all");
let res: Option<Vec<QueryProductWithPrices>> =
redis.get_deserialized(PRODUCTS_NAMESPACE, "all").await?;
redis.get_deserialized(&key).await?;
if let Some(res) = res {
return Ok(res);
@@ -193,10 +194,9 @@ impl QueryProductWithPrices {
.collect::<Vec<_>>();
let mut redis = redis.connect().await?;
let key = redis.key().metadata(PRODUCTS_NAMESPACE, "all");
redis
.set_serialized(PRODUCTS_NAMESPACE, "all", &products, None)
.await?;
redis.set_serialized(&key, &products, None).await?;
Ok(products)
}
@@ -4,7 +4,6 @@ use super::loader_fields::{
};
use super::{DBUser, ids::*};
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
use crate::file_hosting::FileHost;
use crate::models::exp;
@@ -22,10 +21,11 @@ use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
pub const PROJECTS_NAMESPACE: &str = "projects:v1";
pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v1";
const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v1";
pub const PROJECTS_NAMESPACE: &str = "projects:v3";
pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v3";
const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v3";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LinkUrl {
@@ -942,7 +942,7 @@ impl DBProject {
})
?;
Ok(projects)
Ok::<_, DatabaseError>(projects)
},
)
.await
@@ -974,13 +974,10 @@ impl DBProject {
{
let mut redis = redis.connect().await?;
let key = redis.key().entity(PROJECTS_DEPENDENCIES_NAMESPACE, id.0);
let dependencies = redis
.get_deserialized::<Dependencies>(
PROJECTS_DEPENDENCIES_NAMESPACE,
&id.0.to_string(),
)
.await?;
let dependencies =
redis.get_deserialized::<Dependencies>(&key).await?;
if let Some(dependencies) = dependencies {
return Ok(dependencies);
}
@@ -1012,15 +1009,9 @@ impl DBProject {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().entity(PROJECTS_DEPENDENCIES_NAMESPACE, id.0);
redis
.set_serialized(
PROJECTS_DEPENDENCIES_NAMESPACE,
id.0,
&dependencies,
None,
)
.await?;
redis.set_serialized(&key, &dependencies, None).await?;
Ok(dependencies)
}
@@ -1031,21 +1022,21 @@ impl DBProject {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let mut keys = vec![redis.key().entity(PROJECTS_NAMESPACE, id.0)];
if let Some(slug) = slug {
keys.push(
redis
.key()
.entity(PROJECTS_SLUGS_NAMESPACE, slug.to_lowercase()),
);
}
if clear_dependencies.unwrap_or(false) {
keys.push(
redis.key().entity(PROJECTS_DEPENDENCIES_NAMESPACE, id.0),
);
}
redis
.delete_many([
(PROJECTS_NAMESPACE, Some(id.0.to_string())),
(PROJECTS_SLUGS_NAMESPACE, slug.map(|x| x.to_lowercase())),
(
PROJECTS_DEPENDENCIES_NAMESPACE,
if clear_dependencies.unwrap_or(false) {
Some(id.0.to_string())
} else {
None
},
),
])
.await?;
redis.delete_many(&keys).await?;
Ok(())
}
}
@@ -1,7 +1,6 @@
use super::ids::*;
use crate::database::PgTransaction;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
@@ -9,10 +8,11 @@ use futures_util::TryStreamExt;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
const SESSIONS_NAMESPACE: &str = "sessions:v1";
const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v1";
const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v1";
const SESSIONS_NAMESPACE: &str = "sessions:v3";
const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v3";
const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v3";
pub struct SessionBuilder {
pub session: String,
@@ -208,7 +208,7 @@ impl DBSession {
})
.await?;
Ok(db_sessions)
Ok::<_, DatabaseError>(db_sessions)
}).await?;
Ok(val)
@@ -224,13 +224,9 @@ impl DBSession {
{
{
let mut redis = redis.connect().await?;
let key = redis.key().entity(SESSIONS_USERS_NAMESPACE, user_id.0);
let res = redis
.get_deserialized::<Vec<i64>>(
SESSIONS_USERS_NAMESPACE,
&user_id.0.to_string(),
)
.await?;
let res = redis.get_deserialized::<Vec<i64>>(&key).await?;
if let Some(res) = res {
return Ok(res.into_iter().map(DBSessionId).collect());
@@ -253,15 +249,9 @@ impl DBSession {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().entity(SESSIONS_USERS_NAMESPACE, user_id.0);
redis
.set_serialized(
SESSIONS_USERS_NAMESPACE,
user_id.0,
&db_sessions,
None,
)
.await?;
redis.set_serialized(&key, &db_sessions, None).await?;
Ok(db_sessions)
}
@@ -280,20 +270,23 @@ impl DBSession {
return Ok(());
}
redis
.delete_many(clear_sessions.into_iter().flat_map(
|(id, session, user_id)| {
[
(SESSIONS_NAMESPACE, id.map(|i| i.0.to_string())),
(SESSIONS_IDS_NAMESPACE, session),
(
SESSIONS_USERS_NAMESPACE,
user_id.map(|i| i.0.to_string()),
),
]
},
))
.await?;
let keys = clear_sessions
.into_iter()
.flat_map(|(id, session, user_id)| {
[
id.map(|id| redis.key().entity(SESSIONS_NAMESPACE, id.0)),
session.map(|session| {
redis.key().entity(SESSIONS_IDS_NAMESPACE, session)
}),
user_id.map(|user_id| {
redis.key().entity(SESSIONS_USERS_NAMESPACE, user_id.0)
}),
]
.into_iter()
.flatten()
})
.collect::<Vec<_>>();
redis.delete_many(&keys).await?;
Ok(())
}
@@ -1,6 +1,6 @@
use super::{DBOrganization, DBProject, ids::*};
use crate::{
database::{PgTransaction, redis::RedisPool},
database::PgTransaction,
models::teams::{OrganizationPermissions, ProjectPermissions},
};
use dashmap::DashMap;
@@ -8,8 +8,9 @@ use futures::TryStreamExt;
use itertools::Itertools;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
const TEAMS_NAMESPACE: &str = "teams:v1";
const TEAMS_NAMESPACE: &str = "teams:v3";
pub struct TeamBuilder {
pub members: Vec<TeamMemberBuilder>,
@@ -253,7 +254,7 @@ impl DBTeamMember {
})
.await?;
Ok(teams)
Ok::<_, crate::database::models::DatabaseError>(teams)
},
).await?;
@@ -265,7 +266,8 @@ impl DBTeamMember {
redis: &RedisPool,
) -> Result<(), super::DatabaseError> {
let mut redis = redis.connect().await?;
redis.delete(TEAMS_NAMESPACE, id.0).await?;
let key = redis.key().entity(TEAMS_NAMESPACE, id.0);
redis.delete(&key).await?;
Ok(())
}
+31 -36
View File
@@ -3,7 +3,6 @@ use super::{DBCollectionId, DBReportId, DBThreadId};
use crate::database::models::charge_item::DBCharge;
use crate::database::models::user_subscription_item::DBUserSubscription;
use crate::database::models::{DBOrganizationId, DatabaseError};
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
use crate::models::billing::ChargeStatus;
use crate::models::users::Badges;
@@ -15,10 +14,11 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::hash::Hash;
use xredis::RedisPool;
const USERS_NAMESPACE: &str = "users:v1";
const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v1";
const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v1";
const USERS_NAMESPACE: &str = "users:v3";
const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v3";
const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v3";
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBUser {
@@ -273,7 +273,7 @@ impl DBUser {
})
.await?;
Ok(users)
Ok::<_, DatabaseError>(users)
}).await?;
Ok(val)
}
@@ -389,13 +389,10 @@ impl DBUser {
{
let mut redis = redis.connect().await?;
let key = redis.key().entity(USERS_PROJECTS_NAMESPACE, user_id.0);
let cached_projects = redis
.get_deserialized::<Vec<DBProjectId>>(
USERS_PROJECTS_NAMESPACE,
&user_id.0.to_string(),
)
.await?;
let cached_projects =
redis.get_deserialized::<Vec<DBProjectId>>(&key).await?;
if let Some(projects) = cached_projects {
return Ok(projects);
@@ -417,15 +414,9 @@ impl DBUser {
.await?;
let mut redis = redis.connect().await?;
let key = redis.key().entity(USERS_PROJECTS_NAMESPACE, user_id.0);
redis
.set_serialized(
USERS_PROJECTS_NAMESPACE,
user_id.0,
&db_projects,
None,
)
.await?;
redis.set_serialized(&key, &db_projects, None).await?;
Ok(db_projects)
}
@@ -556,18 +547,24 @@ impl DBUser {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete_many(user_ids.iter().flat_map(|(id, username)| {
let keys = user_ids
.iter()
.flat_map(|(id, username)| {
[
(USERS_NAMESPACE, Some(id.0.to_string())),
(
USER_USERNAMES_NAMESPACE,
username.clone().map(|i| i.to_lowercase()),
),
Some(redis.key().entity(USERS_NAMESPACE, id.0)),
username.as_ref().map(|username| {
redis.key().entity(
USER_USERNAMES_NAMESPACE,
username.to_lowercase(),
)
}),
]
}))
.await?;
.into_iter()
.flatten()
})
.collect::<Vec<_>>();
redis.delete_many(&keys).await?;
Ok(())
}
@@ -576,14 +573,12 @@ impl DBUser {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let keys = user_ids
.iter()
.map(|id| redis.key().entity(USERS_PROJECTS_NAMESPACE, id.0))
.collect::<Vec<_>>();
redis
.delete_many(
user_ids.iter().map(|id| {
(USERS_PROJECTS_NAMESPACE, Some(id.0.to_string()))
}),
)
.await?;
redis.delete_many(&keys).await?;
Ok(())
}
@@ -1,4 +1,3 @@
use crate::database::PgTransaction;
use crate::database::models::{
DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError,
};
@@ -131,7 +130,7 @@ impl DBUserSubscription {
pub async fn upsert(
&self,
transaction: &mut PgTransaction<'_>,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
@@ -156,7 +155,7 @@ impl DBUserSubscription {
self.status.as_str(),
serde_json::to_value(&self.metadata)?,
)
.execute(&mut *transaction)
.execute(exec)
.await?;
Ok(())
@@ -5,9 +5,9 @@ use crate::database::PgTransaction;
use crate::database::models::loader_fields::{
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::exp;
use xredis::RedisPool;
use crate::models::projects::{FileType, VersionStatus};
use crate::queue::file_scan::scan_file;
@@ -19,11 +19,10 @@ use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::iter;
use tracing::error;
pub const VERSIONS_NAMESPACE: &str = "versions:v1";
const VERSION_FILES_NAMESPACE: &str = "versions_files:v1";
pub const VERSIONS_NAMESPACE: &str = "versions:v3";
const VERSION_FILES_NAMESPACE: &str = "versions_files:v3";
pub async fn cleanup_unused_attribution_files_and_groups(
transaction: &mut PgTransaction<'_>,
@@ -948,7 +947,7 @@ impl DBVersion {
})
.await?;
Ok(res)
Ok::<_, DatabaseError>(res)
},
).await?;
@@ -1039,7 +1038,7 @@ impl DBVersion {
})
.await?;
Ok(files)
Ok::<_, DatabaseError>(files)
}
).await?;
@@ -1051,25 +1050,18 @@ impl DBVersion {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let mut keys =
vec![redis.key().entity(VERSIONS_NAMESPACE, version.inner.id.0)];
keys.extend(version.files.iter().flat_map(|file| {
file.hashes.iter().map(|(algorithm, hash)| {
redis.key().entity(
VERSION_FILES_NAMESPACE,
format!("{algorithm}_{hash}"),
)
})
}));
redis
.delete_many(
iter::once((
VERSIONS_NAMESPACE,
Some(version.inner.id.0.to_string()),
))
.chain(version.files.iter().flat_map(
|file| {
file.hashes.iter().map(|(algo, hash)| {
(
VERSION_FILES_NAMESPACE,
Some(format!("{algo}_{hash}")),
)
})
},
)),
)
.await?;
redis.delete_many(&keys).await?;
Ok(())
}
@@ -1078,14 +1070,12 @@ impl DBVersion {
redis: &RedisPool,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
let keys = version_ids
.iter()
.map(|id| redis.key().entity(VERSIONS_NAMESPACE, id.0))
.collect::<Vec<_>>();
redis
.delete_many(
version_ids
.iter()
.map(|id| (VERSIONS_NAMESPACE, Some(id.0.to_string()))),
)
.await?;
redis.delete_many(&keys).await?;
Ok(())
}
}
+56
View File
@@ -0,0 +1,56 @@
use std::sync::Arc;
use crate::env::ENV;
struct RedisConfig {
inner: xredis::RedisConfig,
cache_settings: xredis::CacheSettings,
}
impl RedisConfig {
fn from_env() -> Result<Self, xredis::RedisConfigError> {
let inner = xredis::RedisConfig::new(
ENV.REDIS_TOPOLOGY,
ENV.REDIS_CONNECTION_TYPE,
&ENV.REDIS_URL,
ENV.REDIS_WAIT_TIMEOUT_MS,
(
ENV.REDIS_MAX_CONNECTIONS as usize,
ENV.REDIS_MIN_CONNECTIONS,
),
(
ENV.REDIS_CLUSTER_MAX_CONNECTIONS as usize,
ENV.REDIS_CLUSTER_MIN_CONNECTIONS,
),
(ENV.REDIS_BLOCKING_MAX_CONNECTIONS as usize, 0),
ENV.REDIS_CACHE_LOCKING_STRATEGY,
ENV.REDIS_READ_REPLICA_STRATEGY,
)?;
let cache_settings = xredis::CacheSettings {
default_expiry: ENV.REDIS_DEFAULT_EXPIRY,
actual_expiry: ENV.REDIS_ACTUAL_EXPIRY,
version_default_expiry: ENV.REDIS_VERSION_DEFAULT_EXPIRY,
version_actual_expiry: ENV.REDIS_VERSION_ACTUAL_EXPIRY,
encoding_format: ENV.REDIS_ENCODING_FORMAT,
compression_algorithm: ENV.REDIS_COMPRESSION_ALGORITHM,
compression_level: ENV.REDIS_COMPRESSION_LEVEL,
compression_threshold_bytes: ENV.REDIS_COMPRESSION_THRESHOLD_BYTES,
compression_min_savings_ratio: ENV
.REDIS_COMPRESSION_MIN_SAVINGS_RATIO,
};
Ok(Self {
inner,
cache_settings,
})
}
}
pub async fn from_env(
meta_namespace: impl Into<Arc<str>>,
) -> xredis::RedisPool {
let config = RedisConfig::from_env().expect("invalid Redis configuration");
xredis::RedisPool::new(meta_namespace, config.inner, config.cache_settings)
.await
.expect("failed to initialize Redis connections")
}
-957
View File
@@ -1,957 +0,0 @@
use crate::env::ENV;
use super::models::DatabaseError;
use ariadne::ids::base62_impl::{parse_base62, to_base62};
use chrono::{TimeZone, Utc};
use dashmap::DashMap;
use deadpool_redis::{Config, Runtime};
use futures::TryStreamExt;
use futures::future::Either;
use futures::stream::{FuturesUnordered, StreamExt};
use prometheus::{IntGauge, Registry};
use redis::ToRedisArgs;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::{Debug, Display};
use std::future::Future;
use std::hash::Hash;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use tracing::{Instrument, info, info_span};
use util::{cmd, redis_pipe};
pub mod util;
// Bound how many commands we send in a single Redis pipeline. The multiplexed
// connection's BytesMut write buffer keeps its peak capacity for the life of
// the connection, so larger pipelines cause higher steady-state RSS.
const PIPELINE_CHUNK_SIZE: usize = 25;
// Bound how many keys we send in a single MGET. Each MGET response must fit
// into the connection's read buffer, which also retains its peak capacity. At
// ~1 MB per cached value, 32 keys caps any single response at ~32 MB.
const MGET_CHUNK_SIZE: usize = 32;
// How long a pooled Redis connection lives before being recycled, regardless
// of activity. Forced recycling is the only way to release the per-connection
// BytesMut peak capacity that builds up under steady load.
const REDIS_MAX_CONN_AGE: Duration = Duration::from_secs(120);
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Codec {
Raw = 0,
Lz4 = 1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EncodingFormat {
Json,
Postcard,
}
#[derive(Debug, Error)]
#[error("invalid redis codec")]
pub struct InvalidCodec;
#[derive(Debug, Error)]
#[error("invalid redis encoding format")]
pub struct InvalidEncodingFormat;
impl TryFrom<u8> for Codec {
type Error = InvalidCodec;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Raw),
1 => Ok(Self::Lz4),
_ => Err(InvalidCodec),
}
}
}
impl FromStr for Codec {
type Err = InvalidCodec;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"lz4" => Ok(Self::Lz4),
_ => Err(InvalidCodec),
}
}
}
impl FromStr for EncodingFormat {
type Err = InvalidEncodingFormat;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"json" => Ok(Self::Json),
"postcard" => Ok(Self::Postcard),
_ => Err(InvalidEncodingFormat),
}
}
}
fn encode_value<T: Serialize>(value: &T) -> Result<Vec<u8>, DatabaseError> {
let mut value = match ENV.REDIS_ENCODING_FORMAT {
EncodingFormat::Json => serde_json::to_vec(value)?,
EncodingFormat::Postcard => postcard::to_allocvec(value)?,
};
if ENV.REDIS_COMPRESSION_LEVEL > 0
&& ENV.REDIS_COMPRESSION_ALGORITHM == Codec::Lz4
&& value.len() >= ENV.REDIS_COMPRESSION_THRESHOLD_BYTES
{
let compressed = lz4_flex::block::compress_prepend_size(&value);
let savings_ratio = value.len().saturating_sub(compressed.len()) as f64
/ value.len().max(1) as f64
* 100.0;
if savings_ratio >= ENV.REDIS_COMPRESSION_MIN_SAVINGS_RATIO {
let mut encoded = Vec::with_capacity(compressed.len() + 1);
encoded.push(Codec::Lz4 as u8);
encoded.extend(compressed);
return Ok(encoded);
}
}
let mut encoded = Vec::with_capacity(value.len() + 1);
encoded.push(Codec::Raw as u8);
encoded.append(&mut value);
Ok(encoded)
}
fn decode_value<T>(value: &[u8]) -> Option<T>
where
T: for<'a> Deserialize<'a>,
{
let (codec, value) = value.split_first()?;
let value = match Codec::try_from(*codec).ok()? {
Codec::Raw => Cow::Borrowed(value),
Codec::Lz4 => {
Cow::Owned(lz4_flex::block::decompress_size_prepended(value).ok()?)
}
};
match ENV.REDIS_ENCODING_FORMAT {
EncodingFormat::Json => serde_json::from_slice(&value).ok(),
EncodingFormat::Postcard => postcard::from_bytes(&value).ok(),
}
}
fn cache_expiries(namespace: &str) -> (i64, i64) {
// Namespaces may embed a version suffix like `:v1`, so split it out.
match namespace.split_once(':').map(|t| t.0).unwrap_or(namespace) {
"versions" | "versions_files" => (
ENV.REDIS_VERSION_DEFAULT_EXPIRY,
ENV.REDIS_VERSION_ACTUAL_EXPIRY,
),
_ => (ENV.REDIS_DEFAULT_EXPIRY, ENV.REDIS_ACTUAL_EXPIRY),
}
}
#[derive(Clone)]
pub struct RedisPool {
pub url: String,
pub pool: deadpool_redis::Pool,
cache_list: Arc<DashMap<String, util::CacheSubscriber>>,
meta_namespace: Arc<str>,
}
pub struct RedisConnection {
pub connection: deadpool_redis::Connection,
meta_namespace: Arc<str>,
}
impl RedisPool {
// initiate a new redis pool
// testing pool uses a hashmap to mimic redis behaviour for very small data sizes (ie: tests)
// PANICS: production pool will panic if redis url is not set
pub fn new(meta_namespace: impl Into<Arc<str>>) -> Self {
let wait_timeout = Duration::from_millis(ENV.REDIS_WAIT_TIMEOUT_MS);
let url = &ENV.REDIS_URL;
let pool = Config::from_url(url.clone())
.builder()
.expect("Error building Redis pool")
.max_size(ENV.REDIS_MAX_CONNECTIONS as usize)
.wait_timeout(Some(wait_timeout))
.runtime(Runtime::Tokio1)
.build()
.expect("Redis connection failed");
let pool = RedisPool {
url: url.clone(),
pool,
cache_list: Arc::new(DashMap::with_capacity(2048)),
meta_namespace: meta_namespace.into(),
};
let redis_min_connections = ENV.REDIS_MIN_CONNECTIONS;
let spawn_min_connections = (0..redis_min_connections)
.map(|_| {
let pool = pool.clone();
tokio::spawn(async move { pool.pool.get().await })
})
.collect::<FuturesUnordered<_>>();
tokio::spawn({
let pool = pool.clone();
async move {
// collect the connections into a buffer while we're spawning them,
// to make sure that we're not `get`ing any connections we previously took
let _connections =
spawn_min_connections.try_collect::<Vec<_>>().await;
info!(
pool_status = ?pool.pool.status(),
"Finished getting {redis_min_connections} initial Redis connections"
);
}
});
let interval = Duration::from_secs(30);
let max_idle = Duration::from_secs(5 * 60); // 5 minutes
let pool_ref = pool.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
pool_ref.pool.retain(|_, metrics| {
// Drop connections that have been idle too long, OR that
// are older than REDIS_MAX_CONN_AGE regardless of use.
// The age-based recycle is what releases the per-connection
// BytesMut peak capacity under steady traffic.
metrics.last_used() < max_idle
&& metrics.created.elapsed() < REDIS_MAX_CONN_AGE
});
}
});
pool
}
pub async fn register_and_set_metrics(
&self,
registry: &Registry,
) -> Result<(), prometheus::Error> {
let redis_max_size = IntGauge::new(
"labrinth_redis_pool_max_size",
"Maximum size of Redis pool",
)?;
let redis_size = IntGauge::new(
"labrinth_redis_pool_size",
"Current size of Redis pool",
)?;
let redis_available = IntGauge::new(
"labrinth_redis_pool_available",
"Available connections in Redis pool",
)?;
let redis_waiting = IntGauge::new(
"labrinth_redis_pool_waiting",
"Number of futures waiting for a Redis connection",
)?;
registry.register(Box::new(redis_max_size.clone()))?;
registry.register(Box::new(redis_size.clone()))?;
registry.register(Box::new(redis_available.clone()))?;
registry.register(Box::new(redis_waiting.clone()))?;
let redis_pool_ref = self.pool.clone();
tokio::spawn(async move {
loop {
let status = redis_pool_ref.status();
redis_max_size.set(status.max_size as i64);
redis_size.set(status.size as i64);
redis_available.set(status.available as i64);
redis_waiting.set(status.waiting as i64);
tokio::time::sleep(Duration::from_secs(5)).await;
}
});
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn connect(&self) -> Result<RedisConnection, DatabaseError> {
Ok(RedisConnection {
connection: self.pool.get().await?,
meta_namespace: self.meta_namespace.clone(),
})
}
#[tracing::instrument(skip(self, closure))]
pub async fn get_cached_keys<F, Fut, T, K>(
&self,
namespace: &str,
keys: &[K],
closure: F,
) -> Result<Vec<T>, DatabaseError>
where
F: FnOnce(Vec<K>) -> Fut,
Fut: Future<Output = Result<DashMap<K, T>, DatabaseError>>,
T: Serialize + DeserializeOwned,
K: Display
+ Hash
+ Eq
+ PartialEq
+ Clone
+ DeserializeOwned
+ Serialize
+ Debug,
{
Ok(self
.get_cached_keys_raw(namespace, keys, closure)
.await?
.into_iter()
.map(|x| x.1)
.collect())
}
#[tracing::instrument(skip(self, closure))]
pub async fn get_cached_keys_raw<F, Fut, T, K>(
&self,
namespace: &str,
keys: &[K],
closure: F,
) -> Result<HashMap<K, T>, DatabaseError>
where
F: FnOnce(Vec<K>) -> Fut,
Fut: Future<Output = Result<DashMap<K, T>, DatabaseError>>,
T: Serialize + DeserializeOwned,
K: Display
+ Hash
+ Eq
+ PartialEq
+ Clone
+ DeserializeOwned
+ Serialize
+ Debug,
{
self.get_cached_keys_raw_with_slug(
namespace,
None,
false,
keys,
|ids| async move {
Ok(closure(ids)
.await?
.into_iter()
.map(|(key, val)| (key, (None::<String>, val)))
.collect())
},
)
.await
}
#[tracing::instrument(skip(self, closure))]
pub async fn get_cached_keys_with_slug<F, Fut, T, I, K, S>(
&self,
namespace: &str,
slug_namespace: &str,
case_sensitive: bool,
keys: &[I],
closure: F,
) -> Result<Vec<T>, DatabaseError>
where
F: FnOnce(Vec<I>) -> Fut,
Fut: Future<Output = Result<DashMap<K, (Option<S>, T)>, DatabaseError>>,
T: Serialize + DeserializeOwned,
I: Display + Hash + Eq + PartialEq + Clone + Debug,
K: Display
+ Hash
+ Eq
+ PartialEq
+ Clone
+ DeserializeOwned
+ Serialize,
S: Display + Clone + DeserializeOwned + Serialize + Debug,
{
Ok(self
.get_cached_keys_raw_with_slug(
namespace,
Some(slug_namespace),
case_sensitive,
keys,
closure,
)
.await?
.into_iter()
.map(|x| x.1)
.collect())
}
#[tracing::instrument(skip(self, closure))]
pub async fn get_cached_keys_raw_with_slug<F, Fut, T, I, K, S>(
&self,
namespace: &str,
slug_namespace: Option<&str>,
case_sensitive: bool,
keys: &[I],
closure: F,
) -> Result<HashMap<K, T>, DatabaseError>
where
F: FnOnce(Vec<I>) -> Fut,
Fut: Future<Output = Result<DashMap<K, (Option<S>, T)>, DatabaseError>>,
T: Serialize + DeserializeOwned,
I: Display + Hash + Eq + PartialEq + Clone + Debug,
K: Display
+ Hash
+ Eq
+ PartialEq
+ Clone
+ DeserializeOwned
+ Serialize,
S: Display + Clone + DeserializeOwned + Serialize + Debug,
{
let ids = keys
.iter()
.map(|x| (x.to_string(), x.clone()))
.collect::<DashMap<String, I>>();
if ids.is_empty() {
return Ok(HashMap::new());
}
let get_cached_values = |ids: DashMap<String, I>| {
async move {
let slug_ids = if let Some(slug_namespace) = slug_namespace {
async {
let mut connection = self.pool.get().await?;
let args = ids
.iter()
.map(|x| {
format!(
"{}_{slug_namespace}:{}",
self.meta_namespace,
if case_sensitive {
x.value().to_string()
} else {
x.value().to_string().to_lowercase()
}
)
})
.collect::<Vec<_>>();
let mut v = Vec::new();
for chunk in args.chunks(MGET_CHUNK_SIZE) {
let part = cmd("MGET")
.arg(chunk)
.query_async::<Vec<Option<String>>>(
&mut connection,
)
.await?;
v.extend(part.into_iter().flatten());
}
Ok::<_, DatabaseError>(v)
}
.instrument(info_span!("get slug ids"))
.await?
} else {
Vec::new()
};
let mut connection = self.pool.get().await?;
let args = ids
.iter()
.map(|x| x.value().to_string())
.chain(ids.iter().filter_map(|x| {
parse_base62(&x.value().to_string())
.ok()
.map(|x| x.to_string())
}))
.chain(slug_ids)
.map(|x| format!("{}_{namespace}:{x}", self.meta_namespace))
.collect::<Vec<_>>();
let mut cached_values = HashMap::new();
for chunk in args.chunks(MGET_CHUNK_SIZE) {
let part = cmd("MGET")
.arg(chunk)
.query_async::<Vec<Option<Vec<u8>>>>(&mut connection)
.await?;
cached_values.extend(part.into_iter().filter_map(|x| {
x.and_then(|val| {
decode_value::<RedisValue<T, K, S>>(&val)
})
.map(|val| (val.key.clone(), val))
}));
}
Ok::<_, DatabaseError>((cached_values, ids))
}
.instrument(info_span!("get cached values"))
};
let (default_expiry, actual_expiry) = cache_expiries(namespace);
let current_time = Utc::now();
let mut expired_values = HashMap::new();
let (cached_values_raw, ids) = get_cached_values(ids).await?;
let mut cached_values = cached_values_raw
.into_iter()
.filter_map(|(key, val)| {
if Utc.timestamp_opt(val.iat + actual_expiry, 0).unwrap()
< current_time
{
expired_values.insert(val.key.to_string(), val);
None
} else {
let key_str = val.key.to_string();
ids.remove(&key_str);
if let Ok(value) = key_str.parse::<u64>() {
let base62 = to_base62(value);
ids.remove(&base62);
}
if let Some(ref alias) = val.alias {
ids.remove(&alias.to_string());
}
Some((key, val))
}
})
.collect::<HashMap<_, _>>();
let subscribe_ids = DashMap::new();
let mut cache_writers = HashMap::new();
if !ids.is_empty() {
let fetch_ids =
ids.iter().map(|x| x.key().clone()).collect::<Vec<_>>();
fetch_ids.into_iter().for_each(|key| {
let ns_key_value = if case_sensitive {
key.to_lowercase()
} else {
key.clone()
};
let namespaced_key = format!(
"{}_{namespace}:{ns_key_value}",
self.meta_namespace,
);
let either = self.acquire_lock(namespaced_key);
match either {
Either::Left(sentinel) => {
cache_writers.insert(key, sentinel);
}
Either::Right(subscriber) => {
if let Some((key, raw_key)) = ids.remove(&key) {
if let Some(val) = expired_values.remove(&key) {
if let Some(ref alias) = val.alias {
ids.remove(&alias.to_string());
}
if let Ok(value) =
val.key.to_string().parse::<u64>()
{
let base62 = to_base62(value);
ids.remove(&base62);
}
cached_values.insert(val.key.clone(), val);
} else {
subscribe_ids.insert(raw_key, subscriber);
}
}
}
}
});
}
let mut fetch_tasks = Vec::new();
if !ids.is_empty() {
fetch_tasks.push(Either::Left(async {
let fetch_ids =
ids.iter().map(|x| x.value().clone()).collect::<Vec<_>>();
let vals = closure(fetch_ids).await?;
let mut return_values = HashMap::new();
let mut pipe = redis_pipe();
let mut pipe_cmds: usize = 0;
let mut connection = self.pool.get().await?;
// Doesn't need to be atomic
if !vals.is_empty() {
for (key, (slug, value)) in vals {
let value = RedisValue {
key: key.clone(),
iat: Utc::now().timestamp(),
val: value,
alias: slug.clone(),
};
pipe.set_ex(
format!(
"{}_{namespace}:{key}",
self.meta_namespace
),
encode_value(&value)?,
default_expiry as u64,
);
pipe_cmds += 1;
if let Some(slug) = slug {
ids.remove(&slug.to_string());
if let Some(slug_namespace) = slug_namespace {
let actual_slug = if case_sensitive {
slug.to_string()
} else {
slug.to_string().to_lowercase()
};
pipe.set_ex(
format!(
"{}_{slug_namespace}:{}",
self.meta_namespace, actual_slug
),
key.to_string(),
default_expiry as u64,
);
pipe_cmds += 1;
}
}
let key_str = key.to_string();
ids.remove(&key_str);
if let Ok(value) = key_str.parse::<u64>() {
let base62 = to_base62(value);
ids.remove(&base62);
}
return_values.insert(key, value);
if pipe_cmds >= PIPELINE_CHUNK_SIZE {
pipe.query_async::<()>(&mut connection).await?;
pipe = redis_pipe();
pipe_cmds = 0;
}
}
}
if pipe_cmds > 0 {
pipe.query_async::<()>(&mut connection).await?;
}
drop(cache_writers);
Result::<_, DatabaseError>::Ok(return_values)
}));
}
if !subscribe_ids.is_empty() {
fetch_tasks.push(Either::Right(async move {
let mut futures = FuturesUnordered::new();
let len = subscribe_ids.len();
for (key, subscriber) in subscribe_ids {
futures.push(async move {
(
key,
subscriber
.wait_timeout(Duration::from_secs(5))
.await,
)
});
}
let fetch_ids = DashMap::with_capacity(len);
while let Some((key, result)) = futures.next().await {
result?;
fetch_ids.insert(key.to_string(), key);
}
let (return_values, _) = get_cached_values(fetch_ids).await?;
Ok(return_values)
}));
}
if !fetch_tasks.is_empty() {
for map in futures::future::try_join_all(fetch_tasks).await? {
for (key, value) in map {
cached_values.insert(key, value);
}
}
}
Ok(cached_values.into_iter().map(|x| (x.0, x.1.val)).collect())
}
/// Acquire or create a cache lock onto the given key.
fn acquire_lock(
&self,
key: String,
) -> Either<LockSentinel<'_>, util::CacheSubscriber> {
let mut out_writer = None;
let subscriber =
self.cache_list.entry(key.clone()).or_insert_with(|| {
let (writer, subscriber) = util::cache();
out_writer = Some(writer);
subscriber
});
match out_writer {
Some(writer) => Either::Left(LockSentinel {
pool: self,
key,
writer,
}),
None => Either::Right(subscriber.clone()),
}
}
}
struct LockSentinel<'a> {
pool: &'a RedisPool,
key: String,
writer: util::CacheWriter,
}
impl<'a> Drop for LockSentinel<'a> {
fn drop(&mut self) {
self.writer.write();
self.pool.cache_list.remove(&self.key);
}
}
impl RedisConnection {
#[tracing::instrument(skip(self))]
pub async fn set<D>(
&mut self,
namespace: &str,
id: &str,
data: D,
expiry: Option<i64>,
) -> Result<(), DatabaseError>
where
D: ToRedisArgs + Send + Sync + Debug,
{
let mut cmd = cmd("SET");
cmd.arg(format!("{}_{}:{}", self.meta_namespace, namespace, id))
.arg(data)
.arg("EX")
.arg(expiry.unwrap_or(ENV.REDIS_DEFAULT_EXPIRY));
redis_execute::<()>(&mut cmd, &mut self.connection).await?;
Ok(())
}
#[tracing::instrument(skip(self, id, data))]
pub async fn set_serialized<Id, D>(
&mut self,
namespace: &str,
id: Id,
data: D,
expiry: Option<i64>,
) -> Result<(), DatabaseError>
where
Id: Display,
D: serde::Serialize,
{
self.set(namespace, &id.to_string(), encode_value(&data)?, expiry)
.await
}
#[tracing::instrument(skip(self))]
pub async fn get(
&mut self,
namespace: &str,
id: &str,
) -> Result<Option<String>, DatabaseError> {
let mut cmd = cmd("GET");
redis_args(
&mut cmd,
vec![format!("{}_{}:{}", self.meta_namespace, namespace, id)]
.as_slice(),
);
let res = redis_execute(&mut cmd, &mut self.connection).await?;
Ok(res)
}
#[tracing::instrument(skip(self))]
pub async fn get_many(
&mut self,
namespace: &str,
ids: &[String],
) -> Result<Vec<Option<Vec<u8>>>, DatabaseError> {
let mut cmd = cmd("MGET");
redis_args(
&mut cmd,
ids.iter()
.map(|x| format!("{}_{}:{}", self.meta_namespace, namespace, x))
.collect::<Vec<_>>()
.as_slice(),
);
let res = redis_execute(&mut cmd, &mut self.connection).await?;
Ok(res)
}
#[tracing::instrument(skip(self))]
pub async fn get_deserialized<R>(
&mut self,
namespace: &str,
id: &str,
) -> Result<Option<R>, DatabaseError>
where
R: for<'a> serde::Deserialize<'a>,
{
let mut cmd = cmd("GET");
redis_args(
&mut cmd,
vec![format!("{}_{}:{}", self.meta_namespace, namespace, id)]
.as_slice(),
);
let value: Option<Vec<u8>> =
redis_execute(&mut cmd, &mut self.connection).await?;
Ok(value.and_then(|value| decode_value(&value)))
}
#[tracing::instrument(skip(self))]
pub async fn get_many_deserialized<R>(
&mut self,
namespace: &str,
ids: &[String],
) -> Result<Vec<Option<R>>, DatabaseError>
where
R: for<'a> serde::Deserialize<'a>,
{
Ok(self
.get_many(namespace, ids)
.await?
.into_iter()
.map(|value| value.and_then(|value| decode_value::<R>(&value)))
.collect())
}
#[tracing::instrument(skip(self, id))]
pub async fn delete<T1>(
&mut self,
namespace: &str,
id: T1,
) -> Result<(), DatabaseError>
where
T1: Display,
{
let mut cmd = cmd("DEL");
redis_args(
&mut cmd,
vec![format!("{}_{}:{}", self.meta_namespace, namespace, id)]
.as_slice(),
);
redis_execute::<()>(&mut cmd, &mut self.connection).await?;
Ok(())
}
#[tracing::instrument(skip(self, iter))]
pub async fn delete_many(
&mut self,
iter: impl IntoIterator<Item = (&str, Option<String>)>,
) -> Result<(), DatabaseError> {
let mut cmd = cmd("DEL");
let mut any = false;
for (namespace, id) in iter {
if let Some(id) = id {
redis_args(
&mut cmd,
[format!("{}_{}:{}", self.meta_namespace, namespace, id)]
.as_slice(),
);
any = true;
}
}
if any {
redis_execute::<()>(&mut cmd, &mut self.connection).await?;
}
Ok(())
}
#[tracing::instrument(skip(self, value))]
pub async fn lpush(
&mut self,
namespace: &str,
key: &str,
value: impl ToRedisArgs + Send + Sync + Debug,
) -> Result<(), DatabaseError> {
let key = format!("{}_{namespace}:{key}", self.meta_namespace);
cmd("LPUSH")
.arg(key)
.arg(value)
.query_async::<()>(&mut self.connection)
.await?;
Ok(())
}
#[tracing::instrument(skip(self))]
pub async fn brpop(
&mut self,
namespace: &str,
key: &str,
timeout: Option<f64>,
) -> Result<Option<[Vec<u8>; 2]>, DatabaseError> {
let key = format!("{}_{namespace}:{key}", self.meta_namespace);
// a timeout of 0 is infinite
let timeout = timeout.unwrap_or(0.0);
let values = cmd("BRPOP")
.arg(key)
.arg(timeout)
.query_async(&mut self.connection)
.await?;
Ok(values)
}
#[tracing::instrument(skip(self))]
pub async fn incr(
&mut self,
namespace: &str,
id: &str,
) -> Result<Option<u64>, DatabaseError> {
let key = format!("{}_{namespace}:{id}", self.meta_namespace);
let value = cmd("INCR")
.arg(key)
.query_async(&mut self.connection)
.await?;
Ok(value)
}
}
#[derive(Serialize, Deserialize)]
pub struct RedisValue<T, K, S> {
key: K,
alias: Option<S>,
iat: i64,
val: T,
}
impl<T, K, S> RedisValue<T, K, S> {
pub fn value(&self) -> &T {
&self.val
}
}
pub fn redis_args(cmd: &mut util::InstrumentedCmd, args: &[String]) {
for arg in args {
cmd.arg(arg);
}
}
pub async fn redis_execute<T>(
cmd: &mut util::InstrumentedCmd,
redis: &mut deadpool_redis::Connection,
) -> Result<T, deadpool_redis::PoolError>
where
T: redis::FromRedisValue,
{
let res = cmd.query_async::<T>(redis).await?;
Ok(res)
}
-160
View File
@@ -1,160 +0,0 @@
use std::fmt::Debug;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use derive_more::{Deref, DerefMut};
use redis::{FromRedisValue, RedisResult, ToRedisArgs};
use tokio::sync::Notify;
use tokio::time::{Duration, timeout};
use tracing::{Instrument, info_span};
use crate::database::models::DatabaseError;
pub fn redis_pipe() -> InstrumentedPipeline {
InstrumentedPipeline {
inner: redis::pipe(),
}
}
#[derive(Clone, Deref, DerefMut)]
pub struct InstrumentedPipeline {
#[deref]
#[deref_mut]
inner: redis::Pipeline,
}
impl InstrumentedPipeline {
pub fn atomic(&mut self) -> &mut Self {
self.inner.atomic();
self
}
#[inline]
pub async fn query_async<T: FromRedisValue>(
&self,
con: &mut impl redis::aio::ConnectionLike,
) -> RedisResult<T> {
self.inner
.query_async(con)
.instrument(info_span!("pipeline.query_async"))
.await
}
}
pub fn cmd(name: &str) -> InstrumentedCmd {
InstrumentedCmd {
inner: redis::cmd(name),
name: name.to_string(),
args: Vec::new(),
}
}
pub struct InstrumentedCmd {
inner: redis::Cmd,
name: String,
args: Vec<String>,
}
impl InstrumentedCmd {
#[inline]
pub fn arg<T: ToRedisArgs + Debug>(&mut self, arg: T) -> &mut Self {
self.args.push(format!("{arg:?}"));
self.inner.arg(arg);
self
}
#[inline]
pub async fn query_async<T: FromRedisValue>(
&self,
con: &mut impl redis::aio::ConnectionLike,
) -> RedisResult<T> {
let span = info_span!(
"cmd.query_async",
// <https://opentelemetry.io/docs/specs/semconv/db/redis/>
db.system.name = "redis",
db.operation.name = self.name,
db.query.text = format!("{} {}", self.name, self.args.join(" ")),
);
self.inner.query_async(con).instrument(span).await
}
}
pub fn cache() -> (CacheWriter, CacheSubscriber) {
let shared = Arc::new(Shared::new());
(
CacheWriter {
shared: shared.clone(),
},
CacheSubscriber { shared },
)
}
pub struct CacheWriter {
shared: Arc<Shared>,
}
impl CacheWriter {
pub fn write(&self) {
self.shared.make_ready();
}
}
#[derive(Clone)]
pub struct CacheSubscriber {
shared: Arc<Shared>,
}
impl CacheSubscriber {
pub async fn wait_timeout(
self,
duration: Duration,
) -> Result<(), DatabaseError> {
timeout(duration, self.shared.wait()).await.map_err(|_| {
DatabaseError::LocalCacheTimeout {
released: 0,
total: 1,
}
})
}
}
struct Shared {
ready: AtomicBool,
// With this implementation's intrusive linked lists, the waiters are stored inline in the future
// so there's no heap allocation per waiter.
wakers: Notify,
}
impl Shared {
fn new() -> Self {
Self {
ready: AtomicBool::new(false),
wakers: Notify::new(),
}
}
fn make_ready(&self) {
self.ready.store(true, Ordering::Release);
self.wakers.notify_waiters();
}
async fn wait(&self) {
let ready = self.ready.load(Ordering::Acquire);
if ready {
return;
}
let notification = self.wakers.notified();
// Don't need to call `enable` as we use notify_waiters
// Prevent race where the writer set the ready bit and notified waiters between the load and registering the waiter
let ready = self.ready.load(Ordering::SeqCst);
if ready {
return;
}
notification.await;
}
}
+48 -13
View File
@@ -5,6 +5,11 @@ use eyre::{Context, eyre};
use rust_decimal::Decimal;
use serde::de::DeserializeOwned;
use xredis::{
CacheLockingStrategy, ReadReplicaStrategy, RedisConnectionType,
RedisTopology,
};
macro_rules! vars {
(
$(
@@ -127,9 +132,52 @@ vars! {
LABRINTH_ADMIN_KEY: String = "";
LABRINTH_MEDAL_KEY: String = "";
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String = "";
LABRINTH_SUBSCRIPTIONS_KEY: String = "";
RATE_LIMIT_IGNORE_KEY: String = "";
DATABASE_URL: String = "postgresql://labrinth:labrinth@localhost/labrinth";
// Redis
REDIS_TOPOLOGY: RedisTopology = RedisTopology::Standalone;
REDIS_CONNECTION_TYPE: RedisConnectionType = RedisConnectionType::Pooled;
REDIS_CACHE_LOCKING_STRATEGY: CacheLockingStrategy = CacheLockingStrategy::Local;
// URL(s) for Redis. Use comma-separated values for multiple URLs in Cluster topology.
REDIS_URL: String = "redis://localhost";
// Configures the waiting timeout for Redis connection *pools*.
// This doesn't affect the bulk of Redis work in Multiplexed connection type.
REDIS_WAIT_TIMEOUT_MS: u64 = 15000u64;
// Minimum and maximum number of connections when Redis is in Standalone topology.
REDIS_MAX_CONNECTIONS: u32 = 2048u32;
REDIS_MIN_CONNECTIONS: usize = 0usize;
REDIS_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
REDIS_ACTUAL_EXPIRY: i64 = 60 * 30;
REDIS_VERSION_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
REDIS_VERSION_ACTUAL_EXPIRY: i64 = 60 * 30;
// Minimum and maximum number of connections when Redis is in Cluster topology, Pooled connection type.
REDIS_CLUSTER_MAX_CONNECTIONS: u32 = 16u32;
REDIS_CLUSTER_MIN_CONNECTIONS: usize = 0usize;
// The maximum number of connections of the Redis blocking pool. There's a blocking pool regardless of topology
// and main connection type.
REDIS_BLOCKING_MAX_CONNECTIONS: u32 = 256u32;
// The encoding format used for Redis cache values.
REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Json;
// The level of LZ4 compression used for Redis cache values. A value of 0 disables compression (supports 1-12)
REDIS_COMPRESSION_LEVEL: i32 = 0i32;
// The compression algorithm used for Redis cache values. Currently only LZ4 is supported.
REDIS_COMPRESSION_ALGORITHM: xredis::Codec = xredis::Codec::Lz4;
// The minimum number of bytes required to trigger compression for Redis cache values.
REDIS_COMPRESSION_THRESHOLD_BYTES: usize = 1024usize;
// The minimum savings ratio required to trigger compression for Redis cache values. If the savings ratio is lower than this,
// the compressed payload is discarded and the original payload is stored as-is.
REDIS_COMPRESSION_MIN_SAVINGS_RATIO: f64 = 12.5f64;
REDIS_READ_REPLICA_STRATEGY: ReadReplicaStrategy = ReadReplicaStrategy::Primary;
KAFKA_BOOTSTRAP_SERVERS: StringCsv = StringCsv(vec!["localhost:19092".into()]);
KAFKA_CLIENT_ID: String = "labrinth";
BIND_ADDR: String = "";
@@ -291,19 +339,6 @@ vars! {
READONLY_DATABASE_MIN_CONNECTIONS: u32 = 0u32;
READONLY_DATABASE_MAX_CONNECTIONS: u32 = 1u32;
REDIS_WAIT_TIMEOUT_MS: u64 = 15000u64;
REDIS_MAX_CONNECTIONS: u32 = 10000u32;
REDIS_MIN_CONNECTIONS: usize = 0usize;
REDIS_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
REDIS_ACTUAL_EXPIRY: i64 = 60 * 30;
REDIS_VERSION_DEFAULT_EXPIRY: i64 = 60 * 60 * 12;
REDIS_VERSION_ACTUAL_EXPIRY: i64 = 60 * 30;
REDIS_ENCODING_FORMAT: crate::database::redis::EncodingFormat = crate::database::redis::EncodingFormat::Json;
REDIS_COMPRESSION_LEVEL: i32 = 0i32;
REDIS_COMPRESSION_ALGORITHM: crate::database::redis::Codec = crate::database::redis::Codec::Lz4;
REDIS_COMPRESSION_THRESHOLD_BYTES: usize = 1024usize;
REDIS_COMPRESSION_MIN_SAVINGS_RATIO: f64 = 12.5f64;
SEARCH_OPERATION_TIMEOUT: u64 = 300000u64;
SMTP_REPLY_TO_NAME: String = "";
+4 -5
View File
@@ -4,12 +4,12 @@ use std::sync::Arc;
use std::time::Duration;
use actix_web::web;
use database::redis::RedisPool;
use queue::{
analytics::AnalyticsQueue, email::EmailQueue, payouts::PayoutsQueue,
session::AuthQueue, socket::ActiveSockets,
};
use tracing::{debug, info, warn};
use xredis::RedisPool;
extern crate clickhouse as clickhouse_crate;
use clickhouse_crate::Client;
@@ -26,7 +26,7 @@ use crate::util::archon::ArchonClient;
use crate::util::http::HttpClient;
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
use crate::util::tiltify::TiltifyClient;
use sync::friends::handle_pubsub;
use sync::friends::{FRIENDS_CHANNEL_NAME, handle_pubsub};
use url::Url;
use webauthn_rs::{Webauthn, WebauthnBuilder};
@@ -290,11 +290,10 @@ pub fn app_setup(
{
let pool = pool.clone();
let redis_client = redis::Client::open(redis_pool.url.clone()).unwrap();
let pubsub_messages = redis_pool.subscribe(FRIENDS_CHANNEL_NAME);
let sockets = active_sockets.clone();
actix_rt::spawn(async move {
let pubsub = redis_client.get_async_pubsub().await.unwrap();
handle_pubsub(pubsub, pool, sockets).await;
handle_pubsub(pubsub_messages, pool, sockets).await;
});
}
+2 -2
View File
@@ -7,7 +7,7 @@ use actix_web_prom::PrometheusMetricsBuilder;
use clap::Parser;
use labrinth::background_task::BackgroundTask;
use labrinth::database::redis::RedisPool;
use labrinth::database::redis;
use labrinth::env::ENV;
use labrinth::file_hosting::{FileHost, FileHostKind, S3BucketConfig, S3Host};
use labrinth::queue::email::EmailQueue;
@@ -110,7 +110,7 @@ async fn app() -> std::io::Result<()> {
.expect("Database connection failed");
// Redis connector
let redis_pool = RedisPool::new("");
let redis_pool = redis::from_env("").await;
let storage_backend = ENV.STORAGE_BACKEND;
let file_host: Arc<dyn FileHost> = match storage_backend {
+16 -18
View File
@@ -2,12 +2,10 @@ use eyre::Result;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use validator::Validate;
use xredis::RedisPool;
use crate::{
database::{
models::{DBProjectId, DBVersionId},
redis::RedisPool,
},
database::models::{DBProjectId, DBVersionId},
models::{
exp::{
component::{
@@ -257,14 +255,14 @@ pub async fn fetch_query_context(
{
HashMap::new()
} else {
let ping_keys = minecraft_java_server_pings
.iter()
.map(|project_id| {
redis.key().entity(server_ping::REDIS_NAMESPACE, project_id)
})
.collect::<Vec<_>>();
redis
.get_many_deserialized::<minecraft::JavaServerPing>(
server_ping::REDIS_NAMESPACE,
&minecraft_java_server_pings
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
)
.get_many_deserialized::<minecraft::JavaServerPing>(&ping_keys)
.await?
.into_iter()
.enumerate()
@@ -280,14 +278,14 @@ pub async fn fetch_query_context(
let minecraft_server_analytics = if minecraft_server_analytics.is_empty() {
HashMap::new()
} else {
let analytics_keys = minecraft_server_analytics
.iter()
.map(|project_id| {
redis.key().entity(MINECRAFT_SERVER_ANALYTICS, project_id)
})
.collect::<Vec<_>>();
redis
.get_many_deserialized::<MinecraftServerAnalytics>(
MINECRAFT_SERVER_ANALYTICS,
&minecraft_server_analytics
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
)
.get_many_deserialized::<MinecraftServerAnalytics>(&analytics_keys)
.await?
.into_iter()
.enumerate()
+1 -1
View File
@@ -4,7 +4,6 @@ use std::collections::HashMap;
use super::super::ids::OrganizationId;
use crate::database::models::{DatabaseError, version_item};
use crate::database::redis::RedisPool;
use crate::models::ids::{ProjectId, TeamId, ThreadId, VersionId};
use crate::models::projects::{
Dependency, License, Link, Loader, ModeratorMessage, MonetizationStatus,
@@ -16,6 +15,7 @@ use chrono::{DateTime, Utc};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use validator::Validate;
use xredis::RedisPool;
/// A project returned from the API
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
+6 -11
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use xredis::RedisPool;
use const_format::formatcp;
use eyre::{Result, eyre};
@@ -7,13 +8,11 @@ use sqlx::PgPool;
use tracing::{debug, info};
use crate::{
database::{DBProject, redis::RedisPool},
models::ids::ProjectId,
routes::analytics::MINECRAFT_SERVER_PLAYS,
util::error::Context,
database::DBProject, models::ids::ProjectId,
routes::analytics::MINECRAFT_SERVER_PLAYS, util::error::Context,
};
pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v1";
pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v3";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MinecraftServerAnalytics {
@@ -116,13 +115,9 @@ pub async fn cache_analytics(
};
debug!("Caching analytics for {project_id}: {analytics:?}");
let key = redis.key().entity(MINECRAFT_SERVER_ANALYTICS, project_id);
redis
.set_serialized(
MINECRAFT_SERVER_ANALYTICS,
project_id.to_string(),
analytics,
None,
)
.set_serialized(&key, analytics, None)
.await
.wrap_err_with(|| {
eyre!("failed to set analytics for project '{project_id}'")
+62 -88
View File
@@ -1,21 +1,19 @@
use crate::database::PgPool;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::analytics::{
AffiliateCodeClick, Download, MinecraftServerPlay, PageView, Playtime,
};
use crate::routes::ApiError;
use crate::routes::analytics::MINECRAFT_SERVER_PLAYS;
use dashmap::{DashMap, DashSet};
use redis::cmd;
use std::collections::HashMap;
use tracing::trace;
use xredis::RedisPool;
pub mod cache;
const DOWNLOADS_NAMESPACE: &str = "downloads:v1";
const VIEWS_NAMESPACE: &str = "views:v1";
const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v1";
const DOWNLOADS_NAMESPACE: &str = "downloads:v3";
const VIEWS_NAMESPACE: &str = "views:v3";
const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v3";
const MINECRAFT_SERVER_PLAYS_EXPIRY: u64 = 86_400; // 24 hours
const MINECRAFT_SERVER_PLAYS_LIMIT: u32 = 5;
@@ -133,29 +131,22 @@ impl AnalyticsQueue {
raw_plays.insert(index, play);
}
let mut redis =
redis.pool.get().await.map_err(DatabaseError::RedisPool)?;
let redis_keys = plays_keys
.iter()
.map(|key| {
let logical_key = format!("{}-{}", key.0, key.1);
redis.key().with_slot(
MINECRAFT_SERVER_PLAYS_NAMESPACE,
&logical_key,
&logical_key,
)
})
.collect::<Vec<_>>();
let mut redis_connection = redis.connect().await?;
let results = cmd("MGET")
.arg(
plays_keys
.iter()
.map(|x| {
format!(
"{}:{}-{}",
MINECRAFT_SERVER_PLAYS_NAMESPACE, x.0, x.1
)
})
.collect::<Vec<_>>(),
)
.query_async::<Vec<Option<u32>>>(&mut redis)
.await
.map_err(DatabaseError::CacheError)?;
let mut pipe = redis::pipe();
let results =
redis_connection.get_many_typed::<u32>(&redis_keys).await?;
for (idx, count) in results.into_iter().enumerate() {
let key = &plays_keys[idx];
let new_count = if let Some(count) = count {
if count >= MINECRAFT_SERVER_PLAYS_LIMIT {
raw_plays.remove(&idx);
@@ -166,18 +157,15 @@ impl AnalyticsQueue {
1
};
pipe.atomic().set_ex(
format!(
"{}:{}-{}",
MINECRAFT_SERVER_PLAYS_NAMESPACE, key.0, key.1
),
new_count,
MINECRAFT_SERVER_PLAYS_EXPIRY,
);
let key = &redis_keys[idx];
redis_connection
.set(
key,
new_count,
Some(MINECRAFT_SERVER_PLAYS_EXPIRY as i64),
)
.await?;
}
pipe.query_async::<()>(&mut *redis)
.await
.map_err(DatabaseError::CacheError)?;
let mut plays = client
.insert::<MinecraftServerPlay>(MINECRAFT_SERVER_PLAYS)
@@ -199,24 +187,22 @@ impl AnalyticsQueue {
raw_views.push((views, true));
}
let mut redis =
redis.pool.get().await.map_err(DatabaseError::RedisPool)?;
let redis_keys = views_keys
.iter()
.map(|key| {
let logical_key = format!("{}-{}", key.0, key.1);
redis.key().with_slot(
VIEWS_NAMESPACE,
&logical_key,
&logical_key,
)
})
.collect::<Vec<_>>();
let mut redis_connection = redis.connect().await?;
let results = cmd("MGET")
.arg(
views_keys
.iter()
.map(|x| format!("{}:{}-{}", VIEWS_NAMESPACE, x.0, x.1))
.collect::<Vec<_>>(),
)
.query_async::<Vec<Option<u32>>>(&mut redis)
.await
.map_err(DatabaseError::CacheError)?;
let mut pipe = redis::pipe();
let results =
redis_connection.get_many_typed::<u32>(&redis_keys).await?;
for (idx, count) in results.into_iter().enumerate() {
let key = &views_keys[idx];
let new_count =
if let Some((views, monetized)) = raw_views.get_mut(idx) {
if let Some(count) = count {
@@ -237,15 +223,11 @@ impl AnalyticsQueue {
1
};
pipe.atomic().set_ex(
format!("{}:{}-{}", VIEWS_NAMESPACE, key.0, key.1),
new_count,
6 * 60 * 60,
);
let key = &redis_keys[idx];
redis_connection
.set(key, new_count, Some(6 * 60 * 60))
.await?;
}
pipe.query_async::<()>(&mut *redis)
.await
.map_err(DatabaseError::CacheError)?;
let mut views = client.insert::<PageView>("views").await?;
@@ -274,26 +256,22 @@ impl AnalyticsQueue {
raw_downloads.insert(index, download);
}
let mut redis =
redis.pool.get().await.map_err(DatabaseError::RedisPool)?;
let redis_keys = downloads_keys
.iter()
.map(|key| {
let logical_key = format!("{}-{}", key.0, key.1);
redis.key().with_slot(
DOWNLOADS_NAMESPACE,
&logical_key,
&logical_key,
)
})
.collect::<Vec<_>>();
let mut redis_connection = redis.connect().await?;
let results = cmd("MGET")
.arg(
downloads_keys
.iter()
.map(|x| {
format!("{}:{}-{}", DOWNLOADS_NAMESPACE, x.0, x.1)
})
.collect::<Vec<_>>(),
)
.query_async::<Vec<Option<u32>>>(&mut redis)
.await
.map_err(DatabaseError::CacheError)?;
let mut pipe = redis::pipe();
let results =
redis_connection.get_many_typed::<u32>(&redis_keys).await?;
for (idx, count) in results.into_iter().enumerate() {
let key = &downloads_keys[idx];
let new_count = if let Some(count) = count {
if count > 5 {
raw_downloads.remove(&idx);
@@ -305,15 +283,11 @@ impl AnalyticsQueue {
1
};
pipe.atomic().set_ex(
format!("{}:{}-{}", DOWNLOADS_NAMESPACE, key.0, key.1),
new_count,
6 * 60 * 60,
);
let key = &redis_keys[idx];
redis_connection
.set(key, new_count, Some(6 * 60 * 60))
.await?;
}
pipe.query_async::<()>(&mut *redis)
.await
.map_err(DatabaseError::CacheError)?;
let mut transaction = pool.begin().await?;
let mut downloads = client.insert::<Download>("downloads").await?;
+1 -1
View File
@@ -10,7 +10,6 @@ use crate::database::models::{DatabaseError, ids::*};
use crate::database::models::{
product_item, user_subscription_item, users_redeemals,
};
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::billing::{
@@ -35,6 +34,7 @@ use std::str::FromStr;
use std::time::Instant;
use stripe::{self, Currency};
use tracing::{debug, error, info, warn};
use xredis::RedisPool;
/// Updates charges which need to have their tax amount updated. This is done within a timer to avoid reaching
/// Anrok API limits.
+1 -1
View File
@@ -3,7 +3,6 @@ use crate::database::models::notification_item::DBNotification;
use crate::database::models::notifications_deliveries_item::DBNotificationDelivery;
use crate::database::models::notifications_template_item::NotificationTemplate;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::notifications::{NotificationBody, NotificationType};
@@ -23,6 +22,7 @@ use thiserror::Error;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::Semaphore;
use tracing::{error, info, instrument, warn};
use xredis::RedisPool;
const EMAIL_RETRY_DELAY_SECONDS: i64 = 10;
+1 -1
View File
@@ -7,7 +7,6 @@ use crate::database::models::notifications_template_item::{
use crate::database::models::{
DBOrganization, DBProject, DBUser, DatabaseError,
};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::v3::notifications::NotificationBody;
use crate::routes::ApiError;
@@ -20,6 +19,7 @@ use sqlx::query;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{error, warn};
use xredis::RedisPool;
const USER_NAME: &str = "user.name";
const USER_EMAIL: &str = "user.email";
+2 -1
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::sync::Arc;
use xredis::RedisPool;
use chrono::Utc;
use eyre::{Result, eyre};
@@ -17,7 +18,7 @@ use crate::database::models::ids::{
};
use crate::database::models::moderation_external_item::ExternalLicense;
use crate::database::models::{DBFileId, DBUserId, DBVersion};
use crate::database::{PgPool, PgTransaction, redis::RedisPool};
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::error::ApiError;
+1 -1
View File
@@ -1,6 +1,5 @@
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::payouts_values_notifications;
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::payouts::{
@@ -31,6 +30,7 @@ use sqlx::postgres::PgQueryResult;
use std::collections::HashMap;
use tokio::sync::RwLock;
use tracing::{error, info, warn};
use xredis::RedisPool;
mod affiliate;
pub mod flow;
+21 -17
View File
@@ -1,6 +1,5 @@
use crate::database::DBProject;
use crate::database::models::DBProjectId;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::exp;
use crate::models::ids::ProjectId;
@@ -18,6 +17,7 @@ use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tracing::{Instrument, info, info_span, trace, warn};
use xredis::RedisPool;
pub struct ServerPingQueue {
pub db: PgPool,
@@ -26,9 +26,9 @@ pub struct ServerPingQueue {
pub incremental_search_queue: IncrementalSearchQueue,
}
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v1";
pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v3";
pub const REDIS_FAILURE_NAMESPACE: &str =
"minecraft_java_server_ping_failures:v1";
"minecraft_java_server_ping_failures:v3";
pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings";
impl ServerPingQueue {
@@ -111,6 +111,12 @@ impl ServerPingQueue {
for (project_id, ping) in &pings {
let data = ping.data.as_ref();
let ping_key =
self.redis.key().entity(REDIS_NAMESPACE, project_id);
let failure_key = self
.redis
.key()
.entity(REDIS_FAILURE_NAMESPACE, project_id);
let row = ServerPingRecord {
recorded: ping.when.timestamp_nanos_opt().unwrap()
@@ -134,13 +140,13 @@ impl ServerPingQueue {
// ping succeeded; immediately update its online status in redis
redis
.set_serialized(REDIS_NAMESPACE, project_id, ping, None)
.set_serialized(&ping_key, ping, None)
.await
.wrap_err("failed to set redis key")?;
updated_project = true;
redis
.delete(REDIS_FAILURE_NAMESPACE, project_id)
.delete(&failure_key)
.await
.wrap_err("failed to delete failure count")?;
} else {
@@ -148,7 +154,7 @@ impl ServerPingQueue {
// otherwise, just add to the fail counter
let failure_count = redis
.incr(REDIS_FAILURE_NAMESPACE, &project_id.to_string())
.incr(&failure_key)
.await
.wrap_err("failed to increment failure count")?;
@@ -156,12 +162,7 @@ impl ServerPingQueue {
&& count >= ENV.SERVER_PING_MAX_FAIL_COUNT
{
redis
.set_serialized(
REDIS_NAMESPACE,
project_id,
ping,
None,
)
.set_serialized(&ping_key, ping, None)
.await
.wrap_err(
"failed to set failed ping record in redis",
@@ -246,14 +247,17 @@ impl ServerPingQueue {
// and if we do miss an entry that we shouldn't, we just ping it again
let all_project_ids = all_server_projects
.iter()
.map(|row| ProjectId::from(DBProjectId(row.id)).to_string())
.map(|row| ProjectId::from(DBProjectId(row.id)))
.collect::<Vec<_>>();
let ping_keys = all_project_ids
.iter()
.map(|project_id| {
self.redis.key().entity(REDIS_NAMESPACE, project_id)
})
.collect::<Vec<_>>();
let all_server_last_pings = redis
.get_many_deserialized::<exp::minecraft::JavaServerPing>(
REDIS_NAMESPACE,
&all_project_ids,
)
.get_many_deserialized::<exp::minecraft::JavaServerPing>(&ping_keys)
.await
.wrap_err("failed to fetch server project last pings")?;
+1 -1
View File
@@ -3,13 +3,13 @@ use crate::database::models::session_item::DBSession;
use crate::database::models::{
DBOAuthAccessTokenId, DBPatId, DBSessionId, DBUserId, DatabaseError,
};
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::routes::internal::session::SessionMetadata;
use chrono::Utc;
use itertools::Itertools;
use std::collections::{HashMap, HashSet};
use tokio::sync::Mutex;
use xredis::RedisPool;
pub struct AuthQueue {
session_queue: Mutex<HashMap<DBSessionId, SessionMetadata>>,
+1 -1
View File
@@ -1,7 +1,6 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::DBProject;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::analytics::{MinecraftServerPlay, PageView, Playtime};
use crate::models::ids::ProjectId;
@@ -22,6 +21,7 @@ use std::sync::Arc;
use tracing::trace;
use url::Url;
use uuid::Uuid;
use xredis::RedisPool;
pub const FILTERED_HEADERS: &[&str] = &[
"authorization",
+1 -1
View File
@@ -1,6 +1,5 @@
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::models::analytics::{Download, DownloadReason};
use crate::models::ids::{ProjectId, VersionId};
use crate::models::pats::Scopes;
@@ -22,6 +21,7 @@ use std::net::Ipv4Addr;
use std::str::FromStr;
use std::sync::Arc;
use tracing::trace;
use xredis::RedisPool;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(
@@ -1,13 +1,11 @@
use std::{collections::HashMap, net::Ipv4Addr, sync::Arc};
use xredis::RedisPool;
use crate::database::PgPool;
use crate::env::ENV;
use crate::{
auth::get_user_from_headers,
database::{
models::{DBAffiliateCode, DBAffiliateCodeId, DBUser, DBUserId},
redis::RedisPool,
},
database::models::{DBAffiliateCode, DBAffiliateCodeId, DBUser, DBUserId},
models::{
analytics::AffiliateCodeClick, ids::AffiliateCodeId, pats::Scopes,
users::Badges, v3::affiliate_code::AffiliateCode,
@@ -12,7 +12,6 @@ use crate::database::models::{
generate_attribution_group_id,
},
};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::ids::{FileId, ProjectId, VersionId};
use crate::models::pats::Scopes;
@@ -27,6 +26,7 @@ use crate::queue::moderation::ApprovalType;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::error::Context;
use xredis::RedisPool;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(list)
+21 -18
View File
@@ -1,4 +1,5 @@
use self::payments::*;
use self::update_subscriptions::*;
use crate::auth::get_user_from_headers;
use crate::database::models::charge_item::DBCharge;
use crate::database::models::ids::DBUserSubscriptionId;
@@ -10,7 +11,6 @@ use crate::database::models::{
DBAffiliateCodeId, charge_item, generate_charge_id, product_item,
user_subscription_item,
};
use crate::database::redis::RedisPool;
use crate::database::{PgPool, PgTransaction};
use crate::env::ENV;
use crate::models::billing::{
@@ -41,6 +41,7 @@ use stripe::{
Webhook,
};
use tracing::warn;
use xredis::RedisPool;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(
@@ -56,6 +57,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.service(charges)
.service(credit)
.service(active_servers)
.service(update_many)
.service(initiate_payment)
.service(stripe_webhook)
.service(refund_charge)
@@ -63,7 +65,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
);
}
/// List products.
/// List products.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -113,7 +115,7 @@ struct UserSubscriptionWithNextChargeTaxAmount {
pub next_charge_tax_amount: Option<i64>,
}
/// List subscriptions.
/// List subscriptions.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -187,7 +189,7 @@ pub struct ChargeRefund {
pub unprovision: Option<bool>,
}
/// Refund a charge.
/// Refund a charge.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -457,7 +459,7 @@ pub async fn refund_charge(
Ok(HttpResponse::NoContent().finish())
}
/// Reprocess tax for a charge.
/// Reprocess tax for a charge.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -646,7 +648,7 @@ pub struct SubscriptionEditQuery {
pub dry: Option<bool>,
}
/// Update a subscription.
/// Update a subscription.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1146,7 +1148,7 @@ pub async fn edit_subscription(
}
}
/// Get the current customer.
/// Get the current customer.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1190,7 +1192,7 @@ pub struct ChargesQuery {
pub user_id: Option<ariadne::ids::UserId>,
}
/// List payments.
/// List payments.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1256,7 +1258,7 @@ pub async fn charges(
))
}
/// Start a payment method flow.
/// Start a payment method flow.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1315,7 +1317,7 @@ pub struct EditPaymentMethod {
pub primary: bool,
}
/// Update a payment method.
/// Update a payment method.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1385,7 +1387,7 @@ pub async fn edit_payment_method(
}
}
/// Remove a payment method.
/// Remove a payment method.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1474,7 +1476,7 @@ pub async fn remove_payment_method(
}
}
/// List payment methods.
/// List payment methods.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1537,7 +1539,7 @@ struct ActiveServerResponse {
pub region: Option<String>,
}
/// List active servers.
/// List active servers.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1651,7 +1653,7 @@ pub struct PaymentRequest {
pub metadata: Option<PaymentRequestMetadata>,
}
/// Initiate a payment.
/// Initiate a payment.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1721,7 +1723,7 @@ pub async fn initiate_payment(
}
}
/// Receive a Stripe webhook.
/// Receive a Stripe webhook.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -1967,7 +1969,7 @@ pub async fn stripe_webhook(
if charge_status != ChargeStatus::Failed {
subscription
.upsert(transaction)
.upsert(&mut *transaction)
.await?;
}
@@ -2009,7 +2011,7 @@ pub async fn stripe_webhook(
};
if charge_status != ChargeStatus::Failed {
charge.upsert(transaction).await?;
charge.upsert(&mut *transaction).await?;
}
(charge, price, product, subscription, new_region)
@@ -2644,7 +2646,7 @@ pub enum CreditTarget {
},
}
/// Credit subscriptions.
/// Credit subscriptions.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
@@ -2773,3 +2775,4 @@ pub async fn credit(
}
pub mod payments;
pub mod update_subscriptions;
@@ -3,12 +3,12 @@ use crate::database::models::{
generate_charge_id, generate_user_subscription_id, product_item,
products_tax_identifier_item, user_subscription_item,
};
use crate::database::redis::RedisPool;
use crate::models::ids::*;
use crate::models::v3::billing::SubscriptionStatus;
use crate::models::v3::users::User;
use crate::routes::ApiError;
use crate::util::anrok;
use xredis::RedisPool;
use crate::database::PgPool;
use ariadne::ids::base62_impl::to_base62;
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use actix_web::{post, web};
use ariadne::ids::UserId as Id;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::database::PgPool;
use crate::database::models::user_subscription_item::DBUserSubscription;
use crate::models::billing::SubscriptionMetadata;
use crate::routes::ApiError;
use crate::util::error::Context;
use crate::util::guards::subscriptions_key_guard;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateManySubscriptions {
pub subscriptions: Vec<SubscriptionUpdate>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SubscriptionUpdate {
pub target: SubscriptionTarget,
pub update_region: Option<String>,
pub ignore_if_missing: bool,
}
#[derive(
Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, ToSchema,
)]
pub enum SubscriptionTarget {
Pyro { user_id: Id, server_id: Uuid },
}
/// Update multiple managed subscriptions.
#[utoipa::path(
context_path = "/billing",
tag = "billing",
request_body = UpdateManySubscriptions,
responses((status = OK))
)]
#[post(
"/subscriptions/manager/update_many",
guard = "subscriptions_key_guard"
)]
pub async fn update_many(
pool: web::Data<PgPool>,
body: web::Json<UpdateManySubscriptions>,
) -> Result<(), ApiError> {
let UpdateManySubscriptions { subscriptions } = body.into_inner();
let mut txn = pool
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
// Only supports hosting subscriptions now, so gather the server IDs and
// fetch the subscriptions from them
let server_ids = subscriptions
.iter()
.map(|update| match update.target {
SubscriptionTarget::Pyro { server_id, .. } => server_id.to_string(),
})
.collect::<Vec<_>>();
let found_subscriptions =
DBUserSubscription::get_many_by_server_ids(&server_ids, &mut txn)
.await
.wrap_internal_err("failed to fetch subscriptions to update")?;
let mut subscriptions_by_target =
HashMap::<SubscriptionTarget, DBUserSubscription>::new();
// Creates a map of subscription "key" -> it's DB representation.
for subscription in found_subscriptions {
let Some(SubscriptionMetadata::Pyro { id, .. }) =
subscription.metadata.as_ref()
else {
continue;
};
let Ok(server_id) = Uuid::parse_str(id) else {
continue;
};
let target = SubscriptionTarget::Pyro {
user_id: subscription.user_id.into(),
server_id,
};
subscriptions_by_target.insert(target, subscription);
}
for update in subscriptions {
let Some(subscription) =
subscriptions_by_target.get_mut(&update.target)
else {
continue;
};
// Update the subscription region
if let Some(SubscriptionMetadata::Pyro { region, .. }) =
subscription.metadata.as_mut()
&& let Some(new_region) = update.update_region.clone()
{
*region = Some(new_region);
}
subscription.upsert(&mut txn).await?;
}
txn.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
Ok(())
}
+9 -10
View File
@@ -10,6 +10,7 @@ use sha2::Sha256;
use std::collections::HashSet;
use tracing::{debug, info, warn};
use uuid::Uuid;
use xredis::RedisPool;
use crate::{
database::{
@@ -18,7 +19,6 @@ use crate::{
DBCampaignDonationId, DBUser, DBUserId,
generate_campaign_donation_id,
},
redis::RedisPool,
},
env::ENV,
models::payouts::TremendousForexResponse,
@@ -68,7 +68,7 @@ pub struct CampaignInfo {
cached_at: DateTime<Utc>,
}
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info:v1";
const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info:v3";
const CAMPAIGN_INFO_CACHE_STALE_SECONDS: i64 = 15 * 60;
const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 24 * 60 * 60;
@@ -143,7 +143,7 @@ impl CampaignDonation {
}
}
/// Receive a Tiltify webhook.
/// Receive a Tiltify webhook.
#[utoipa::path(
context_path = "/campaign",
tag = "campaigns",
@@ -307,7 +307,7 @@ fn verify_tiltify_webhook_signature(
Ok(())
}
/// Get Pride campaign data.
/// Get Pride campaign data.
#[utoipa::path(
context_path = "/campaign",
tag = "campaigns",
@@ -324,12 +324,12 @@ pub async fn pride_26(
.connect()
.await
.wrap_internal_err("connecting to redis")?;
let cache_key = redis
.key()
.entity(CAMPAIGN_INFO_CACHE_NAMESPACE, campaign_id);
let cached = redis_connection
.get_deserialized::<CampaignInfo>(
CAMPAIGN_INFO_CACHE_NAMESPACE,
campaign_id,
)
.get_deserialized::<CampaignInfo>(&cache_key)
.await
.wrap_internal_err("getting cached campaign info")?;
@@ -383,8 +383,7 @@ pub async fn pride_26(
redis_connection
.set_serialized(
CAMPAIGN_INFO_CACHE_NAMESPACE,
campaign_id,
&cache_key,
&campaign_info,
Some(CAMPAIGN_INFO_CACHE_TTL_SECONDS),
)
@@ -1,4 +1,5 @@
use std::{collections::HashMap, fmt::Write, time::Instant};
use xredis::RedisPool;
use crate::env::ENV;
use crate::{database::PgPool, util::http::HttpClient};
@@ -11,16 +12,13 @@ use tracing::info;
use crate::{
auth::check_is_moderator_from_headers,
database::{
models::{
DBFileId, DBProjectId, DelphiReportId, DelphiReportIssueDetailsId,
DelphiReportIssueId,
delphi_report_item::{
DBDelphiReport, DBDelphiReportIssue, DelphiSeverity,
DelphiStatus, ReportIssueDetail,
},
database::models::{
DBFileId, DBProjectId, DelphiReportId, DelphiReportIssueDetailsId,
DelphiReportIssueId,
delphi_report_item::{
DBDelphiReport, DBDelphiReportIssue, DelphiSeverity, DelphiStatus,
ReportIssueDetail,
},
redis::RedisPool,
},
models::{
ids::{ProjectId, VersionId},
@@ -6,7 +6,6 @@ use crate::database::models::ids::{DBNotificationId, DBUserId};
use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::models::notifications::NotificationDeliveryStatus;
use crate::models::users::Role;
use crate::models::v3::notifications::{Notification, NotificationBody};
@@ -27,6 +26,7 @@ use ariadne::ids::UserId;
use eyre::eyre;
use lettre::message::Mailbox;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(create)
+8 -14
View File
@@ -8,7 +8,6 @@ use crate::database::models::flow_item::DBFlow;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::session_item::DBSession;
use crate::database::models::{DBPasskey, DBPasskeyId, DBUser, DBUserId};
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::error::ApiError as ApiErrorResponse;
@@ -59,6 +58,7 @@ use webauthn_rs::prelude::{
PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse,
Webauthn, WebauthnError,
};
use xredis::RedisPool;
use zxcvbn::Score;
/// Sourced from <https://github.com/disposable-email-domains/disposable-email-domains>.
@@ -2213,15 +2213,15 @@ async fn validate_2fa_code(
)
.map_err(|_| AuthenticationError::InvalidCredentials)?;
const TOTP_NAMESPACE: &str = "used_totp:v1";
const TOTP_NAMESPACE: &str = "used_totp:v3";
let mut conn = redis.connect().await?;
let logical_key = format!("{}-{}", input, user_id.0);
let key = redis
.key()
.with_slot(TOTP_NAMESPACE, &logical_key, &logical_key);
// Check if TOTP has already been used
if conn
.get(TOTP_NAMESPACE, &format!("{}-{}", input, user_id.0))
.await?
.is_some()
{
if conn.get(&key).await?.is_some() {
return Err(AuthenticationError::InvalidCredentials);
}
@@ -2229,13 +2229,7 @@ async fn validate_2fa_code(
.check_current(input.as_str())
.map_err(|_| AuthenticationError::InvalidCredentials)?
{
conn.set(
TOTP_NAMESPACE,
&format!("{}-{}", input, user_id.0),
"",
Some(60),
)
.await?;
conn.set(&key, "", Some(60)).await?;
Ok(true)
} else if allow_backup {
+1 -1
View File
@@ -1,10 +1,10 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{HttpRequest, HttpResponse, post, web};
use xredis::RedisPool;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(web::scope("/gdpr").service(export));

Some files were not shown because too many files have changed in this diff Show More