diff --git a/.cargo/config.toml b/.cargo/config.toml index a52d5a685..7115f0015 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,6 @@ -# Windows has stack overflows when calling from Tauri, so we increase compiler size +# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler [target.'cfg(windows)'] -rustflags = ["-C", "link-args=/STACK:16777220"] +rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"] [build] -rustflags = ["--cfg", "tokio_unstable"] \ No newline at end of file +rustflags = ["--cfg", "tokio_unstable"] diff --git a/.dockerignore b/.dockerignore new file mode 120000 index 000000000..3e4e48b0b --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.gitignore \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 72764c8e8..b4b1641f5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,5 +14,5 @@ max_line_length = 100 max_line_length = off trim_trailing_whitespace = false -[*.rs] -indent_size = 4 \ No newline at end of file +[*.{rs,java,kts}] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes index 6313b56c5..7e8596cc8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,35 @@ * text=auto eol=lf + +# SQLx calculates a checksum of migration scripts at build time to compare +# it with the checksum of the applied migration for the same version at +# runtime, to know if the migration script has been changed, and thus the +# DB schema went out of sync with the code. +# +# However, such checksum treats the script as a raw byte stream, taking +# into account inconsequential differences like different line endings +# in different OSes. When combined with Git's EOL conversion and mixed +# native and cross-compilation scenarios, this leads to existing +# migrations that didn't change having potentially different checksums +# according to the environment they were built in, which can break the +# migration system when deploying the Modrinth App, rendering it +# unusable. +# +# The gitattribute above ensures that all text files are checked out +# with LF line endings, but widely deployed app versions were built +# without this attribute set, which left such line endings variable to +# the platform. Thus, there is no perfect solution to this problem: +# forcing CRLF here would break Linux and macOS users, forcing LF +# breaks Windows users, and leaving it unspecified may still lead to +# line ending differences when cross-compiling from Linux to Windows +# or vice versa, or having Git configured with different line +# conversion settings. Moreover, there is no `eol=native` attribute, +# and using CI-only scripts to convert line endings would make the +# builds differ between CI and most local environments. So, let's pick +# the least bad option: let Git handle line endings using its +# configuration by leaving it unspecified, which works fine as long as +# people don't mess with Git's line ending settings, which is the vast +# majority of cases. +/packages/app-lib/migrations/20240711194701_init.sql !eol +/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol +/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol +/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-docker.yml index 1ed8e9685..114c8ee48 100644 --- a/.github/workflows/labrinth-docker.yml +++ b/.github/workflows/labrinth-docker.yml @@ -18,9 +18,6 @@ on: jobs: docker: runs-on: ubuntu-latest - defaults: - run: - working-directory: ./apps/labrinth steps: - name: Checkout uses: actions/checkout@v2 @@ -38,8 +35,6 @@ jobs: - name: Build and push id: docker_build uses: docker/build-push-action@v2 - env: - SQLX_OFFLINE: true with: file: ./apps/labrinth/Dockerfile push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml new file mode 100644 index 000000000..76ae5f900 --- /dev/null +++ b/.github/workflows/theseus-build.yml @@ -0,0 +1,150 @@ +name: Modrinth App build +on: + push: + branches: + - main + tags: + - 'v*' + paths: + - .github/workflows/theseus-build.yml + - 'apps/app/**' + - 'apps/app-frontend/**' + - 'packages/app-lib/**' + - 'packages/app-macros/**' + - 'packages/assets/**' + - 'packages/ui/**' + - 'packages/utils/**' + workflow_dispatch: + inputs: + sign-windows-binaries: + description: Sign Windows binaries + type: boolean + default: true + required: false + +jobs: + build: + name: Build + strategy: + fail-fast: false + matrix: + platform: [macos-latest, windows-latest, ubuntu-22.04] + include: + - platform: macos-latest + artifact-target-name: universal-apple-darwin + - platform: windows-latest + artifact-target-name: x86_64-pc-windows-msvc + - platform: ubuntu-22.04 + artifact-target-name: x86_64-unknown-linux-gnu + + runs-on: ${{ matrix.platform }} + + steps: + - name: 📥 Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🧰 Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + rustflags: '' + target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }} + + - name: 🧰 Install pnpm + uses: pnpm/action-setup@v4 + + - name: 🧰 Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: 🧰 Install Linux build dependencies + if: startsWith(matrix.platform, 'ubuntu') + run: | + sudo apt-get update + sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev + + - name: 🧰 Setup Dasel + uses: jaxxstorm/action-install-gh-release@v2.1.0 + with: + repo: TomWright/dasel + tag: v2.8.1 + extension-matching: disable + rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }} + chmod: 0755 + + - name: ⚙️ Set application version + shell: bash + run: | + APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')" + echo "Setting application version to $APP_VERSION" + dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version' + dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version' + dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version' + + - name: 💨 Setup Turbo cache + uses: rharkor/caching-for-turbo@v1.8 + + - name: 🧰 Install dependencies + run: pnpm install + + - name: ✍️ Set up Windows code signing + if: startsWith(matrix.platform, 'windows') + shell: bash + run: | + if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then + choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already + else + dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand' + fi + + - name: 🔨 Build macOS app + run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json + if: startsWith(matrix.platform, 'macos') + env: + ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + + - name: 🔨 Build Linux app + run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json + if: startsWith(matrix.platform, 'ubuntu') + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + + - name: 🔨 Build Windows app + run: | + [System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream + $env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD" + $env:JAVA_HOME = "$env:JAVA_HOME_11_X64" + pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater' + Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue + if: startsWith(matrix.platform, 'windows') + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }} + DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }} + DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }} + + - name: 📤 Upload app bundles + uses: actions/upload-artifact@v4 + with: + name: App bundle (${{ matrix.artifact-target-name }}) + path: | + target/release/bundle/appimage/Modrinth App_*.AppImage* + target/release/bundle/deb/Modrinth App_*.deb* + target/release/bundle/rpm/Modrinth App-*.rpm* + target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz* + target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg* + target/release/bundle/nsis/Modrinth App_*-setup.exe* + target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip* diff --git a/.github/workflows/theseus-release.yml b/.github/workflows/theseus-release.yml index a34f5ae17..6cd2be01f 100644 --- a/.github/workflows/theseus-release.yml +++ b/.github/workflows/theseus-release.yml @@ -1,157 +1,118 @@ -name: 'Modrinth App build' +name: Modrinth App release on: - push: - branches: - - main - tags: - - 'v*' - paths: - - .github/workflows/theseus-release.yml - - 'apps/app/**' - - 'apps/app-frontend/**' - - 'apps/labrinth/src/common/**' - - 'apps/labrinth/Cargo.toml' - - 'packages/app-lib/**' - - 'packages/app-macros/**' - - 'packages/assets/**' - - 'packages/ui/**' - - 'packages/utils/**' workflow_dispatch: + inputs: + version-tag: + description: Version tag to release to the wide public + type: string + required: true + release-notes: + description: Release notes to include in the Tauri version manifest + default: A new release of the Modrinth App is available! + type: string + required: true jobs: - build: - strategy: - fail-fast: false - matrix: - platform: [macos-latest, windows-latest, ubuntu-22.04] + release: + name: Release Modrinth App + runs-on: ubuntu-latest - runs-on: ${{ matrix.platform }} + env: + LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu) + WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc) + MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin) + LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com steps: - - uses: actions/checkout@v4 - - - name: Rust setup (mac) - if: startsWith(matrix.platform, 'macos') - uses: dtolnay/rust-toolchain@stable + - name: 📥 Download Modrinth App artifacts + uses: dawidd6/action-download-artifact@v11 with: - components: rustfmt, clippy - targets: aarch64-apple-darwin, x86_64-apple-darwin + workflow: theseus-build.yml + workflow_conclusion: success + event: push + branch: ${{ inputs.version-tag }} + use_unzip: true - - name: Rust setup - if: "!startsWith(matrix.platform, 'macos')" - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Setup rust cache - uses: actions/cache@v4 - with: - path: | - target/** - !target/*/release/bundle/*/*.dmg - !target/*/release/bundle/*/*.app.tar.gz - !target/*/release/bundle/*/*.app.tar.gz.sig - !target/release/bundle/*/*.dmg - !target/release/bundle/*/*.app.tar.gz - !target/release/bundle/*/*.app.tar.gz.sig - - !target/release/bundle/appimage/*.AppImage - !target/release/bundle/appimage/*.AppImage.tar.gz - !target/release/bundle/appimage/*.AppImage.tar.gz.sig - !target/release/bundle/deb/*.deb - !target/release/bundle/rpm/*.rpm - - !target/release/bundle/msi/*.msi - !target/release/bundle/msi/*.msi.zip - !target/release/bundle/msi/*.msi.zip.sig - - !target/release/bundle/nsis/*.exe - !target/release/bundle/nsis/*.nsis.zip - !target/release/bundle/nsis/*.nsis.zip.sig - key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-rust-target- - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install pnpm via corepack - shell: bash - run: | - corepack enable - corepack prepare --activate - - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: install dependencies (ubuntu only) - if: startsWith(matrix.platform, 'ubuntu') - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev - - - name: Install frontend dependencies - run: pnpm install - - - name: build app (macos) - run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config "tauri-release.conf.json" - if: startsWith(matrix.platform, 'macos') + - name: 🛠️ Generate version manifest env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + VERSION_TAG: ${{ inputs.version-tag }} + RELEASE_NOTES: ${{ inputs.release-notes }} + run: | + # Reference: https://tauri.app/plugin/updater/#server-support + jq -nc \ + --arg versionTag "${VERSION_TAG#v}" \ + --arg releaseNotes "$RELEASE_NOTES" \ + --rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \ + --rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \ + --rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \ + --rawfile windowsX64UpdateArtifactSignature "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION_TAG#v}_x64-setup.nsis.zip.sig" \ + '{ + "version": $versionTag, + "notes": $releaseNotes, + "pub_date": now | todateiso8601, + "platforms": { + "darwin-aarch64": { + "signature": $macOsAarch64UpdateArtifactSignature, + "url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")", + "install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"] + }, + "darwin-x86_64": { + "signature": $macOsX64UpdateArtifactSignature, + "url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")", + "install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"] + }, + "linux-x86_64": { + "signature": $linuxX64UpdateArtifactSignature, + "url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage.tar.gz")", + "install_urls": [ + @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.deb")", + @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage")", + @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App-" + $versionTag + "-1.x86_64.rpm")" + ] + }, + "windows-x86_64": { + "signature": $windowsX64UpdateArtifactSignature, + "url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.nsis.zip")", + "install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.exe")"] + } + } + }' > updates.json - - name: build app - run: pnpm --filter=@modrinth/app run tauri build --config "tauri-release.conf.json" - id: build_os - if: "!startsWith(matrix.platform, 'macos')" + echo "Generated manifest for version ${VERSION_TAG}:" + cat updates.json + + - name: 📤 Upload release artifacts env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} + VERSION_TAG: ${{ inputs.version-tag }} + AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }} + AWS_REGION: ${{ secrets.LAUNCHER_FILES_BUCKET_REGION }} + AWS_ENDPOINT_URL: ${{ secrets.LAUNCHER_FILES_BUCKET_ENDPOINT_URL }} + AWS_PAGER: '' + # Work around incompatible checksum behavior with some S3-like object storage providers, + # such as Cloudflare R2. See: + # - https://developers.cloudflare.com/r2/examples/aws/aws-cli/ + # - https://developers.cloudflare.com/r2/examples/aws/aws-sdk-java/ + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + run: | + for macosBundleType in 'macos' 'dmg'; do + aws s3 cp --recursive \ + "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \ + "s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos" + done - - name: upload ${{ matrix.platform }} - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.platform }} - path: | - target/*/release/bundle/*/*.dmg - target/*/release/bundle/*/*.app.tar.gz - target/*/release/bundle/*/*.app.tar.gz.sig - target/release/bundle/*/*.dmg - target/release/bundle/*/*.app.tar.gz - target/release/bundle/*/*.app.tar.gz.sig + for linuxBundleType in 'appimage' 'deb' 'rpm'; do + aws s3 cp --recursive \ + "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \ + "s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux" + done - target/release/bundle/*/*.AppImage - target/release/bundle/*/*.AppImage.tar.gz - target/release/bundle/*/*.AppImage.tar.gz.sig - target/release/bundle/*/*.deb - target/release/bundle/*/*.rpm + for windowsBundleType in 'nsis'; do + aws s3 cp --recursive \ + "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \ + "s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows" + done - target/release/bundle/msi/*.msi - target/release/bundle/msi/*.msi.zip - target/release/bundle/msi/*.msi.zip.sig - - target/release/bundle/nsis/*.exe - target/release/bundle/nsis/*.nsis.zip - target/release/bundle/nsis/*.nsis.zip.sig + aws s3 cp updates.json "s3://${AWS_BUCKET}" diff --git a/.github/workflows/turbo-ci.yml b/.github/workflows/turbo-ci.yml index 0061f621a..6f82db1b2 100644 --- a/.github/workflows/turbo-ci.yml +++ b/.github/workflows/turbo-ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ['main'] + branches: [main] pull_request: types: [opened, synchronize] merge_group: @@ -10,71 +10,74 @@ on: jobs: build: - name: Build, Test, and Lint + name: Lint and Test runs-on: ubuntu-22.04 + env: + # Ensure pnpm output is colored in GitHub Actions logs + FORCE_COLOR: 3 + # Make cargo nextest successfully ignore projects without tests + NEXTEST_NO_TESTS: pass + steps: - - name: Check out code + - name: 📥 Check out code uses: actions/checkout@v4 with: fetch-depth: 2 - - name: Cache turbo build setup - uses: actions/cache@v4 - with: - path: .turbo - key: ${{ runner.os }}-turbo-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-turbo- - - - name: Install build dependencies + - name: 🧰 Install build dependencies run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev - - name: Setup Node.JS environment + - name: 🧰 Install pnpm + uses: pnpm/action-setup@v4 + + - name: 🧰 Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc + cache: pnpm - - name: Install pnpm via corepack - shell: bash - run: | - corepack enable - corepack prepare --activate - - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - name: Setup pnpm cache + - name: 🧰 Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- + rustflags: '' + components: clippy, rustfmt + cache: false - - name: Install dependencies + - name: 🧰 Setup nextest + uses: taiki-e/install-action@nextest + + # cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall + # back to a cached cargo install + - name: 🧰 Setup cargo-sqlx + uses: AlexTMjugador/cache-cargo-install-action@feat/features-support + with: + tool: sqlx-cli + locked: false + no-default-features: true + features: rustls,postgres + + - name: 💨 Setup Turbo cache + uses: rharkor/caching-for-turbo@v1.8 + + - name: 🧰 Install dependencies run: pnpm install - - name: Build - run: pnpm build - env: - SQLX_OFFLINE: true + - name: ⚙️ Start services + run: docker compose up --wait - - name: Lint - run: pnpm lint - env: - SQLX_OFFLINE: true + - name: ⚙️ Setup Labrinth environment and database + working-directory: apps/labrinth + run: | + cp .env.local .env + sqlx database setup - - name: Start docker compose - run: docker compose up -d + - name: 🔍 Lint and test + run: pnpm run ci - - name: Test - run: pnpm test - env: - SQLX_OFFLINE: true - DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres + - name: 🔍 Verify intl:extract has been run + run: | + pnpm intl:extract + git diff --exit-code */*/src/locales/en-US/index.json diff --git a/.idea/code.iml b/.idea/code.iml index a4489beac..aeca7f8c1 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/.idea/modules.xml b/.idea/modules.xml index aa7113fc6..23968dc67 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -5,4 +5,4 @@ - + \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..ba331903d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.2 diff --git a/Cargo.lock b/Cargo.lock index 3c29be8c6..f6519c3b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,7 +69,7 @@ dependencies = [ "actix-utils", "base64 0.22.1", "bitflags 2.9.1", - "brotli 8.0.1", + "brotli", "bytes", "bytestring", "derive_more 2.0.1", @@ -81,7 +81,7 @@ dependencies = [ "http 0.2.12", "httparse", "httpdate", - "itoa 1.0.15", + "itoa", "language-tags", "local-channel", "mime", @@ -232,7 +232,7 @@ dependencies = [ "futures-core", "futures-util", "impl-more", - "itoa 1.0.15", + "itoa", "language-tags", "log", "mime", @@ -551,12 +551,12 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.24" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d615619615a650c571269c00dca41db04b9210037fa76ed8239f70404ab56985" +checksum = "40f6024f3f856663b45fd0c9b6f2024034a702f453549449e0d84a305900dad4" dependencies = [ - "brotli 8.0.1", - "bzip2", + "brotli", + "bzip2 0.6.0", "deflate64", "flate2", "futures-core", @@ -902,7 +902,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "itoa 1.0.15", + "itoa", "matchit", "memchr", "mime", @@ -1113,17 +1113,6 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "brotli" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor 4.0.3", -] - [[package]] name = "brotli" version = "8.0.1" @@ -1132,17 +1121,7 @@ checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.0", -] - -[[package]] -name = "brotli-decompressor" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "brotli-decompressor", ] [[package]] @@ -1243,6 +1222,15 @@ dependencies = [ "bzip2-sys", ] +[[package]] +name = "bzip2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "bzip2-sys" version = "0.1.13+1.0.8" @@ -1379,6 +1367,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + [[package]] name = "chrono" version = "0.4.41" @@ -1649,10 +1648,29 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie 0.18.1", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1688,7 +1706,7 @@ dependencies = [ "bitflags 2.9.1", "core-foundation 0.10.0", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -1815,15 +1833,15 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.27.2" +version = "0.29.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" dependencies = [ "cssparser-macros", "dtoa-short", - "itoa 0.4.8", + "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -1847,7 +1865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" dependencies = [ "csv-core", - "itoa 1.0.15", + "itoa", "ryu", "serde", ] @@ -1963,6 +1981,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "data-url" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" + [[package]] name = "deadpool" version = "0.12.2" @@ -2146,7 +2170,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.0", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2246,6 +2270,15 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +dependencies = [ + "litrs", +] + [[package]] name = "dotenv-build" version = "0.1.1" @@ -2666,15 +2699,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -2682,7 +2706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -2696,12 +2720,6 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2968,6 +2986,20 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + [[package]] name = "gdkx11-sys" version = "0.18.2" @@ -3457,16 +3489,14 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.26.0" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" dependencies = [ "log", "mac", "markup5ever", - "proc-macro2", - "quote", - "syn 1.0.109", + "match_token", ] [[package]] @@ -3477,7 +3507,7 @@ checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", "fnv", - "itoa 1.0.15", + "itoa", ] [[package]] @@ -3488,7 +3518,7 @@ checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" dependencies = [ "bytes", "fnv", - "itoa 1.0.15", + "itoa", ] [[package]] @@ -3585,7 +3615,7 @@ dependencies = [ "http-body 0.4.6", "httparse", "httpdate", - "itoa 1.0.15", + "itoa", "pin-project-lite", "socket2", "tokio", @@ -3608,7 +3638,7 @@ dependencies = [ "http-body 1.0.1", "httparse", "httpdate", - "itoa 1.0.15", + "itoa", "pin-project-lite", "smallvec", "tokio", @@ -3633,11 +3663,10 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.5" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "futures-util", "http 1.3.1", "hyper 1.6.0", "hyper-util", @@ -3647,7 +3676,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.2", "tower-service", - "webpki-roots 0.26.11", + "webpki-roots 1.0.0", ] [[package]] @@ -3663,22 +3692,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.6.0", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.14" @@ -3960,7 +3973,7 @@ dependencies = [ "dashmap", "env_logger", "indexmap 2.9.0", - "itoa 1.0.15", + "itoa", "log", "num-format", "once_cell", @@ -4115,12 +4128,6 @@ dependencies = [ "either", ] -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" - [[package]] name = "itoa" version = "1.0.15" @@ -4305,14 +4312,13 @@ dependencies = [ [[package]] name = "kuchikiki" -version = "0.8.2" +version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap 1.9.3", - "matches", + "indexmap 2.9.0", "selectors", ] @@ -4351,7 +4357,7 @@ dependencies = [ "futures-util", "hex", "hmac", - "hyper-tls", + "hyper-rustls 0.27.7", "hyper-util", "iana-time-zone", "image", @@ -4398,7 +4404,7 @@ dependencies = [ "webp", "woothee", "yaserde", - "zip 4.0.0", + "zip", "zxcvbn", ] @@ -4473,6 +4479,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775bf80d5878ab7c2b1080b5351a48b2f737d9f6f8b383574eebcc22be0dfccb" + [[package]] name = "libc" version = "0.2.172" @@ -4564,6 +4576,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + [[package]] name = "local-channel" version = "0.1.5" @@ -4652,18 +4670,29 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.11.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" dependencies = [ "log", - "phf 0.10.1", - "phf_codegen 0.10.0", + "phf 0.11.3", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril", ] +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "matchers" version = "0.1.0" @@ -4870,9 +4899,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de14a9b5d569ca68d7c891d613b390cf5ab4f851c77aaa2f9e435555d3d9492" +checksum = "58b89bf91c19bf036347f1ab85a81c560f08c0667c8601bece664d860a600988" dependencies = [ "crossbeam-channel", "dpi", @@ -4925,23 +4954,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.9.0" @@ -5152,7 +5164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" dependencies = [ "arrayvec", - "itoa 1.0.15", + "itoa", ] [[package]] @@ -5516,50 +5528,12 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl" -version = "0.10.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", -] - [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-sys" -version = "0.9.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -5733,9 +5707,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -5744,7 +5716,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -5769,12 +5743,12 @@ dependencies = [ [[package]] name = "phf_codegen" -version = "0.10.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -5809,12 +5783,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -6245,6 +6219,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "psm" version = "0.1.26" @@ -6274,6 +6254,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "qoi" version = "0.4.1" @@ -6624,7 +6614,7 @@ dependencies = [ "cfg-if", "combine", "futures-util", - "itoa 1.0.15", + "itoa", "num-bigint", "percent-encoding", "pin-project-lite", @@ -6668,6 +6658,26 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "regex" version = "1.11.1" @@ -6735,13 +6745,15 @@ checksum = "e3a8614ee435691de62bcffcf4a66d91b3594bf1428a5722e79103249a095690" [[package]] name = "reqwest" -version = "0.12.19" +version = "0.12.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" +checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" dependencies = [ "async-compression", "base64 0.22.1", "bytes", + "cookie 0.18.1", + "cookie_store", "encoding_rs", "futures-channel", "futures-core", @@ -6751,14 +6763,12 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.6.0", - "hyper-rustls 0.27.5", + "hyper-rustls 0.27.7", "hyper-util", - "ipnet", "js-sys", "log", "mime", "mime_guess", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", @@ -6953,9 +6963,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.37.1" +version = "1.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50" +checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" dependencies = [ "arrayvec", "borsh", @@ -7070,7 +7080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "schannel", "security-framework 2.11.1", ] @@ -7096,6 +7106,15 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -7208,6 +7227,18 @@ dependencies = [ "uuid 1.17.0", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -7311,22 +7342,20 @@ dependencies = [ [[package]] name = "selectors" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", "cssparser", "derive_more 0.99.20", "fxhash", "log", - "matches", "phf 0.8.0", "phf_codegen 0.8.0", "precomputed-hash", "servo_arc", "smallvec", - "thin-slice", ] [[package]] @@ -7340,9 +7369,9 @@ dependencies = [ [[package]] name = "sentry" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a505499b38861edd82b5a688fa06ba4ba5875bb832adeeeba22b7b23fc4bc39a" +checksum = "507ac2be9bf2da56c831da57faf1dadd81f434bd282935cdb06193d0c94e8811" dependencies = [ "httpdate", "reqwest", @@ -7355,14 +7384,13 @@ dependencies = [ "sentry-tracing", "tokio", "ureq", - "webpki-roots 0.26.11", ] [[package]] name = "sentry-actix" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ad8bfdcfbc6e0d0dacaa5728555085ef459fa9226cfc2fe64eefa4b8038b7f" +checksum = "8402c142005ee560ae361c73ebece13a299ec3e9cce5b8654479ea9aac8dc8df" dependencies = [ "actix-http", "actix-web", @@ -7373,9 +7401,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dace796060e4ad10e3d1405b122ae184a8b2e71dce05ae450e4f81b7686b0d9" +checksum = "eb4416302fa5325181a120e0fe7d4afd83cd95e52a9b86afa34a8161383fe0dc" dependencies = [ "backtrace", "regex", @@ -7384,9 +7412,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87bd9e6b51ffe2bc7188ebe36cb67557cb95749c08a3f81f33e8c9b135e0d1bc" +checksum = "936752f42b6f651dcb257da0bfa235ecc79e82011c49ed3383c212cc582263ff" dependencies = [ "hostname", "libc", @@ -7398,9 +7426,9 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7426d4beec270cfdbb50f85f0bb2ce176ea57eed0b11741182a163055a558187" +checksum = "00e9bd2cadaeda3af41e9fa5d14645127d6f6a4aec73da3ae38e477ecafd3682" dependencies = [ "rand 0.9.1", "sentry-types", @@ -7410,9 +7438,9 @@ dependencies = [ [[package]] name = "sentry-debug-images" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df15c066c04f34c4dfd496a8e76590106b93283f72ef1a47d8fb24d88493424" +checksum = "e1e074fe9a0970c91999b23ed3195e6e30990d589fba3a68f20a1686af0f5cda" dependencies = [ "findshlibs", "sentry-core", @@ -7420,9 +7448,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92beed69b776a162b6d269bef1eaa3e614090b6df45a88d9b239c4fdbffdfba" +checksum = "4651d34f3ba649d9e6dc1268443cae6728b8f741c2f0264004f8ecf5b247330d" dependencies = [ "sentry-backtrace", "sentry-core", @@ -7430,10 +7458,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55c323492795de90824f3198562e33dd74ae3bc852fbb13c0cabec54a1cf73cd" +checksum = "c25c47d36bc80c74d26d568ffe970c37b337c061b7234ad6f2d159439c16f000" dependencies = [ + "bitflags 2.9.1", "sentry-backtrace", "sentry-core", "tracing-core", @@ -7442,9 +7471,9 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.38.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04b6c9287202294685cb1f749b944dbbce8160b81a1061ecddc073025fed129f" +checksum = "a08e7154abe2cd557f26fd70038452810748aefdf39bc973f674421224b147c1" dependencies = [ "debugid", "hex", @@ -7547,7 +7576,7 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ - "itoa 1.0.15", + "itoa", "memchr", "ryu", "serde", @@ -7559,7 +7588,7 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" dependencies = [ - "itoa 1.0.15", + "itoa", "serde", ] @@ -7633,22 +7662,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.15", + "itoa", "ryu", "serde", ] [[package]] name = "serde_with" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.9.0", + "schemars 0.9.0", "serde", "serde_derive", "serde_json", @@ -7658,9 +7688,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" dependencies = [ "darling", "proc-macro2", @@ -7692,9 +7722,9 @@ dependencies = [ [[package]] name = "servo_arc" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" dependencies = [ "nodrop", "stable_deref_trait", @@ -7863,7 +7893,7 @@ dependencies = [ "bytemuck", "cfg_aliases", "core-graphics", - "foreign-types 0.5.0", + "foreign-types", "js-sys", "log", "objc2 0.5.2", @@ -7978,6 +8008,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid 1.17.0", "webpki-roots 0.26.11", ] @@ -8043,7 +8074,7 @@ dependencies = [ "hex", "hkdf", "hmac", - "itoa 1.0.15", + "itoa", "log", "md-5", "memchr", @@ -8060,6 +8091,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8084,7 +8116,7 @@ dependencies = [ "hkdf", "hmac", "home", - "itoa 1.0.15", + "itoa", "log", "md-5", "memchr", @@ -8099,6 +8131,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", + "uuid 1.17.0", "whoami", ] @@ -8125,6 +8158,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", + "uuid 1.17.0", ] [[package]] @@ -8353,9 +8387,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tao" -version = "0.33.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e59c1f38e657351a2e822eadf40d6a2ad4627b9c25557bc1180ec1b3295ef82" +checksum = "49c380ca75a231b87b6c9dd86948f035012e7171d1a7c40a9c2890489a7ffd8a" dependencies = [ "bitflags 2.9.1", "core-foundation 0.10.0", @@ -8426,17 +8460,16 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.5.1" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7b0bc1aec81bda6bc455ea98fcaed26b3c98c1648c627ad6ff1c704e8bf8cbc" +checksum = "773663ec28d911ac02f3a478c55f7f2fa581368d9e16ce9dff8d650b3666f91e" dependencies = [ "anyhow", "bytes", "dirs", "dunce", "embed_plist", - "futures-util", - "getrandom 0.2.16", + "getrandom 0.3.3", "glob", "gtk", "heck 0.5.0", @@ -8478,9 +8511,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a0350f0df1db385ca5c02888a83e0e66655c245b7443db8b78a70da7d7f8fc" +checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83" dependencies = [ "anyhow", "cargo_toml", @@ -8489,7 +8522,7 @@ dependencies = [ "heck 0.5.0", "json-patch 3.0.1", "quote", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", @@ -8502,12 +8535,12 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93f035551bf7b11b3f51ad9bc231ebbe5e085565527991c16cf326aa38cdf47" +checksum = "f5df493a1075a241065bc865ed5ef8d0fbc1e76c7afdc0bf0eccfaa7d4f0e406" dependencies = [ "base64 0.22.1", - "brotli 7.0.0", + "brotli", "ico", "json-patch 3.0.1", "plist", @@ -8529,9 +8562,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db4df25e2d9d45de0c4c910da61cd5500190da14ae4830749fee3466dddd112" +checksum = "f237fbea5866fa5f2a60a21bea807a2d6e0379db070d89c3a10ac0f2d4649bbc" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -8543,14 +8576,14 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37a5ebe6a610d1b78a94650896e6f7c9796323f408800cef436e0fa0539de601" +checksum = "1d9a0bd00bf1930ad1a604d08b0eb6b2a9c1822686d65d7f4731a7723b8901d3" dependencies = [ "anyhow", "glob", "plist", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "tauri-utils", @@ -8560,9 +8593,9 @@ dependencies = [ [[package]] name = "tauri-plugin-deep-link" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4976ac728ebc0487515aa956cfdf200abcc52b784e441493fc544bc6ce369c8" +checksum = "ab261eb006db10ab478e3fbb5a4e2692df3f7eb3e28300ee2b64428979167ed0" dependencies = [ "dunce", "rust-ini", @@ -8580,9 +8613,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.2.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33318fe222fc2a612961de8b0419e2982767f213f54a4d3a21b0d7b85c41df8" +checksum = "1aefb14219b492afb30b12647b5b1247cadd2c0603467310c36e0f7ae1698c28" dependencies = [ "log", "raw-window-handle", @@ -8598,15 +8631,15 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ead0daec5d305adcefe05af9d970fc437bcc7996052d564e7393eb291252da" +checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f" dependencies = [ "anyhow", "dunce", "glob", "percent-encoding", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_repr", @@ -8619,17 +8652,41 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-opener" -version = "2.2.7" +name = "tauri-plugin-http" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66644b71a31ec1a8a52c4a16575edd28cf763c87cf4a7da24c884122b5c77097" +checksum = "b0c1a38da944b357ffa23bafd563b1579f18e6fbd118fcd84769406d35dcc5c7" +dependencies = [ + "bytes", + "cookie_store", + "data-url", + "http 1.3.1", + "regex", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "tokio", + "url", + "urlpattern", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecee219f11cdac713ab32959db5d0cceec4810ba4f4458da992292ecf9660321" dependencies = [ "dunce", "glob", "objc2-app-kit", "objc2-foundation 0.3.1", "open", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "tauri", @@ -8642,9 +8699,9 @@ dependencies = [ [[package]] name = "tauri-plugin-os" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424f19432397850c2ddd42aa58078630c15287bbce3866eb1d90e7dbee680637" +checksum = "05bccb4c6de4299beec5a9b070878a01bce9e2c945aa7a75bcea38bcba4c675d" dependencies = [ "gethostname", "log", @@ -8660,24 +8717,24 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.2.4" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d0e07b40fb2eb13778e30778f5979347a2bf30e1b9d47f78ff7fe92d2e4b3d" +checksum = "b441b6d5d1a194e9fee0b358fe0d602ded845d0f580e1f8c8ef78ebc3c8b225d" dependencies = [ "serde", "serde_json", "tauri", "thiserror 2.0.12", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "zbus", ] [[package]] name = "tauri-plugin-updater" -version = "2.7.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f05c38afd77a4b8fd98e8fb6f1cdbb5fbb8a46ba181eb2758b05321e3c6209" +checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b" dependencies = [ "base64 0.22.1", "dirs", @@ -8701,15 +8758,15 @@ dependencies = [ "time", "tokio", "url", - "windows-sys 0.59.0", - "zip 2.4.2", + "windows-sys 0.60.2", + "zip", ] [[package]] name = "tauri-plugin-window-state" -version = "2.2.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27a3fe49de72adbe0d84aee33c89a0b059722cd0b42aaeab29eaaee7f7535cd" +checksum = "5a3d22b21b9cec73601b512a868f7c74f93c044d44fd6ca1c84e9d6afb6b1559" dependencies = [ "bitflags 2.9.1", "log", @@ -8722,9 +8779,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f004905d549854069e6774533d742b03cacfd6f03deb08940a8677586cbe39" +checksum = "9e7bb73d1bceac06c20b3f755b2c8a2cb13b20b50083084a8cf3700daf397ba4" dependencies = [ "cookie 0.18.1", "dpi", @@ -8744,9 +8801,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85d056f4d4b014fe874814034f3416d57114b617a493a4fe552580851a3f3a2" +checksum = "fe52ed0ef40fd7ad51a620ecb3018e32eba3040bb95025216a962a37f6f050c5" dependencies = [ "gtk", "http 1.3.1", @@ -8771,12 +8828,12 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2900399c239a471bcff7f15c4399eb1a8c4fe511ba2853e07c996d771a5e0a4" +checksum = "41743bbbeb96c3a100d234e5a0b60a46d5aa068f266160862c7afdbf828ca02e" dependencies = [ "anyhow", - "brotli 7.0.0", + "brotli", "cargo_metadata", "ctor", "dunce", @@ -8792,7 +8849,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde-untagged", @@ -8855,7 +8912,7 @@ dependencies = [ [[package]] name = "theseus" -version = "0.9.5" +version = "1.0.0-local" dependencies = [ "ariadne", "async-compression", @@ -8864,32 +8921,41 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bytemuck", "bytes", + "chardetng", "chrono", "daedalus", "dashmap", + "data-url", "dirs", "discord-rich-presence", "dunce", "either", + "encoding_rs", "enumset", "flate2", "fs4", "futures", + "hashlink", + "heck 0.5.0", "hickory-resolver", "indicatif", "notify", "notify-debouncer-mini", "p256", "paste", + "png", "quartz_nbt", "quick-xml 0.37.5", "rand 0.8.5", "regex", "reqwest", + "rgb", "serde", "serde_ini", "serde_json", + "serde_with", "sha1_smol", "sha2", "sqlx", @@ -8906,12 +8972,12 @@ dependencies = [ "uuid 1.17.0", "whoami", "winreg 0.55.0", - "zip 4.0.0", + "zip", ] [[package]] name = "theseus_gui" -version = "0.9.5" +version = "1.0.0-local" dependencies = [ "chrono", "daedalus", @@ -8927,6 +8993,7 @@ dependencies = [ "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-http", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-single-instance", @@ -8951,12 +9018,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "thin-slice" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" - [[package]] name = "thiserror" version = "1.0.69" @@ -9056,7 +9117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", - "itoa 1.0.15", + "itoa", "num-conv", "powerfmt", "serde", @@ -9144,16 +9205,6 @@ dependencies = [ "syn 2.0.101", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.24.1" @@ -9459,9 +9510,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.20.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7eee98ec5c90daf179d55c20a49d8c0d043054ce7c26336c09a24d31f14fa0" +checksum = "2da75ec677957aa21f6e0b361df0daab972f13a5bee3606de0638fd4ee1c666a" dependencies = [ "crossbeam-channel", "dirs", @@ -9648,19 +9699,33 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "3.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "9f0fde9bc91026e381155f8c67cb354bcd35260b2f4a29bcc84639f762760c39" dependencies = [ "base64 0.22.1", "log", - "once_cell", + "percent-encoding", "rustls 0.23.27", + "rustls-pemfile 2.2.0", "rustls-pki-types", - "url", + "ureq-proto", + "utf-8", "webpki-roots 0.26.11", ] +[[package]] +name = "ureq-proto" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59db78ad1923f2b1be62b6da81fe80b173605ca0d57f85da2e005382adf693f7" +dependencies = [ + "base64 0.22.1", + "http 1.3.1", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.4" @@ -10067,9 +10132,9 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b542b5cfbd9618c46c2784e4d41ba218c336ac70d44c55e47b251033e7d85601" +checksum = "d4ba622a989277ef3886dd5afb3e280e3dd6d974b766118950a08f8f678ad6a4" dependencies = [ "webview2-com-macros", "webview2-com-sys", @@ -10092,9 +10157,9 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae2d11c4a686e4409659d7891791254cf9286d3cfe0eef54df1523533d22295" +checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c" dependencies = [ "thiserror 2.0.12", "windows", @@ -10340,6 +10405,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -10379,13 +10453,29 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows-version" version = "0.1.4" @@ -10413,6 +10503,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -10431,6 +10527,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -10449,12 +10551,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -10473,6 +10587,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -10491,6 +10611,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -10509,6 +10635,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -10527,6 +10659,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" version = "0.5.40" @@ -10608,8 +10746,8 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "wry" -version = "0.51.2" -source = "git+https://github.com/modrinth/wry?rev=cafdaa9#cafdaa95068221787dd43d14dbd95a6a50bd88b5" +version = "0.52.1" +source = "git+https://github.com/modrinth/wry?rev=21db186#21db1866d53e7be8b513c7272887c6993e7f09b3" dependencies = [ "base64 0.22.1", "block2 0.6.1", @@ -10617,6 +10755,7 @@ dependencies = [ "crossbeam-channel", "dpi", "dunce", + "gdkx11", "gtk", "html5ever", "http 1.3.1", @@ -10645,6 +10784,7 @@ dependencies = [ "windows", "windows-core", "windows-version", + "x11-dl", ] [[package]] @@ -10898,27 +11038,12 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "95ab361742de920c5535880f89bbd611ee62002bf11341d16a5f057bb8ba6899" dependencies = [ "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "indexmap 2.9.0", - "memchr", - "thiserror 2.0.12", -] - -[[package]] -name = "zip" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd" -dependencies = [ - "arbitrary", - "bzip2", + "bzip2 0.5.2", "crc32fast", "deflate64", "flate2", diff --git a/Cargo.toml b/Cargo.toml index eabe40a95..d95e9b601 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ members = [ "packages/daedalus", ] +[workspace.package] +edition = "2024" + [workspace.dependencies] actix-cors = "0.7.1" actix-files = "0.6.6" @@ -21,7 +24,8 @@ actix-web-prom = "0.10.0" actix-ws = "0.3.0" argon2 = { version = "0.5.3", features = ["std"] } ariadne = { path = "packages/ariadne" } -async-compression = { version = "0.4.24", default-features = false } +async_zip = "0.0.17" +async-compression = { version = "0.4.25", default-features = false } async-recursion = "1.1.1" async-stripe = { version = "0.41.0", default-features = false, features = [ "runtime-tokio-hyper-rustls", @@ -31,11 +35,12 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [ "futures-03-sink", ] } async-walkdir = "2.1.0" -async_zip = "0.0.17" base64 = "0.22.1" bitflags = "2.9.1" +bytemuck = "1.23.0" bytes = "1.10.1" censor = "0.3.0" +chardetng = "0.1.17" chrono = "0.4.41" clap = "4.5.40" clickhouse = "0.13.3" @@ -43,6 +48,7 @@ color-thief = "0.2.2" console-subscriber = "0.4.1" daedalus = { path = "packages/daedalus" } dashmap = "6.1.0" +data-url = "0.3.1" deadpool-redis = "0.21.1" dirs = "6.0.0" discord-rich-presence = "0.2.5" @@ -50,15 +56,23 @@ dotenv-build = "0.1.1" dotenvy = "0.15.7" dunce = "1.0.5" either = "1.15.0" +encoding_rs = "0.8.35" enumset = "1.1.6" flate2 = "1.1.2" fs4 = { version = "0.13.1", default-features = false } futures = { version = "0.3.31", default-features = false } futures-util = "0.3.31" +hashlink = "0.10.0" +heck = "0.5.0" hex = "0.4.3" hickory-resolver = "0.25.2" hmac = "0.12.1" -hyper-tls = "0.6.0" +hyper-rustls = { version = "0.27.7", default-features = false, features = [ + "http1", + "native-tokio", + "ring", + "tls12", +] } hyper-util = "0.1.14" iana-time-zone = "0.1.63" image = { version = "0.25.6", default-features = false, features = ["rayon"] } @@ -84,26 +98,28 @@ notify = { version = "8.0.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false } p256 = "0.13.2" paste = "1.0.15" +png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.37.5" 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 -redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 +redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 regex = "1.11.1" -reqwest = { version = "0.12.19", default-features = false } +reqwest = { version = "0.12.20", default-features = false } +rgb = "0.8.50" +rust_decimal = { version = "1.37.2", features = [ + "serde-with-float", + "serde-with-str", +] } +rust_iso3166 = "0.1.14" rust-s3 = { version = "0.35.1", default-features = false, features = [ "fail-on-err", "tags", "tokio-rustls-tls", ] } -rust_decimal = { version = "1.37.1", features = [ - "serde-with-float", - "serde-with-str", -] } -rust_iso3166 = "0.1.14" rusty-money = "0.4.1" -sentry = { version = "0.38.1", default-features = false, features = [ +sentry = { version = "0.41.0", default-features = false, features = [ "backtrace", "contexts", "debug-images", @@ -111,14 +127,14 @@ sentry = { version = "0.38.1", default-features = false, features = [ "reqwest", "rustls", ] } -sentry-actix = "0.38.1" +sentry-actix = "0.41.0" serde = "1.0.219" -serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this serde_bytes = "0.11.17" serde_cbor = "0.11.2" serde_ini = "0.2.0" serde_json = "1.0.140" -serde_with = "3.12.0" +serde_with = "3.13.0" +serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this sha1 = "0.10.6" sha1_smol = { version = "1.0.1", features = ["std"] } sha2 = "0.10.9" @@ -126,18 +142,19 @@ spdx = "0.10.8" sqlx = { version = "0.8.6", default-features = false } sysinfo = { version = "0.35.2", default-features = false } tar = "0.4.44" -tauri = "2.5.1" -tauri-build = "2.2.0" -tauri-plugin-deep-link = "2.3.0" -tauri-plugin-dialog = "2.2.2" -tauri-plugin-opener = "2.2.7" -tauri-plugin-os = "2.2.1" -tauri-plugin-single-instance = "2.2.4" -tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [ +tauri = "2.6.1" +tauri-build = "2.3.0" +tauri-plugin-deep-link = "2.4.0" +tauri-plugin-dialog = "2.3.0" +tauri-plugin-http = "2.5.0" +tauri-plugin-opener = "2.4.0" +tauri-plugin-os = "2.3.0" +tauri-plugin-single-instance = "2.3.0" +tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [ "rustls-tls", "zip", ] } -tauri-plugin-window-state = "2.2.2" +tauri-plugin-window-state = "2.3.0" tempfile = "3.20.0" theseus = { path = "packages/app-lib" } thiserror = "2.0.12" @@ -160,7 +177,7 @@ whoami = "1.6.0" winreg = "0.55.0" woothee = "0.13.0" yaserde = "0.12.0" -zip = { version = "4.0.0", default-features = false, features = [ +zip = { version = "4.2.0", default-features = false, features = [ "bzip2", "deflate", "deflate64", @@ -168,8 +185,46 @@ zip = { version = "4.0.0", default-features = false, features = [ ] } zxcvbn = "3.1.0" +[workspace.lints.clippy] +bool_to_int_with_if = "warn" +borrow_as_ptr = "warn" +cfg_not_test = "warn" +clear_with_drain = "warn" +cloned_instead_of_copied = "warn" +collection_is_never_read = "warn" +dbg_macro = "warn" +default_trait_access = "warn" +explicit_iter_loop = "warn" +filter_map_next = "warn" +flat_map_option = "warn" +format_push_string = "warn" +get_unwrap = "warn" +large_include_file = "warn" +large_stack_arrays = "warn" +manual_assert = "warn" +manual_instant_elapsed = "warn" +manual_is_variant_and = "warn" +manual_let_else = "warn" +map_unwrap_or = "warn" +match_bool = "warn" +needless_collect = "warn" +negative_feature_names = "warn" +non_std_lazy_statics = "warn" +pathbuf_init_then_push = "warn" +read_zero_byte_vec = "warn" +redundant_clone = "warn" +redundant_feature_names = "warn" +redundant_type_annotations = "warn" +todo = "warn" +unnested_or_patterns = "warn" +wildcard_dependencies = "warn" + +[workspace.lints.rust] +# Turn warnings into errors by default +warnings = "deny" + [patch.crates-io] -wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" } +wry = { git = "https://github.com/modrinth/wry", rev = "21db186" } # Optimize for speed and reduce size on release builds [profile.release] diff --git a/apps/app-frontend/.prettierignore b/apps/app-frontend/.prettierignore index 581edad3d..0cb3e84e5 100644 --- a/apps/app-frontend/.prettierignore +++ b/apps/app-frontend/.prettierignore @@ -1 +1,2 @@ **/dist +*.gltf diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index fa6d8f926..b58e63553 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -1,7 +1,7 @@ { "name": "@modrinth/app-frontend", "private": true, - "version": "0.9.5", + "version": "1.0.0-local", "type": "module", "scripts": { "dev": "vite", @@ -9,26 +9,31 @@ "tsc:check": "vue-tsc --noEmit", "lint": "eslint . && prettier --check .", "fix": "eslint . --fix && prettier --write .", - "intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace" + "intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", + "test": "vue-tsc --noEmit" }, "dependencies": { + "@geometrically/minecraft-motd-parser": "^1.1.4", "@modrinth/assets": "workspace:*", "@modrinth/ui": "workspace:*", "@modrinth/utils": "workspace:*", "@sentry/vue": "^8.27.0", - "@geometrically/minecraft-motd-parser": "^1.1.4", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.1", - "@tauri-apps/plugin-os": "^2.2.1", + "@tauri-apps/plugin-http": "^2.5.0", "@tauri-apps/plugin-opener": "^2.2.6", + "@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", + "@types/three": "^0.172.0", "@vintl/vintl": "^4.4.1", + "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "ofetch": "^1.3.4", "pinia": "^2.1.7", "posthog-js": "^1.158.2", + "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", "vue-multiselect": "3.0.0", @@ -39,11 +44,12 @@ "@eslint/compat": "^1.1.1", "@formatjs/cli": "^6.2.12", "@nuxt/eslint-config": "^0.5.6", + "@taijased/vue-render-tracker": "^1.0.7", "@vitejs/plugin-vue": "^5.0.4", "autoprefixer": "^10.4.19", "eslint": "^9.9.1", "eslint-config-custom": "workspace:*", - "eslint-plugin-turbo": "^2.1.1", + "eslint-plugin-turbo": "^2.5.4", "postcss": "^8.4.39", "prettier": "^3.2.5", "sass": "^1.74.1", @@ -51,8 +57,7 @@ "tsconfig": "workspace:*", "typescript": "^5.5.4", "vite": "^5.4.6", - "vue-tsc": "^2.1.6", - "@taijased/vue-render-tracker": "^1.0.7" + "vue-tsc": "^2.1.6" }, "packageManager": "pnpm@9.4.0", "web-types": "../../web-types.json" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 760c8d47e..1bc25942c 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,8 +1,9 @@ diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue new file mode 100644 index 000000000..3bb559d60 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue @@ -0,0 +1,140 @@ + + diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue new file mode 100644 index 000000000..818922eff --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue @@ -0,0 +1,140 @@ + + + diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue index 9ad0bbdcd..12fc67468 100644 --- a/apps/app-frontend/src/components/ui/world/InstanceItem.vue +++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue @@ -1,4 +1,5 @@ + + + + diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js index 82b0b3ec2..2e0361cd5 100644 --- a/apps/app-frontend/src/pages/index.js +++ b/apps/app-frontend/src/pages/index.js @@ -1,5 +1,6 @@ import Index from './Index.vue' import Browse from './Browse.vue' import Worlds from './Worlds.vue' +import Skins from './Skins.vue' -export { Index, Browse, Worlds } +export { Index, Browse, Worlds, Skins } diff --git a/apps/app-frontend/src/pages/instance/Logs.vue b/apps/app-frontend/src/pages/instance/Logs.vue index e8750c628..83d0cbe8d 100644 --- a/apps/app-frontend/src/pages/instance/Logs.vue +++ b/apps/app-frontend/src/pages/instance/Logs.vue @@ -483,7 +483,7 @@ onUnmounted(() => { display: flex; flex-direction: column; gap: 1rem; - height: calc(100vh - 11rem); + height: 100vh; } .button-row { diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 6d5e4e372..67172e68d 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -34,6 +34,14 @@ export default new createRouter({ breadcrumb: [{ name: 'Discover content' }], }, }, + { + path: '/skins', + name: 'Skins', + component: Pages.Skins, + meta: { + breadcrumb: [{ name: 'Skins' }], + }, + }, { path: '/library', name: 'Library', diff --git a/apps/app-frontend/tailwind.config.js b/apps/app-frontend/tailwind.config.js index 0d0fab4bf..b5196b368 100644 --- a/apps/app-frontend/tailwind.config.js +++ b/apps/app-frontend/tailwind.config.js @@ -41,6 +41,7 @@ export default { green: 'var(--color-green-highlight)', blue: 'var(--color-blue-highlight)', purple: 'var(--color-purple-highlight)', + gray: 'var(--color-gray-highlight)', }, divider: { DEFAULT: 'var(--color-divider)', diff --git a/apps/app-frontend/tsconfig.node.json b/apps/app-frontend/tsconfig.node.json index e5a932a9e..ac300be84 100644 --- a/apps/app-frontend/tsconfig.node.json +++ b/apps/app-frontend/tsconfig.node.json @@ -10,6 +10,7 @@ "isolatedModules": true, "moduleDetection": "force", "noEmit": true, + "resolveJsonModule": true, "strict": true }, diff --git a/apps/app-frontend/vite.config.ts b/apps/app-frontend/vite.config.ts index 3f88715a9..8adf5fb2d 100644 --- a/apps/app-frontend/vite.config.ts +++ b/apps/app-frontend/vite.config.ts @@ -4,6 +4,8 @@ import svgLoader from 'vite-svg-loader' import vue from '@vitejs/plugin-vue' +import tauriConf from '../app/tauri.conf.json' + const projectRootDir = resolve(__dirname) // https://vitejs.dev/config/ @@ -41,17 +43,32 @@ export default defineConfig({ server: { port: 1420, strictPort: true, + headers: { + 'content-security-policy': Object.entries(tauriConf.app.security.csp) + .map(([directive, sources]) => { + // An additional websocket connect-src is required for Vite dev tools to work + if (directive === 'connect-src') { + sources = Array.isArray(sources) ? sources : [sources] + sources.push('ws://localhost:1420') + } + + return Array.isArray(sources) + ? `${directive} ${sources.join(' ')}` + : `${directive} ${sources}` + }) + .join('; '), + }, }, // to make use of `TAURI_ENV_DEBUG` and other env variables // https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands envPrefix: ['VITE_', 'TAURI_'], build: { // Tauri supports es2021 - target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars + target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars // don't minify for debug builds - minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars + minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars // produce sourcemaps for debug builds - sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars + sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars commonjsOptions: { esmExternals: true, }, diff --git a/apps/app-playground/Cargo.toml b/apps/app-playground/Cargo.toml index 7f2e4288a..691c9d3b7 100644 --- a/apps/app-playground/Cargo.toml +++ b/apps/app-playground/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "theseus_playground" version = "0.0.0" -edition = "2024" +edition.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -9,3 +9,6 @@ edition = "2024" theseus = { workspace = true, features = ["cli"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } enumset.workspace = true + +[lints] +workspace = true diff --git a/apps/app-playground/package.json b/apps/app-playground/package.json index 0d76eaed8..342b3cecb 100644 --- a/apps/app-playground/package.json +++ b/apps/app-playground/package.json @@ -2,9 +2,9 @@ "name": "@modrinth/app-playground", "scripts": { "build": "cargo build --release", - "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings", - "fix": "cargo fmt && cargo clippy --fix", + "lint": "cargo fmt --check && cargo clippy --all-targets", + "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", "dev": "cargo run", - "test": "cargo test" + "test": "cargo nextest run --all-targets --no-fail-fast" } } diff --git a/apps/app-playground/src/main.rs b/apps/app-playground/src/main.rs index 24019b125..a2c2b8922 100644 --- a/apps/app-playground/src/main.rs +++ b/apps/app-playground/src/main.rs @@ -27,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result { let credentials = minecraft_auth::finish_login(&input, login).await?; - println!("Logged in user {}.", credentials.username); + println!( + "Logged in user {}.", + credentials.maybe_online_profile().await.name + ); Ok(credentials) } diff --git a/apps/app/.gitignore b/apps/app/.gitignore index d887d6c0b..f73fca36c 100644 --- a/apps/app/.gitignore +++ b/apps/app/.gitignore @@ -1,6 +1,2 @@ -# Generated by Cargo -# will have compiled files and executables -/target/ - # Generated by tauri, metadata generated at compile time /gen/ diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index d05c3f308..d1c67affc 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -1,11 +1,10 @@ [package] name = "theseus_gui" -version = "0.9.5" +version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging description = "The Modrinth App is a desktop application for managing your Minecraft mods" license = "GPL-3.0-only" repository = "https://github.com/modrinth/code/apps/app/" -edition = "2024" -build = "build.rs" +edition.workspace = true [build-dependencies] tauri-build = { workspace = true, features = ["codegen"] } @@ -17,14 +16,15 @@ serde_json.workspace = true serde = { workspace = true, features = ["derive"] } serde_with.workspace = true -tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] } -tauri-plugin-window-state.workspace = true +tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] } tauri-plugin-deep-link.workspace = true -tauri-plugin-os.workspace = true -tauri-plugin-opener.workspace = true tauri-plugin-dialog.workspace = true -tauri-plugin-updater.workspace = true +tauri-plugin-http.workspace = true +tauri-plugin-opener.workspace = true +tauri-plugin-os.workspace = true tauri-plugin-single-instance.workspace = true +tauri-plugin-updater.workspace = true +tauri-plugin-window-state.workspace = true tokio = { workspace = true, features = ["time"] } thiserror.workspace = true @@ -56,3 +56,6 @@ default = ["custom-protocol"] # DO NOT remove this custom-protocol = ["tauri/custom-protocol"] updater = [] + +[lints] +workspace = true diff --git a/apps/app/Info.plist b/apps/app/Info.plist index 2e875fe1f..844d3a25f 100644 --- a/apps/app/Info.plist +++ b/apps/app/Info.plist @@ -18,5 +18,25 @@ A Minecraft mod wants to access your camera. NSMicrophoneUsageDescription A Minecraft mod wants to access your microphone. + NSAppTransportSecurity + + NSExceptionDomains + + asset.localhost + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + textures.minecraft.net + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + diff --git a/apps/app/build.rs b/apps/app/build.rs index 644d22b68..7a4da8872 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -99,6 +99,24 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "minecraft-skins", + InlinedPlugin::new() + .commands(&[ + "get_available_capes", + "get_available_skins", + "add_and_equip_custom_skin", + "set_default_cape", + "equip_skin", + "remove_custom_skin", + "unequip_skin", + "normalize_skin_texture", + "get_dragged_skin_data", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "mr-auth", InlinedPlugin::new() @@ -151,7 +169,6 @@ fn main() { "profile_update_managed_modrinth_version", "profile_repair_managed_modrinth", "profile_run", - "profile_run_credentials", "profile_kill", "profile_edit", "profile_edit_icon", diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index b9777b6d9..b3947857b 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -19,12 +19,21 @@ "window-state:default", "window-state:allow-restore-state", "window-state:allow-save-window-state", + + { + "identifier": "http:default", + "allow": [ + { "url": "https://modrinth.com/*" }, + { "url": "https://*.modrinth.com/*" } + ] + }, "auth:default", "import:default", "jre:default", "logs:default", "metadata:default", + "minecraft-skins:default", "mr-auth:default", "profile-create:default", "pack:default", diff --git a/apps/app/package.json b/apps/app/package.json index 794bf7e0b..43f017203 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,12 +1,12 @@ { "name": "@modrinth/app", "scripts": { - "build": "tauri build", "tauri": "tauri", + "build": "tauri build", "dev": "tauri dev", - "test": "cargo test", - "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings", - "fix": "cargo fmt && cargo clippy --fix" + "test": "cargo nextest run --all-targets --no-fail-fast", + "lint": "cargo fmt --check && cargo clippy --all-targets", + "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt" }, "devDependencies": { "@tauri-apps/cli": "2.5.0" diff --git a/apps/app/src/api/jre.rs b/apps/app/src/api/jre.rs index 036d5889b..71c72257c 100644 --- a/apps/app/src/api/jre.rs +++ b/apps/app/src/api/jre.rs @@ -41,8 +41,8 @@ pub async fn jre_find_filtered_jres( // Validates JRE at a given path // Returns None if the path is not a valid JRE #[tauri::command] -pub async fn jre_get_jre(path: PathBuf) -> Result> { - jre::check_jre(path).await.map_err(|e| e.into()) +pub async fn jre_get_jre(path: PathBuf) -> Result { + Ok(jre::check_jre(path).await?) } // Tests JRE of a certain version diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs new file mode 100644 index 000000000..a6d138fbd --- /dev/null +++ b/apps/app/src/api/minecraft_skins.rs @@ -0,0 +1,104 @@ +use crate::api::Result; + +use std::path::Path; +use theseus::minecraft_skins::{ + self, Bytes, Cape, MinecraftSkinVariant, Skin, UrlOrBlob, +}; + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("minecraft-skins") + .invoke_handler(tauri::generate_handler![ + get_available_capes, + get_available_skins, + add_and_equip_custom_skin, + set_default_cape, + equip_skin, + remove_custom_skin, + unequip_skin, + normalize_skin_texture, + get_dragged_skin_data, + ]) + .build() +} + +/// `invoke('plugin:minecraft-skins|get_available_capes')` +/// +/// See also: [minecraft_skins::get_available_capes] +#[tauri::command] +pub async fn get_available_capes() -> Result> { + Ok(minecraft_skins::get_available_capes().await?) +} + +/// `invoke('plugin:minecraft-skins|get_available_skins')` +/// +/// See also: [minecraft_skins::get_available_skins] +#[tauri::command] +pub async fn get_available_skins() -> Result> { + Ok(minecraft_skins::get_available_skins().await?) +} + +/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)` +/// +/// See also: [minecraft_skins::add_and_equip_custom_skin] +#[tauri::command] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> Result<()> { + Ok(minecraft_skins::add_and_equip_custom_skin( + texture_blob, + variant, + cape_override, + ) + .await?) +} + +/// `invoke('plugin:minecraft-skins|set_default_cape', cape)` +/// +/// See also: [minecraft_skins::set_default_cape] +#[tauri::command] +pub async fn set_default_cape(cape: Option) -> Result<()> { + Ok(minecraft_skins::set_default_cape(cape).await?) +} + +/// `invoke('plugin:minecraft-skins|equip_skin', skin)` +/// +/// See also: [minecraft_skins::equip_skin] +#[tauri::command] +pub async fn equip_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::equip_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)` +/// +/// See also: [minecraft_skins::remove_custom_skin] +#[tauri::command] +pub async fn remove_custom_skin(skin: Skin) -> Result<()> { + Ok(minecraft_skins::remove_custom_skin(skin).await?) +} + +/// `invoke('plugin:minecraft-skins|unequip_skin')` +/// +/// See also: [minecraft_skins::unequip_skin] +#[tauri::command] +pub async fn unequip_skin() -> Result<()> { + Ok(minecraft_skins::unequip_skin().await?) +} + +/// `invoke('plugin:minecraft-skins|normalize_skin_texture')` +/// +/// See also: [minecraft_skins::normalize_skin_texture] +#[tauri::command] +pub async fn normalize_skin_texture(texture: UrlOrBlob) -> Result { + Ok(minecraft_skins::normalize_skin_texture(&texture).await?) +} + +/// `invoke('plugin:minecraft-skins|get_dragged_skin_data', path)` +/// +/// See also: [minecraft_skins::get_dragged_skin_data] +#[tauri::command] +pub async fn get_dragged_skin_data(path: String) -> Result { + let path = Path::new(&path); + Ok(minecraft_skins::get_dragged_skin_data(path).await?) +} diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 09d37e87a..294e784f6 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -7,6 +7,7 @@ pub mod import; pub mod jre; pub mod logs; pub mod metadata; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs index db979be35..1d812639e 100644 --- a/apps/app/src/api/profile.rs +++ b/apps/app/src/api/profile.rs @@ -28,7 +28,6 @@ pub fn init() -> tauri::plugin::TauriPlugin { profile_update_managed_modrinth_version, profile_repair_managed_modrinth, profile_run, - profile_run_credentials, profile_kill, profile_edit, profile_edit_icon, @@ -256,22 +255,6 @@ pub async fn profile_run(path: &str) -> Result { Ok(process) } -// Run Minecraft using a profile using chosen credentials -// Returns the UUID, which can be used to poll -// for the actual Child in the state. -// invoke('plugin:profile|profile_run_credentials', {path, credentials})') -#[tauri::command] -pub async fn profile_run_credentials( - path: &str, - credentials: Credentials, -) -> Result { - let process = - profile::run_credentials(path, &credentials, &QuickPlayType::None) - .await?; - - Ok(process) -} - #[tauri::command] pub async fn profile_kill(path: &str) -> Result<()> { profile::kill(path).await?; diff --git a/apps/app/src/api/utils.rs b/apps/app/src/api/utils.rs index 554ce9207..0ba25fc46 100644 --- a/apps/app/src/api/utils.rs +++ b/apps/app/src/api/utils.rs @@ -37,6 +37,7 @@ pub fn get_os() -> OS { let os = OS::MacOS; os } + #[derive(Debug, Clone, Serialize, Deserialize)] #[allow(clippy::enum_variant_names)] pub enum OS { diff --git a/apps/app/src/api/worlds.rs b/apps/app/src/api/worlds.rs index f72f738b8..544ce05d6 100644 --- a/apps/app/src/api/worlds.rs +++ b/apps/app/src/api/worlds.rs @@ -43,7 +43,7 @@ pub async fn get_recent_worlds( display_statuses.unwrap_or(EnumSet::all()), ) .await?; - for world in result.iter_mut() { + for world in &mut result { adapt_world_icon(&app_handle, &mut world.world); } Ok(result) @@ -55,7 +55,7 @@ pub async fn get_profile_worlds( path: &str, ) -> Result> { let mut result = worlds::get_profile_worlds(path).await?; - for world in result.iter_mut() { + for world in &mut result { adapt_world_icon(&app_handle, world); } Ok(result) diff --git a/apps/app/src/macos/deep_link.rs b/apps/app/src/macos/deep_link.rs index e2c66ac15..08a9bc756 100644 --- a/apps/app/src/macos/deep_link.rs +++ b/apps/app/src/macos/deep_link.rs @@ -11,7 +11,8 @@ pub fn get_or_init_payload>( manager: &M, ) -> InitialPayload { let initial_payload = manager.try_state::(); - let mtx = if let Some(initial_payload) = initial_payload { + + if let Some(initial_payload) = initial_payload { initial_payload.inner().clone() } else { tracing::info!("No initial payload found, creating new"); @@ -22,7 +23,5 @@ pub fn get_or_init_payload>( manager.manage(payload.clone()); payload - }; - - mtx + } } diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 4291431df..b6b00ea81 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -183,6 +183,7 @@ fn main() { let _ = win.set_focus(); } })) + .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_deep_link::init()) @@ -197,7 +198,7 @@ fn main() { { let payload = macos::deep_link::get_or_init_payload(app); - let mtx_copy = payload.payload.clone(); + let mtx_copy = payload.payload; app.listen("deep-link://new-url", move |url| { let mtx_copy_copy = mtx_copy.clone(); let request = url.payload().to_owned(); @@ -229,7 +230,6 @@ fn main() { tauri::async_runtime::spawn(api::utils::handle_command( payload, )); - dbg!(url); }); #[cfg(not(target_os = "linux"))] @@ -249,6 +249,7 @@ fn main() { .plugin(api::logs::init()) .plugin(api::jre::init()) .plugin(api::metadata::init()) + .plugin(api::minecraft_skins::init()) .plugin(api::pack::init()) .plugin(api::process::init()) .plugin(api::profile::init()) @@ -273,22 +274,22 @@ fn main() { match app { Ok(app) => { - #[allow(unused_variables)] app.run(|app, event| { + #[cfg(not(target_os = "macos"))] + drop((app, event)); #[cfg(target_os = "macos")] if let tauri::RunEvent::Opened { urls } = event { tracing::info!("Handling webview open {urls:?}"); let file = urls .into_iter() - .filter_map(|url| url.to_file_path().ok()) - .next(); + .find_map(|url| url.to_file_path().ok()); if let Some(file) = file { let payload = macos::deep_link::get_or_init_payload(app); - let mtx_copy = payload.payload.clone(); + let mtx_copy = payload.payload; let request = file.to_string_lossy().to_string(); tauri::async_runtime::spawn(async move { let mut payload = mtx_copy.lock().await; diff --git a/apps/app/tauri-release.conf.json b/apps/app/tauri-release.conf.json index 32b8a3489..47b3705a3 100644 --- a/apps/app/tauri-release.conf.json +++ b/apps/app/tauri-release.conf.json @@ -1,6 +1,24 @@ { "bundle": { - "createUpdaterArtifacts": "v1Compatible" + "createUpdaterArtifacts": "v1Compatible", + "windows": { + "signCommand": { + "cmd": "jsign", + "args": [ + "sign", + "--verbose", + "--storetype", + "DIGICERTONE", + "--keystore", + "https://clientauth.one.digicert.com", + "--storepass", + "env:DIGICERT_ONE_SIGNER_CREDENTIALS", + "--tsaurl", + "https://timestamp.sectigo.com,http://timestamp.digicert.com", + "%1" + ] + } + } }, "build": { "features": ["updater"] diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index 72e380d8d..724e536d8 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -14,9 +14,6 @@ "externalBin": [], "icon": ["icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "windows": { - "certificateThumbprint": null, - "digestAlgorithm": "sha256", - "timestampUrl": "http://timestamp.digicert.com", "nsis": { "installMode": "perMachine", "installerHooks": "./nsis/hooks.nsi" @@ -30,7 +27,6 @@ "providerShortName": null, "signingIdentity": null }, - "resources": [], "shortDescription": "", "linux": { "deb": { @@ -45,7 +41,7 @@ ] }, "productName": "Modrinth App", - "version": "0.9.5", + "version": "../app-frontend/package.json", "mainBinaryName": "Modrinth App", "identifier": "ModrinthApp", "plugins": { @@ -90,9 +86,9 @@ "capabilities": ["ads", "core", "plugins"], "csp": { "default-src": "'self' customprotocol: asset:", - "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs", + "connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:", "font-src": ["https://cdn-raw.modrinth.com/fonts/"], - "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:", + "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "style-src": "'unsafe-inline' 'self'", "script-src": "https://*.posthog.com 'self'", "frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'", diff --git a/apps/app/turbo.jsonc b/apps/app/turbo.jsonc new file mode 100644 index 000000000..7cdf6bd4b --- /dev/null +++ b/apps/app/turbo.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "../../node_modules/turbo/schema.json", + "extends": ["//"], + "tasks": { + // Running Clippy and tests on a Tauri application requires + // the frontend to be built at least once first + "lint": { + "dependsOn": ["@modrinth/app-frontend#build"] + }, + "test": { + "dependsOn": ["@modrinth/app-frontend#build"] + } + } +} diff --git a/apps/daedalus_client/Cargo.toml b/apps/daedalus_client/Cargo.toml index cb2a646cd..ffec1a471 100644 --- a/apps/daedalus_client/Cargo.toml +++ b/apps/daedalus_client/Cargo.toml @@ -2,7 +2,7 @@ name = "daedalus_client" version = "0.2.2" authors = ["Jai A "] -edition = "2024" +edition.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -28,3 +28,6 @@ tracing-error.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } + +[lints] +workspace = true diff --git a/apps/daedalus_client/Dockerfile b/apps/daedalus_client/Dockerfile index 5e5930e29..9ea70f9ca 100644 --- a/apps/daedalus_client/Dockerfile +++ b/apps/daedalus_client/Dockerfile @@ -1,5 +1,4 @@ -FROM rust:1.86.0 AS build -ENV PKG_CONFIG_ALLOW_CROSS=1 +FROM rust:1.88.0 AS build WORKDIR /usr/src/daedalus COPY . . @@ -10,11 +9,8 @@ FROM debian:bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates openssl \ - && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN update-ca-certificates - COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client WORKDIR /daedalus_client diff --git a/apps/daedalus_client/package.json b/apps/daedalus_client/package.json index 1f48075b1..f75b9d768 100644 --- a/apps/daedalus_client/package.json +++ b/apps/daedalus_client/package.json @@ -2,10 +2,10 @@ "name": "@modrinth/daedalus_client", "scripts": { "build": "cargo build --release", - "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings", - "fix": "cargo fmt && cargo clippy --fix", + "lint": "cargo fmt --check && cargo clippy --all-targets", + "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", "dev": "cargo run", - "test": "cargo test" + "test": "cargo nextest run --all-targets --no-fail-fast" }, "dependencies": { "@modrinth/daedalus": "workspace:*" diff --git a/apps/daedalus_client/src/minecraft.rs b/apps/daedalus_client/src/minecraft.rs index c2ee6e2d4..f90cf6ddd 100644 --- a/apps/daedalus_client/src/minecraft.rs +++ b/apps/daedalus_client/src/minecraft.rs @@ -52,8 +52,7 @@ pub async fn fetch( if modrinth_version .original_sha1 .as_ref() - .map(|x| x == &version.sha1) - .unwrap_or(false) + .is_some_and(|x| x == &version.sha1) { existing_versions.push(modrinth_version); } else { diff --git a/apps/docs/package.json b/apps/docs/package.json index 8ce3806e6..274ddd55c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro check && astro build", + "lint": "astro check", + "build": "astro build", "preview": "astro preview", "astro": "astro" }, @@ -18,4 +19,4 @@ "starlight-openapi": "^0.14.0", "typescript": "^5.8.2" } -} \ No newline at end of file +} diff --git a/apps/docs/public/welcome-channel.yaml b/apps/docs/public/welcome-channel.yaml index f5893ba2b..21728ff2d 100644 --- a/apps/docs/public/welcome-channel.yaml +++ b/apps/docs/public/welcome-channel.yaml @@ -68,7 +68,7 @@ Support: https://support.modrinth.com Status page: https://status.modrinth.com Roadmap: https://roadmap.modrinth.com - Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome + Blog and newsletter: https://modrinth.com/news API documentation: https://docs.modrinth.com Modrinth source code: https://github.com/modrinth Help translate Modrinth: https://crowdin.com/project/modrinth diff --git a/apps/docs/src/content/docs/contributing/labrinth.md b/apps/docs/src/content/docs/contributing/labrinth.md index 155e854cb..1be7dd3ed 100644 --- a/apps/docs/src/content/docs/contributing/labrinth.md +++ b/apps/docs/src/content/docs/contributing/labrinth.md @@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with sqlx database setup ``` -Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages. - To enable labrinth to create a project, you need to add two things. 1. An entry in the `loaders` table. @@ -85,11 +83,10 @@ During development, you might notice that changes made directly to entities in t #### CDN options -`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local` +`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local` or `s3`, but defaults to `local` -The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names: -`BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID` -`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME` +The S3 configuration options are fairly self-explanatory in name, so here's simply their names: +`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_PUBLIC_BUCKET_NAME`, `S3_PRIVATE_BUCKET_NAME`, `S3_USES_PATH_STYLE_BUCKETS` #### Search, OAuth, and miscellaneous options diff --git a/apps/frontend/.env.local b/apps/frontend/.env.local index f764f8513..29aad1d10 100644 --- a/apps/frontend/.env.local +++ b/apps/frontend/.env.local @@ -2,4 +2,3 @@ BASE_URL=http://127.0.0.1:8000/v2/ BROWSER_BASE_URL=http://127.0.0.1:8000/v2/ PYRO_BASE_URL=https://staging-archon.modrinth.com PROD_OVERRIDE=true - diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c04ddc418..7cded8314 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,7 +10,8 @@ "postinstall": "nuxi prepare", "lint": "eslint . && prettier --check .", "fix": "eslint . --fix && prettier --write .", - "intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace" + "intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", + "test": "nuxi build" }, "devDependencies": { "@formatjs/cli": "^6.2.12", @@ -37,9 +38,12 @@ "@intercom/messenger-js-sdk": "^0.0.14", "@ltd/j-toml": "^1.38.0", "@modrinth/assets": "workspace:*", + "@modrinth/blog": "workspace:*", + "@modrinth/moderation": "workspace:*", "@modrinth/ui": "workspace:*", "@modrinth/utils": "workspace:*", "@pinia/nuxt": "^0.5.1", + "@types/three": "^0.172.0", "@vintl/vintl": "^4.4.1", "@vueuse/core": "^11.1.0", "ace-builds": "^1.36.2", @@ -55,10 +59,10 @@ "markdown-it": "14.1.0", "pathe": "^1.1.2", "pinia": "^2.1.7", + "prettier": "^3.6.2", "qrcode.vue": "^3.4.0", "semver": "^7.5.4", "three": "^0.172.0", - "@types/three": "^0.172.0", "vue-multiselect": "3.0.0-alpha.2", "vue-typed-virtual-list": "^1.0.10", "vue3-ace-editor": "^2.2.4", diff --git a/apps/frontend/src/assets/styles/components.scss b/apps/frontend/src/assets/styles/components.scss index a1ea3a1e9..f647e7e25 100644 --- a/apps/frontend/src/assets/styles/components.scss +++ b/apps/frontend/src/assets/styles/components.scss @@ -197,13 +197,13 @@ } > :where( - input + *, - .input-group + *, - .textarea-wrapper + *, - .chips + *, - .resizable-textarea-wrapper + *, - .input-div + * - ) { + input + *, + .input-group + *, + .textarea-wrapper + *, + .chips + *, + .resizable-textarea-wrapper + *, + .input-div + * + ) { &:not(:empty) { margin-block-start: var(--spacing-card-md); } diff --git a/apps/frontend/src/assets/styles/global.scss b/apps/frontend/src/assets/styles/global.scss index b52b738ed..cefb9460c 100644 --- a/apps/frontend/src/assets/styles/global.scss +++ b/apps/frontend/src/assets/styles/global.scss @@ -115,10 +115,12 @@ html { --shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15); --shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2); - --shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15), + --shadow-raised: + 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15), 1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12), 4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09); - --shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, + --shadow-floating: + hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px; --shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px; @@ -150,8 +152,8 @@ html { rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.2695) 100% ); - --landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), - inset 2px 2px 64px rgba(255, 255, 255, 0.45); + --landing-blob-shadow: + 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45); --landing-card-bg: rgba(255, 255, 255, 0.8); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); @@ -251,13 +253,15 @@ html { --shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2); --shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1); - --shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, + --shadow-floating: + hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px; --shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px; --landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp"); - --landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%), + --landing-maze-gradient-bg: + linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%), url("https://cdn.modrinth.com/landing-new/landing-lower.webp"); --landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%); @@ -284,7 +288,8 @@ html { rgba(44, 48, 79, 0.35) 0%, rgba(32, 35, 50, 0.2695) 100% ); - --landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45); + --landing-blob-shadow: + 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45); --landing-card-bg: rgba(59, 63, 85, 0.15); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); @@ -360,8 +365,9 @@ body { // Defaults background-color: var(--color-bg); color: var(--color-text); - --font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, - Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + --font-standard: + Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, + Fira Sans, Droid Sans, Helvetica Neue, sans-serif; --mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; font-family: var(--font-standard); font-size: 16px; diff --git a/apps/frontend/src/components/ui/AdPlaceholder.vue b/apps/frontend/src/components/ui/AdPlaceholder.vue index 639a28b86..e2d5c9b46 100644 --- a/apps/frontend/src/components/ui/AdPlaceholder.vue +++ b/apps/frontend/src/components/ui/AdPlaceholder.vue @@ -1,15 +1,20 @@ diff --git a/apps/frontend/src/components/ui/Badge.vue b/apps/frontend/src/components/ui/Badge.vue deleted file mode 100644 index 1ebb84510..000000000 --- a/apps/frontend/src/components/ui/Badge.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/CopyCode.vue b/apps/frontend/src/components/ui/CopyCode.vue deleted file mode 100644 index 98cb14c06..000000000 --- a/apps/frontend/src/components/ui/CopyCode.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/ModerationChecklist.vue b/apps/frontend/src/components/ui/ModerationChecklist.vue index c4fabfa3b..b21fd9954 100644 --- a/apps/frontend/src/components/ui/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/ModerationChecklist.vue @@ -9,7 +9,7 @@ @@ -306,7 +306,7 @@
@@ -335,7 +335,7 @@
@@ -373,9 +373,8 @@ import { UpdatedIcon, CheckIcon, DropdownIcon, - XIcon as CrossIcon, EyeOffIcon, - ExitIcon, + XIcon, ScaleIcon, } from "@modrinth/assets"; import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui"; @@ -654,11 +653,11 @@ For a brief rundown of how this works: { name: "Insufficient", resultingMessage: `## Insufficient Gallery Images - We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations). - Keep in mind that you should: - - Set a featured image that best represents your project. - - Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description. - - Upload any relevant images in your Description to your Gallery tab for best results.`, +We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations). +Keep in mind that you should: +- Set a featured image that best represents your project. +- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description. +- Upload any relevant images in your Description to your Gallery tab for best results.`, }, { name: "Not relevant", diff --git a/apps/frontend/src/components/ui/NewsletterButton.vue b/apps/frontend/src/components/ui/NewsletterButton.vue new file mode 100644 index 000000000..61778eaa0 --- /dev/null +++ b/apps/frontend/src/components/ui/NewsletterButton.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/frontend/src/components/ui/NotificationItem.vue b/apps/frontend/src/components/ui/NotificationItem.vue index 1da769a34..4a293684a 100644 --- a/apps/frontend/src/components/ui/NotificationItem.vue +++ b/apps/frontend/src/components/ui/NotificationItem.vue @@ -104,13 +104,13 @@ by the moderators. @@ -331,16 +331,13 @@ import { XIcon, ExternalIcon, } from "@modrinth/assets"; -import { useRelativeTime } from "@modrinth/ui"; +import { Avatar, ProjectStatusBadge, CopyCode, useRelativeTime } from "@modrinth/ui"; import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue"; import { getProjectLink, getVersionLink } from "~/helpers/projects.js"; import { getUserLink } from "~/helpers/users.js"; import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js"; import { markAsRead } from "~/helpers/notifications.ts"; import DoubleIcon from "~/components/ui/DoubleIcon.vue"; -import Avatar from "~/components/ui/Avatar.vue"; -import Badge from "~/components/ui/Badge.vue"; -import CopyCode from "~/components/ui/CopyCode.vue"; import Categories from "~/components/ui/search/Categories.vue"; const app = useNuxtApp(); diff --git a/apps/frontend/src/components/ui/Notifications.vue b/apps/frontend/src/components/ui/Notifications.vue index 563cf23f4..ebb43c2e0 100644 --- a/apps/frontend/src/components/ui/Notifications.vue +++ b/apps/frontend/src/components/ui/Notifications.vue @@ -1,5 +1,11 @@ - diff --git a/apps/frontend/src/pages/legal/privacy.vue b/apps/frontend/src/pages/legal/privacy.vue index 82214716d..aa6dcd0b5 100644 --- a/apps/frontend/src/pages/legal/privacy.vue +++ b/apps/frontend/src/pages/legal/privacy.vue @@ -122,8 +122,8 @@

Creator Monetization Program data

When you sign up for our - - Creator Monetization Program + Creator Monetization Program (the "CMP"), we collect:

diff --git a/apps/frontend/src/pages/moderation/index.vue b/apps/frontend/src/pages/moderation/index.vue index 52bd5a57e..aa4ff5fa7 100644 --- a/apps/frontend/src/pages/moderation/index.vue +++ b/apps/frontend/src/pages/moderation/index.vue @@ -32,7 +32,7 @@
+ + + + diff --git a/apps/frontend/src/pages/news/changelog.vue b/apps/frontend/src/pages/news/changelog.vue index 9cfb0802b..ee4019663 100644 --- a/apps/frontend/src/pages/news/changelog.vue +++ b/apps/frontend/src/pages/news/changelog.vue @@ -6,6 +6,21 @@
+ + diff --git a/apps/frontend/src/pages/organization/[id].vue b/apps/frontend/src/pages/organization/[id].vue index 6dfdc57d5..901300161 100644 --- a/apps/frontend/src/pages/organization/[id].vue +++ b/apps/frontend/src/pages/organization/[id].vue @@ -98,7 +98,10 @@ {{ formatCompactNumber(projects?.length || 0) }} projects -
+
{{ formatCompactNumber(sumDownloads) }} downloads @@ -146,9 +149,7 @@
- +

Members

@@ -284,14 +285,13 @@ import NavTabs from "~/components/ui/NavTabs.vue"; const vintl = useVIntl(); const { formatMessage } = vintl; -const formatCompactNumber = useCompactNumber(); +const formatCompactNumber = useCompactNumber(true); const auth = await useAuth(); const user = await useUser(); const cosmetics = useCosmetics(); const route = useNativeRoute(); const tags = useTags(); -const flags = useFeatureFlags(); const config = useRuntimeConfig(); let orgId = useRouteId(); diff --git a/apps/frontend/src/pages/organization/[id]/settings/projects.vue b/apps/frontend/src/pages/organization/[id]/settings/projects.vue index 85ce5b9dc..86226cc29 100644 --- a/apps/frontend/src/pages/organization/[id]/settings/projects.vue +++ b/apps/frontend/src/pages/organization/[id]/settings/projects.vue @@ -201,8 +201,8 @@ icon-only @click="updateDescending()" > - - + +
@@ -272,7 +272,7 @@
{{ - $formatProjectType( + formatProjectType( $getProjectTypeForDisplay(project.project_types[0] ?? "project", project.loaders), ) }} @@ -308,11 +308,12 @@ import { XIcon, EditIcon, SaveIcon, - SortAscendingIcon, - SortDescendingIcon, + SortAscIcon, + SortDescIcon, } from "@modrinth/assets"; import { Button, Modal, Avatar, CopyCode, Badge, Checkbox, commonMessages } from "@modrinth/ui"; +import { formatProjectType } from "@modrinth/utils"; import ModalCreation from "~/components/ui/ModalCreation.vue"; import OrganizationProjectTransferModal from "~/components/ui/OrganizationProjectTransferModal.vue"; diff --git a/apps/frontend/src/pages/plus.vue b/apps/frontend/src/pages/plus.vue index 42fbcb652..864af2656 100644 --- a/apps/frontend/src/pages/plus.vue +++ b/apps/frontend/src/pages/plus.vue @@ -73,7 +73,7 @@ Remove all ads - Never see an advertisement again on the Modrinth app or the website. + Never see an advertisement again on the Modrinth app.
@@ -82,7 +82,7 @@ Get an exclusive badge on your user page.
- ...and much more coming soon! + ...and much more coming soon™! - - -
- - - - - diff --git a/apps/frontend/src/public/promo-frame.html b/apps/frontend/src/public/promo-frame.html index 2dc4fc32f..c8cce954c 100644 --- a/apps/frontend/src/public/promo-frame.html +++ b/apps/frontend/src/public/promo-frame.html @@ -95,15 +95,6 @@ }); document.addEventListener("contextmenu", (event) => event.preventDefault()); - - const plusLink = document.getElementById("plus-link"); - plusLink.addEventListener("click", function () { - window.__TAURI_INTERNALS__.invoke("plugin:ads|record_ads_click", {}); - window.__TAURI_INTERNALS__.invoke("plugin:ads|open_link", { - path: "https://modrinth.com/plus", - origin: "https://modrinth.com", - }); - }); diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 8675bcd69..09b6e719d 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -3,7 +3,8 @@ RUST_LOG=info,sqlx::query=warn SENTRY_DSN=none SITE_URL=http://localhost:3000 -CDN_URL=https://staging-cdn.modrinth.com +# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH +CDN_URL=file:///tmp/modrinth LABRINTH_ADMIN_KEY=feedbeef RATE_LIMIT_IGNORE_KEY=feedbeef @@ -25,18 +26,21 @@ PUBLIC_DISCORD_WEBHOOK= CLOUDFLARE_INTEGRATION=false STORAGE_BACKEND=local - MOCK_FILE_PATH=/tmp/modrinth -BACKBLAZE_KEY_ID=none -BACKBLAZE_KEY=none -BACKBLAZE_BUCKET_ID=none +S3_PUBLIC_BUCKET_NAME=none +S3_PUBLIC_USES_PATH_STYLE_BUCKET=false +S3_PUBLIC_REGION=none +S3_PUBLIC_URL=none +S3_PUBLIC_ACCESS_TOKEN=none +S3_PUBLIC_SECRET=none -S3_ACCESS_TOKEN=none -S3_SECRET=none -S3_URL=none -S3_REGION=none -S3_BUCKET_NAME=none +S3_PRIVATE_BUCKET_NAME=none +S3_PRIVATE_USES_PATH_STYLE_BUCKET=false +S3_PRIVATE_REGION=none +S3_PRIVATE_URL=none +S3_PRIVATE_ACCESS_TOKEN=none +S3_PRIVATE_SECRET=none # 1 hour LOCAL_INDEX_INTERVAL=3600 @@ -81,6 +85,8 @@ TREMENDOUS_CAMPAIGN_ID=none HCAPTCHA_SECRET=none +SMTP_FROM_NAME=Modrinth +SMTP_FROM_ADDRESS=no-reply@mail.modrinth.com SMTP_USERNAME=none SMTP_PASSWORD=none SMTP_HOST=none diff --git a/apps/labrinth/.sqlx/query-010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800.json b/apps/labrinth/.sqlx/query-010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800.json deleted file mode 100644 index 1336c5438..000000000 --- a/apps/labrinth/.sqlx/query-010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800" -} diff --git a/apps/labrinth/.sqlx/query-09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d.json b/apps/labrinth/.sqlx/query-09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d.json new file mode 100644 index 000000000..03bf9d951 --- /dev/null +++ b/apps/labrinth/.sqlx/query-09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_instance_users (user_id, shared_instance_id, permissions)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d" +} diff --git a/apps/labrinth/.sqlx/query-124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2.json b/apps/labrinth/.sqlx/query-124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2.json deleted file mode 100644 index ecf7f3118..000000000 --- a/apps/labrinth/.sqlx/query-124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET webhook_sent = TRUE\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2" -} diff --git a/apps/labrinth/.sqlx/query-186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b.json b/apps/labrinth/.sqlx/query-186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b.json deleted file mode 100644 index e31392b44..000000000 --- a/apps/labrinth/.sqlx/query-186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM mods_links\n WHERE joining_mod_id = $1 AND joining_platform_id IN (\n SELECT id FROM link_platforms WHERE name = ANY($2)\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "TextArray" - ] - }, - "nullable": [] - }, - "hash": "186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b" -} diff --git a/apps/labrinth/.sqlx/query-19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da.json b/apps/labrinth/.sqlx/query-19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da.json deleted file mode 100644 index 9254267e3..000000000 --- a/apps/labrinth/.sqlx/query-19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET license = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da" -} diff --git a/apps/labrinth/.sqlx/query-1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e.json b/apps/labrinth/.sqlx/query-1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e.json new file mode 100644 index 000000000..c584ce964 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, title, owner_id, public, current_version_id\n FROM shared_instances\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "owner_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "public", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "current_version_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e" +} diff --git a/apps/labrinth/.sqlx/query-265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0.json b/apps/labrinth/.sqlx/query-265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0.json new file mode 100644 index 000000000..7902fc25e --- /dev/null +++ b/apps/labrinth/.sqlx/query-265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, shared_instance_id, size, sha512, created\n FROM shared_instance_versions\n WHERE shared_instance_id = $1\n ORDER BY created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "shared_instance_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "size", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "sha512", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0" +} diff --git a/apps/labrinth/.sqlx/query-299b8ea6e7a0048fa389cc4432715dc2a09e227d2f08e91167a43372a7ac6e35.json b/apps/labrinth/.sqlx/query-299b8ea6e7a0048fa389cc4432715dc2a09e227d2f08e91167a43372a7ac6e35.json new file mode 100644 index 000000000..eecc67513 --- /dev/null +++ b/apps/labrinth/.sqlx/query-299b8ea6e7a0048fa389cc4432715dc2a09e227d2f08e91167a43372a7ac6e35.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = FALSE\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "299b8ea6e7a0048fa389cc4432715dc2a09e227d2f08e91167a43372a7ac6e35" +} diff --git a/apps/labrinth/.sqlx/query-374c234b92b838b0bd65de100a9008b0fb78c79976fd858e0599e1ccb7f08b82.json b/apps/labrinth/.sqlx/query-374c234b92b838b0bd65de100a9008b0fb78c79976fd858e0599e1ccb7f08b82.json new file mode 100644 index 000000000..4e906d600 --- /dev/null +++ b/apps/labrinth/.sqlx/query-374c234b92b838b0bd65de100a9008b0fb78c79976fd858e0599e1ccb7f08b82.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET side_types_migration_review_status = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "374c234b92b838b0bd65de100a9008b0fb78c79976fd858e0599e1ccb7f08b82" +} diff --git a/apps/labrinth/.sqlx/query-3fcfed18cbfb37866e0fa57a4e95efb326864f8219941d1b696add39ed333ad1.json b/apps/labrinth/.sqlx/query-3fcfed18cbfb37866e0fa57a4e95efb326864f8219941d1b696add39ed333ad1.json new file mode 100644 index 000000000..73e76e5ed --- /dev/null +++ b/apps/labrinth/.sqlx/query-3fcfed18cbfb37866e0fa57a4e95efb326864f8219941d1b696add39ed333ad1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = TRUE\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "3fcfed18cbfb37866e0fa57a4e95efb326864f8219941d1b696add39ed333ad1" +} diff --git a/apps/labrinth/.sqlx/query-40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08.json b/apps/labrinth/.sqlx/query-40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08.json deleted file mode 100644 index 4190c1eb4..000000000 --- a/apps/labrinth/.sqlx/query-40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = TRUE\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08" -} diff --git a/apps/labrinth/.sqlx/query-47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb.json b/apps/labrinth/.sqlx/query-47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb.json new file mode 100644 index 000000000..24e1d953b --- /dev/null +++ b/apps/labrinth/.sqlx/query-47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_instance_versions\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb" +} diff --git a/apps/labrinth/.sqlx/query-47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55.json b/apps/labrinth/.sqlx/query-47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55.json new file mode 100644 index 000000000..2d0db8068 --- /dev/null +++ b/apps/labrinth/.sqlx/query-47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_instances SET current_version_id = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55" +} diff --git a/apps/labrinth/.sqlx/query-4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955.json b/apps/labrinth/.sqlx/query-4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955.json deleted file mode 100644 index be21454a5..000000000 --- a/apps/labrinth/.sqlx/query-4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET description = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955" -} diff --git a/apps/labrinth/.sqlx/query-4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202.json b/apps/labrinth/.sqlx/query-4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202.json deleted file mode 100644 index 279dbcf75..000000000 --- a/apps/labrinth/.sqlx/query-4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET slug = LOWER($1)\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202" -} diff --git a/apps/labrinth/.sqlx/query-595f4e7432d5b41002988c6cc6b0b1f09273ad02c319e6631c74d80a9b278328.json b/apps/labrinth/.sqlx/query-595f4e7432d5b41002988c6cc6b0b1f09273ad02c319e6631c74d80a9b278328.json new file mode 100644 index 000000000..5474a7c6b --- /dev/null +++ b/apps/labrinth/.sqlx/query-595f4e7432d5b41002988c6cc6b0b1f09273ad02c319e6631c74d80a9b278328.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET summary = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "595f4e7432d5b41002988c6cc6b0b1f09273ad02c319e6631c74d80a9b278328" +} diff --git a/apps/labrinth/.sqlx/query-6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356.json b/apps/labrinth/.sqlx/query-6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356.json deleted file mode 100644 index b262237e9..000000000 --- a/apps/labrinth/.sqlx/query-6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO mods_links (joining_mod_id, joining_platform_id, url)\n VALUES ($1, $2, $3)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int4", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356" -} diff --git a/apps/labrinth/.sqlx/query-652a5765bda0b78034a291e382a063126529d91f308cbafed2c6e635a3013b30.json b/apps/labrinth/.sqlx/query-652a5765bda0b78034a291e382a063126529d91f308cbafed2c6e635a3013b30.json new file mode 100644 index 000000000..f855ed9cf --- /dev/null +++ b/apps/labrinth/.sqlx/query-652a5765bda0b78034a291e382a063126529d91f308cbafed2c6e635a3013b30.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT created, SUM(amount) sum\n FROM payouts_values\n WHERE created BETWEEN $1 AND $2\n GROUP BY created\n ORDER BY created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "sum", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "652a5765bda0b78034a291e382a063126529d91f308cbafed2c6e635a3013b30" +} diff --git a/apps/labrinth/.sqlx/query-66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2.json b/apps/labrinth/.sqlx/query-66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2.json deleted file mode 100644 index f2e83cf92..000000000 --- a/apps/labrinth/.sqlx/query-66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET requested_status = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2" -} diff --git a/apps/labrinth/.sqlx/query-6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599.json b/apps/labrinth/.sqlx/query-6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599.json new file mode 100644 index 000000000..910adbc36 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET public = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599" +} diff --git a/apps/labrinth/.sqlx/query-6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json b/apps/labrinth/.sqlx/query-6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json new file mode 100644 index 000000000..35b064008 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_instances (id, title, owner_id, current_version_id)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1" +} diff --git a/apps/labrinth/.sqlx/query-70be97b02e402de0490ade5866c47232f9c341add2f3838cc3ae1a07a310d561.json b/apps/labrinth/.sqlx/query-70be97b02e402de0490ade5866c47232f9c341add2f3838cc3ae1a07a310d561.json new file mode 100644 index 000000000..c75b97c5d --- /dev/null +++ b/apps/labrinth/.sqlx/query-70be97b02e402de0490ade5866c47232f9c341add2f3838cc3ae1a07a310d561.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET name = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "70be97b02e402de0490ade5866c47232f9c341add2f3838cc3ae1a07a310d561" +} diff --git a/apps/labrinth/.sqlx/query-72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741.json b/apps/labrinth/.sqlx/query-72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741.json new file mode 100644 index 000000000..e0787e3bb --- /dev/null +++ b/apps/labrinth/.sqlx/query-72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET owner_id = $1\n WHERE owner_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741" +} diff --git a/apps/labrinth/.sqlx/query-79040825457845cc078be7b3293804d6fb2e05ffce07e7b4248d8705d6fc6e61.json b/apps/labrinth/.sqlx/query-79040825457845cc078be7b3293804d6fb2e05ffce07e7b4248d8705d6fc6e61.json new file mode 100644 index 000000000..5c989cc2a --- /dev/null +++ b/apps/labrinth/.sqlx/query-79040825457845cc078be7b3293804d6fb2e05ffce07e7b4248d8705d6fc6e61.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET moderation_message_body = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "79040825457845cc078be7b3293804d6fb2e05ffce07e7b4248d8705d6fc6e61" +} diff --git a/apps/labrinth/.sqlx/query-7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478.json b/apps/labrinth/.sqlx/query-7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478.json deleted file mode 100644 index 6996aa840..000000000 --- a/apps/labrinth/.sqlx/query-7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET monetization_status = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478" -} diff --git a/apps/labrinth/.sqlx/query-5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763.json b/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json similarity index 79% rename from apps/labrinth/.sqlx/query-5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763.json rename to apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json index 53b085320..f7cb84084 100644 --- a/apps/labrinth/.sqlx/query-5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763.json +++ b/apps/labrinth/.sqlx/query-7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ", + "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ", "describe": { "columns": [ { @@ -125,11 +125,16 @@ }, { "ordinal": 24, + "name": "side_types_migration_review_status", + "type_info": "Varchar" + }, + { + "ordinal": 25, "name": "categories", "type_info": "VarcharArray" }, { - "ordinal": 25, + "ordinal": 26, "name": "additional_categories", "type_info": "VarcharArray" } @@ -165,9 +170,10 @@ true, false, false, + false, null, null ] }, - "hash": "5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763" + "hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62" } diff --git a/apps/labrinth/.sqlx/query-f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329.json b/apps/labrinth/.sqlx/query-7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b.json similarity index 58% rename from apps/labrinth/.sqlx/query-f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329.json rename to apps/labrinth/.sqlx/query-7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b.json index ec3004f9c..7897b45d3 100644 --- a/apps/labrinth/.sqlx/query-f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329.json +++ b/apps/labrinth/.sqlx/query-7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)\n ", + "query": "SELECT EXISTS(SELECT 1 FROM shared_instance_versions WHERE id=$1)", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329" + "hash": "7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b" } diff --git a/apps/labrinth/.sqlx/query-7e403d399ddd3279c4c65db7b9ea850cdd9fef3df1b3f7d5f62e079b4522f2ca.json b/apps/labrinth/.sqlx/query-7e403d399ddd3279c4c65db7b9ea850cdd9fef3df1b3f7d5f62e079b4522f2ca.json new file mode 100644 index 000000000..8226261f5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7e403d399ddd3279c4c65db7b9ea850cdd9fef3df1b3f7d5f62e079b4522f2ca.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET slug = LOWER($1)\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7e403d399ddd3279c4c65db7b9ea850cdd9fef3df1b3f7d5f62e079b4522f2ca" +} diff --git a/apps/labrinth/.sqlx/query-879bf4cc2e305c7e3e2456e2e0a0d910865d622f3add8ad6d99e9cddcc1d2f1a.json b/apps/labrinth/.sqlx/query-879bf4cc2e305c7e3e2456e2e0a0d910865d622f3add8ad6d99e9cddcc1d2f1a.json new file mode 100644 index 000000000..559deee40 --- /dev/null +++ b/apps/labrinth/.sqlx/query-879bf4cc2e305c7e3e2456e2e0a0d910865d622f3add8ad6d99e9cddcc1d2f1a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT SUM(amount) from payouts_values WHERE date_available <= NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sum", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "879bf4cc2e305c7e3e2456e2e0a0d910865d622f3add8ad6d99e9cddcc1d2f1a" +} diff --git a/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json b/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json new file mode 100644 index 000000000..d627ef2dc --- /dev/null +++ b/apps/labrinth/.sqlx/query-92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e" +} diff --git a/apps/labrinth/.sqlx/query-9482a3419337911ac6a10eeaf065e29589ee1b707729344e81d183c713aa0d28.json b/apps/labrinth/.sqlx/query-9482a3419337911ac6a10eeaf065e29589ee1b707729344e81d183c713aa0d28.json new file mode 100644 index 000000000..babe8ad64 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9482a3419337911ac6a10eeaf065e29589ee1b707729344e81d183c713aa0d28.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9482a3419337911ac6a10eeaf065e29589ee1b707729344e81d183c713aa0d28" +} diff --git a/apps/labrinth/.sqlx/query-9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550.json b/apps/labrinth/.sqlx/query-9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550.json new file mode 100644 index 000000000..92c3b45f8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n -- See https://github.com/launchbadge/sqlx/issues/1266 for why we need all the \"as\"\n SELECT\n id as \"id!\",\n title as \"title!\",\n public as \"public!\",\n owner_id as \"owner_id!\",\n current_version_id\n FROM shared_instances\n WHERE owner_id = $1\n UNION\n SELECT\n id as \"id!\",\n title as \"title!\",\n public as \"public!\",\n owner_id as \"owner_id!\",\n current_version_id\n FROM shared_instances\n JOIN shared_instance_users ON id = shared_instance_id\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title!", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "public!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "owner_id!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "current_version_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550" +} diff --git a/apps/labrinth/.sqlx/query-9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f.json b/apps/labrinth/.sqlx/query-9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f.json new file mode 100644 index 000000000..1a5097375 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET title = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f" +} diff --git a/apps/labrinth/.sqlx/query-a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af.json b/apps/labrinth/.sqlx/query-a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af.json deleted file mode 100644 index a8ec2492d..000000000 --- a/apps/labrinth/.sqlx/query-a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = FALSE\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af" -} diff --git a/apps/labrinth/.sqlx/query-a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f.json b/apps/labrinth/.sqlx/query-a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f.json deleted file mode 100644 index 4b97bd691..000000000 --- a/apps/labrinth/.sqlx/query-a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f" -} diff --git a/apps/labrinth/.sqlx/query-a5ae1fe0ca4ca8432736398fed25687173b2fbde3405340a5579c5ef68cb5218.json b/apps/labrinth/.sqlx/query-a5ae1fe0ca4ca8432736398fed25687173b2fbde3405340a5579c5ef68cb5218.json new file mode 100644 index 000000000..799ce4489 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a5ae1fe0ca4ca8432736398fed25687173b2fbde3405340a5579c5ef68cb5218.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET license = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a5ae1fe0ca4ca8432736398fed25687173b2fbde3405340a5579c5ef68cb5218" +} diff --git a/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json b/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json new file mode 100644 index 000000000..73d8a8eec --- /dev/null +++ b/apps/labrinth/.sqlx/query-a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad" +} diff --git a/apps/labrinth/.sqlx/query-aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798.json b/apps/labrinth/.sqlx/query-aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798.json new file mode 100644 index 000000000..2c517f367 --- /dev/null +++ b/apps/labrinth/.sqlx/query-aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT shared_instance_id, user_id, permissions\n FROM shared_instance_users\n WHERE shared_instance_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "shared_instance_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "permissions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798" +} diff --git a/apps/labrinth/.sqlx/query-b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc.json b/apps/labrinth/.sqlx/query-b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc.json deleted file mode 100644 index a66547009..000000000 --- a/apps/labrinth/.sqlx/query-b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET summary = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc" -} diff --git a/apps/labrinth/.sqlx/query-b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731.json b/apps/labrinth/.sqlx/query-b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731.json new file mode 100644 index 000000000..a99713026 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, shared_instance_id, size, sha512, created\n FROM shared_instance_versions\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "shared_instance_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "size", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "sha512", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731" +} diff --git a/apps/labrinth/.sqlx/query-c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9.json b/apps/labrinth/.sqlx/query-c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9.json deleted file mode 100644 index fff83ae5a..000000000 --- a/apps/labrinth/.sqlx/query-c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9" -} diff --git a/apps/labrinth/.sqlx/query-c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea.json b/apps/labrinth/.sqlx/query-c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea.json new file mode 100644 index 000000000..b710ec2d4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT permissions\n FROM shared_instance_users\n WHERE shared_instance_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea" +} diff --git a/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json b/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json new file mode 100644 index 000000000..dc7aa810b --- /dev/null +++ b/apps/labrinth/.sqlx/query-c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b" +} diff --git a/apps/labrinth/.sqlx/query-9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f.json b/apps/labrinth/.sqlx/query-cec98010827455127da68a2bc5cd3c1ee3bfd357a6a8604febad3ed214a9b77b.json similarity index 55% rename from apps/labrinth/.sqlx/query-9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f.json rename to apps/labrinth/.sqlx/query-cec98010827455127da68a2bc5cd3c1ee3bfd357a6a8604febad3ed214a9b77b.json index 5152ba222..5a26ce08b 100644 --- a/apps/labrinth/.sqlx/query-9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f.json +++ b/apps/labrinth/.sqlx/query-cec98010827455127da68a2bc5cd3c1ee3bfd357a6a8604febad3ed214a9b77b.json @@ -1,15 +1,14 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET name = $1\n WHERE (id = $2)\n ", + "query": "\n UPDATE mods\n SET webhook_sent = TRUE\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { "Left": [ - "Varchar", "Int8" ] }, "nullable": [] }, - "hash": "9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f" + "hash": "cec98010827455127da68a2bc5cd3c1ee3bfd357a6a8604febad3ed214a9b77b" } diff --git a/apps/labrinth/.sqlx/query-cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9.json b/apps/labrinth/.sqlx/query-cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9.json new file mode 100644 index 000000000..d48180d91 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_instances\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9" +} diff --git a/apps/labrinth/.sqlx/query-d010207297e1c4f2ebfb0a81caf45481c94edb1e8d8ac47db13ec0ff9b2f5328.json b/apps/labrinth/.sqlx/query-d010207297e1c4f2ebfb0a81caf45481c94edb1e8d8ac47db13ec0ff9b2f5328.json new file mode 100644 index 000000000..507b49c6c --- /dev/null +++ b/apps/labrinth/.sqlx/query-d010207297e1c4f2ebfb0a81caf45481c94edb1e8d8ac47db13ec0ff9b2f5328.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET moderation_message = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d010207297e1c4f2ebfb0a81caf45481c94edb1e8d8ac47db13ec0ff9b2f5328" +} diff --git a/apps/labrinth/.sqlx/query-d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b.json b/apps/labrinth/.sqlx/query-d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b.json deleted file mode 100644 index 9df5df701..000000000 --- a/apps/labrinth/.sqlx/query-d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET moderation_message = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b" -} diff --git a/apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json b/apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json new file mode 100644 index 000000000..3d9785e4d --- /dev/null +++ b/apps/labrinth/.sqlx/query-d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b" +} diff --git a/apps/labrinth/.sqlx/query-abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1.json b/apps/labrinth/.sqlx/query-d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4.json similarity index 54% rename from apps/labrinth/.sqlx/query-abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1.json rename to apps/labrinth/.sqlx/query-d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4.json index 20e672605..81eef9932 100644 --- a/apps/labrinth/.sqlx/query-abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1.json +++ b/apps/labrinth/.sqlx/query-d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))\n ", + "query": "SELECT EXISTS(SELECT 1 FROM shared_instances WHERE id=$1)", "describe": { "columns": [ { @@ -11,12 +11,12 @@ ], "parameters": { "Left": [ - "Text" + "Int8" ] }, "nullable": [ null ] }, - "hash": "abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1" + "hash": "d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4" } diff --git a/apps/labrinth/.sqlx/query-d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e.json b/apps/labrinth/.sqlx/query-d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e.json new file mode 100644 index 000000000..0e0bb268f --- /dev/null +++ b/apps/labrinth/.sqlx/query-d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_instance_versions (id, shared_instance_id, size, sha512, created)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e" +} diff --git a/apps/labrinth/.sqlx/query-dbdf1cce30709c3e1066d0a9156e12ce9e4773e3678da6f10f459a26bd0f3931.json b/apps/labrinth/.sqlx/query-dbdf1cce30709c3e1066d0a9156e12ce9e4773e3678da6f10f459a26bd0f3931.json new file mode 100644 index 000000000..d449d3551 --- /dev/null +++ b/apps/labrinth/.sqlx/query-dbdf1cce30709c3e1066d0a9156e12ce9e4773e3678da6f10f459a26bd0f3931.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payout_sources_balance (account_type, amount, pending, recorded)\n SELECT * FROM UNNEST ($1::text[], $2::numeric[], $3::boolean[], $4::timestamptz[])\n ON CONFLICT (recorded, account_type, pending)\n DO UPDATE SET amount = EXCLUDED.amount\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "NumericArray", + "BoolArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "dbdf1cce30709c3e1066d0a9156e12ce9e4773e3678da6f10f459a26bd0f3931" +} diff --git a/apps/labrinth/.sqlx/query-e42e63db3ae4d1d745508b80651494da8738873b98aa608792af19e60b9fb998.json b/apps/labrinth/.sqlx/query-e42e63db3ae4d1d745508b80651494da8738873b98aa608792af19e60b9fb998.json new file mode 100644 index 000000000..e42fc661e --- /dev/null +++ b/apps/labrinth/.sqlx/query-e42e63db3ae4d1d745508b80651494da8738873b98aa608792af19e60b9fb998.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET requested_status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e42e63db3ae4d1d745508b80651494da8738873b98aa608792af19e60b9fb998" +} diff --git a/apps/labrinth/.sqlx/query-e7654740161726b2aef4f7c9a26eb00efcac9f6285a39d8df06d606613684ba3.json b/apps/labrinth/.sqlx/query-e7654740161726b2aef4f7c9a26eb00efcac9f6285a39d8df06d606613684ba3.json new file mode 100644 index 000000000..ef8f7b22c --- /dev/null +++ b/apps/labrinth/.sqlx/query-e7654740161726b2aef4f7c9a26eb00efcac9f6285a39d8df06d606613684ba3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET description = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e7654740161726b2aef4f7c9a26eb00efcac9f6285a39d8df06d606613684ba3" +} diff --git a/apps/labrinth/.sqlx/query-e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585.json b/apps/labrinth/.sqlx/query-e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585.json deleted file mode 100644 index 52bc085af..000000000 --- a/apps/labrinth/.sqlx/query-e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585" -} diff --git a/apps/labrinth/.sqlx/query-ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14.json b/apps/labrinth/.sqlx/query-ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14.json deleted file mode 100644 index dcdaa6f99..000000000 --- a/apps/labrinth/.sqlx/query-ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14" -} diff --git a/apps/labrinth/.sqlx/query-ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4.json b/apps/labrinth/.sqlx/query-ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4.json deleted file mode 100644 index 4bcf71fd8..000000000 --- a/apps/labrinth/.sqlx/query-ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE mods\n SET moderation_message_body = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4" -} diff --git a/apps/labrinth/.sqlx/query-bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38.json b/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json similarity index 60% rename from apps/labrinth/.sqlx/query-bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38.json rename to apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json index 1de75299c..af9bf42e5 100644 --- a/apps/labrinth/.sqlx/query-bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38.json +++ b/apps/labrinth/.sqlx/query-ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17\n )\n ", + "query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ", "describe": { "columns": [], "parameters": { @@ -21,10 +21,11 @@ "Text", "Int4", "Varchar", - "Int8" + "Int8", + "Varchar" ] }, "nullable": [] }, - "hash": "bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38" + "hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702" } diff --git a/apps/labrinth/.sqlx/query-f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6.json b/apps/labrinth/.sqlx/query-f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6.json new file mode 100644 index 000000000..c8514e0a3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET current_version_id = (\n SELECT id FROM shared_instance_versions\n WHERE shared_instance_id = $1\n ORDER BY created DESC\n LIMIT 1\n )\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6" +} diff --git a/apps/labrinth/.sqlx/query-fa874e2c55995feaa5e0d3cd54db82b88af15477d616d0d3b3c6967b31d967f7.json b/apps/labrinth/.sqlx/query-fa874e2c55995feaa5e0d3cd54db82b88af15477d616d0d3b3c6967b31d967f7.json new file mode 100644 index 000000000..24a5dbb41 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fa874e2c55995feaa5e0d3cd54db82b88af15477d616d0d3b3c6967b31d967f7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET monetization_status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "fa874e2c55995feaa5e0d3cd54db82b88af15477d616d0d3b3c6967b31d967f7" +} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 53f30a27c..b41b0fe96 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -2,7 +2,7 @@ name = "labrinth" version = "2.7.0" authors = ["geometrically "] -edition = "2024" +edition.workspace = true license = "AGPL-3.0" # This seems redundant, but it's necessary for Docker to work @@ -36,7 +36,7 @@ paste.workspace = true meilisearch-sdk = { workspace = true, features = ["reqwest"] } rust-s3.workspace = true reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] } -hyper-tls.workspace = true +hyper-rustls.workspace = true hyper-util.workspace = true serde = { workspace = true, features = ["derive"] } @@ -131,3 +131,6 @@ actix-http.workspace = true dotenv-build.workspace = true chrono.workspace = true iana-time-zone.workspace = true + +[lints] +workspace = true diff --git a/apps/labrinth/Dockerfile b/apps/labrinth/Dockerfile index 8ed142c92..f0677efd5 100644 --- a/apps/labrinth/Dockerfile +++ b/apps/labrinth/Dockerfile @@ -1,12 +1,8 @@ -FROM rust:1.86.0 AS build -ENV PKG_CONFIG_ALLOW_CROSS=1 +FROM rust:1.88.0 AS build WORKDIR /usr/src/labrinth COPY . . -ENV SQLX_OFFLINE=true -COPY apps/labrinth/.sqlx/ .sqlx/ -RUN cargo build --release --package labrinth - +RUN SQLX_OFFLINE=true cargo build --release --package labrinth FROM debian:bookworm-slim @@ -15,12 +11,9 @@ LABEL org.opencontainers.image.description="Modrinth API" LABEL org.opencontainers.image.licenses=AGPL-3.0 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates openssl dumb-init \ - && apt-get clean \ + && apt-get install -y --no-install-recommends ca-certificates dumb-init curl \ && rm -rf /var/lib/apt/lists/* -RUN update-ca-certificates - COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/ COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets diff --git a/apps/labrinth/migrations/20250519184051_shared-instances.sql b/apps/labrinth/migrations/20250519184051_shared-instances.sql new file mode 100644 index 000000000..8f785403c --- /dev/null +++ b/apps/labrinth/migrations/20250519184051_shared-instances.sql @@ -0,0 +1,43 @@ +CREATE TABLE shared_instances ( + id BIGINT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + owner_id BIGINT NOT NULL REFERENCES users, + current_version_id BIGINT NULL, + public BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX shared_instances_owner_id ON shared_instances(owner_id); + +CREATE TABLE shared_instance_users ( + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + permissions BIGINT NOT NULL DEFAULT 0, + + PRIMARY KEY (user_id, shared_instance_id) +); + +CREATE TABLE shared_instance_invited_users ( + id BIGINT PRIMARY KEY, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + invited_user_id BIGINT NULL REFERENCES users ON DELETE CASCADE +); +CREATE INDEX shared_instance_invited_users_shared_instance_id ON shared_instance_invited_users(shared_instance_id); +CREATE INDEX shared_instance_invited_users_invited_user_id ON shared_instance_invited_users(invited_user_id); + +CREATE TABLE shared_instance_invite_links ( + id BIGINT PRIMARY KEY, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + expiration timestamptz NULL, + remaining_uses BIGINT CHECK ( remaining_uses >= 0 ) NULL +); +CREATE INDEX shared_instance_invite_links_shared_instance_id ON shared_instance_invite_links(shared_instance_id); + +CREATE TABLE shared_instance_versions ( + id BIGINT PRIMARY KEY, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + size BIGINT NOT NULL, + sha512 bytea NOT NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE shared_instances +ADD FOREIGN KEY (current_version_id) REFERENCES shared_instance_versions(id) ON DELETE SET NULL; diff --git a/apps/labrinth/migrations/20250523174544_project-versions-environments.sql b/apps/labrinth/migrations/20250523174544_project-versions-environments.sql new file mode 100644 index 000000000..b11c88c2a --- /dev/null +++ b/apps/labrinth/migrations/20250523174544_project-versions-environments.sql @@ -0,0 +1,124 @@ +DO LANGUAGE plpgsql $$ +DECLARE + VAR_env_field_id INT; + VAR_env_field_enum_id INT := 4; -- Known available ID for a new enum type +BEGIN + +-- Define a new loader field for environment +INSERT INTO loader_field_enums (id, enum_name, ordering, hidable) + VALUES (VAR_env_field_enum_id, 'environment', NULL, TRUE); + +INSERT INTO loader_field_enum_values (enum_id, value, ordering, created, metadata) + VALUES + -- Must be installed on both client and (integrated) server + (VAR_env_field_enum_id, 'client_and_server', NULL, NOW(), NULL), + -- Must be installed only on the client + (VAR_env_field_enum_id, 'client_only', NULL, NOW(), NULL), + -- Must be installed on the client, may be installed on a (integrated) server. To be displayed as a + -- client mod + (VAR_env_field_enum_id, 'client_only_server_optional', NULL, NOW(), NULL), + -- Must be installed only on the integrated singleplayer server. To be displayed as a server mod for + -- singleplayer exclusively + (VAR_env_field_enum_id, 'singleplayer_only', NULL, NOW(), NULL), + -- Must be installed only on a (integrated) server + (VAR_env_field_enum_id, 'server_only', NULL, NOW(), NULL), + -- Must be installed on the server, may be installed on the client. To be displayed as a + -- singleplayer-compatible server mod + (VAR_env_field_enum_id, 'server_only_client_optional', NULL, NOW(), NULL), + -- Must be installed only on a dedicated multiplayer server (not the integrated singleplayer server). + -- To be displayed as an server mod for multiplayer exclusively + (VAR_env_field_enum_id, 'dedicated_server_only', NULL, NOW(), NULL), + -- Can be installed on both client and server, with no strong preference for either. To be displayed + -- as both a client and server mod + (VAR_env_field_enum_id, 'client_or_server', NULL, NOW(), NULL), + -- Can be installed on both client and server, with a preference for being installed on both. To be + -- displayed as a client and server mod + (VAR_env_field_enum_id, 'client_or_server_prefers_both', NULL, NOW(), NULL), + (VAR_env_field_enum_id, 'unknown', NULL, NOW(), NULL); + +INSERT INTO loader_fields (field, field_type, enum_type, optional) + VALUES ('environment', 'enum', VAR_env_field_enum_id, FALSE) + RETURNING id INTO VAR_env_field_id; + +-- Update version_fields to have the new environment field, initializing it from the +-- values of the previous fields +INSERT INTO version_fields (version_id, field_id, enum_value) + SELECT vf.version_id, VAR_env_field_id, ( + SELECT id + FROM loader_field_enum_values + WHERE enum_id = VAR_env_field_enum_id + AND value = ( + CASE jsonb_object_agg(lf.field, vf.int_value) + WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_only' + WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'client_and_server' + WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_only_server_optional' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'singleplayer_only' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_only' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'client_and_server' + WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_only_server_optional' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'server_only' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_or_server' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'server_only_client_optional' + WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_or_server_prefers_both' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'server_only' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_or_server' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'server_only_client_optional' + WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_or_server_prefers_both' + ELSE 'unknown' + END + ) + ) + FROM version_fields vf + JOIN loader_fields lf ON vf.field_id = lf.id + WHERE lf.field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only') + GROUP BY vf.version_id + HAVING COUNT(DISTINCT lf.field) = 4; + +-- Clean up old fields from the project versions +DELETE FROM version_fields + WHERE field_id IN ( + SELECT id + FROM loader_fields + WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only') + ); + +-- Switch loader fields definitions on the available loaders to use the new environment field +ALTER TABLE loader_fields_loaders DROP CONSTRAINT unique_loader_field; +ALTER TABLE loader_fields_loaders DROP CONSTRAINT loader_fields_loaders_pkey; +ALTER TABLE loader_fields_loaders REPLICA IDENTITY FULL; -- Required due to temporary PK removal for replica sync in production + +UPDATE loader_fields_loaders + SET loader_field_id = VAR_env_field_id + WHERE loader_field_id IN ( + SELECT id + FROM loader_fields + WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only') + ); + +-- Remove duplicate (loader_id, loader_field_id) pairs that may have been created due to several +-- old fields being converted to a single new field +DELETE FROM loader_fields_loaders + WHERE ctid NOT IN ( + SELECT MIN(ctid) + FROM loader_fields_loaders + GROUP BY loader_id, loader_field_id + ); + +-- Having both a PK and UNIQUE constraint for the same columns is redundant, so only restore the PK +ALTER TABLE loader_fields_loaders ADD PRIMARY KEY (loader_id, loader_field_id); +ALTER TABLE loader_fields_loaders REPLICA IDENTITY DEFAULT; + +-- Finally, remove the old loader fields +DELETE FROM loader_fields + WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only'); + +-- Add a field to the projects table to track whether the new environment field value has been +-- reviewed to be appropriate after automated migration +ALTER TABLE mods + ADD COLUMN side_types_migration_review_status VARCHAR(64) NOT NULL DEFAULT 'reviewed' + CHECK (side_types_migration_review_status IN ('reviewed', 'pending')); + +UPDATE mods SET side_types_migration_review_status = 'pending'; + +END; +$$ diff --git a/apps/labrinth/migrations/20250628213541_payout-sources-recording.sql b/apps/labrinth/migrations/20250628213541_payout-sources-recording.sql new file mode 100644 index 000000000..b22e75952 --- /dev/null +++ b/apps/labrinth/migrations/20250628213541_payout-sources-recording.sql @@ -0,0 +1,8 @@ +-- Add migration script here +CREATE TABLE payout_sources_balance ( + account_type TEXT NOT NULL, + amount numeric(40, 20) NOT NULL, + pending BOOLEAN NOT NULL, + recorded timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (recorded, account_type, pending) +); diff --git a/apps/labrinth/package.json b/apps/labrinth/package.json index 03e662511..4d01f2043 100644 --- a/apps/labrinth/package.json +++ b/apps/labrinth/package.json @@ -2,10 +2,13 @@ "name": "@modrinth/labrinth", "scripts": { "build": "cargo build --release", - "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings", - "fix": "cargo fmt && cargo clippy --fix", + "lint": "cargo fmt --check && cargo clippy --all-targets", + "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", "dev": "cargo run", - "//": "CI will fail since test takes up too much disk space. So we have it named differently.", - "test-labrinth": "cargo test" + "//": "Labrinth integration tests require a lot of disk space, so in the standard GitHub Actions", + "//": "runners we must remove useless development tools from the base image, which frees up ~20 GiB.", + "//": "The command commented out below can be used in CI to debug what is taking up space:", + "//": "sudo du -xh --max-depth=4 / | sort -rh | curl -X POST --data-urlencode content@/dev/fd/0 https://api.mclo.gs/1/log", + "test": "if-ci sudo rm -rf /usr/local/lib/android /usr/local/.ghcup /opt/hostedtoolcache/CodeQL /usr/share/swift && cargo nextest run --all-targets --no-fail-fast" } } diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index 07c6e436c..7cb644384 100644 --- a/apps/labrinth/src/auth/checks.rs +++ b/apps/labrinth/src/auth/checks.rs @@ -100,10 +100,7 @@ pub async fn filter_visible_project_ids( project.status.is_searchable() } else { !project.status.is_hidden() - }) || user_option - .as_ref() - .map(|x| x.role.is_mod()) - .unwrap_or(false) + }) || user_option.as_ref().is_some_and(|x| x.role.is_mod()) { return_projects.push(project.id); } else if user_option.is_some() { @@ -158,7 +155,7 @@ pub async fn filter_enlisted_projects_ids( ) .fetch(pool) .map_ok(|row| { - for x in projects.iter() { + for x in &projects { let bool = Some(x.id.0) == row.id && Some(x.team_id.0) == row.team_id; if bool { @@ -238,7 +235,6 @@ pub async fn filter_visible_version_ids( redis: &RedisPool, ) -> Result, ApiError> { let mut return_versions = Vec::new(); - let mut check_versions = Vec::new(); // First, filter out versions belonging to projects we can't see // (ie: a hidden project, but public version, should still be hidden) @@ -271,15 +267,10 @@ pub async fn filter_visible_version_ids( // - we are enlisted on the team of the mod if (!version.status.is_hidden() && visible_project_ids.contains(&version.project_id)) - || user_option - .as_ref() - .map(|x| x.role.is_mod()) - .unwrap_or(false) + || user_option.as_ref().is_some_and(|x| x.role.is_mod()) || enlisted_version_ids.contains(&version.id) { return_versions.push(version.id); - } else if user_option.is_some() { - check_versions.push(version); } } @@ -310,10 +301,7 @@ pub async fn filter_enlisted_version_ids( .await?; for version in versions { - if user_option - .as_ref() - .map(|x| x.role.is_mod()) - .unwrap_or(false) + if user_option.as_ref().is_some_and(|x| x.role.is_mod()) || (user_option.is_some() && authorized_project_ids.contains(&version.project_id)) { @@ -348,11 +336,8 @@ pub async fn filter_visible_collections( let mut check_collections = Vec::new(); for collection in collections { - if !collection.status.is_hidden() - || user_option - .as_ref() - .map(|x| x.role.is_mod()) - .unwrap_or(false) + if (!collection.status.is_hidden() && !collection.projects.is_empty()) + || user_option.as_ref().is_some_and(|x| x.role.is_mod()) { return_collections.push(collection.into()); } else if user_option.is_some() { diff --git a/apps/labrinth/src/auth/email/mod.rs b/apps/labrinth/src/auth/email/mod.rs index 269578db3..2220f389d 100644 --- a/apps/labrinth/src/auth/email/mod.rs +++ b/apps/labrinth/src/auth/email/mod.rs @@ -2,7 +2,7 @@ use lettre::message::Mailbox; use lettre::message::header::ContentType; use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::client::{Tls, TlsParameters}; -use lettre::{Address, Message, SmtpTransport, Transport}; +use lettre::{Message, SmtpTransport, Transport}; use thiserror::Error; use tracing::warn; @@ -23,11 +23,13 @@ pub fn send_email_raw( subject: String, body: String, ) -> Result<(), MailError> { + let from_name = dotenvy::var("SMTP_FROM_NAME") + .unwrap_or_else(|_| "Modrinth".to_string()); + let from_address = dotenvy::var("SMTP_FROM_ADDRESS") + .unwrap_or_else(|_| "no-reply@mail.modrinth.com".to_string()); + let email = Message::builder() - .from(Mailbox::new( - Some("Modrinth".to_string()), - Address::new("no-reply", "mail.modrinth.com")?, - )) + .from(Mailbox::new(Some(from_name), from_address.parse()?)) .to(to.parse()?) .subject(subject) .header(ContentType::TEXT_HTML) diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs index ad23bf74c..06656a52e 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -9,7 +9,7 @@ use ariadne::ids::DecodingError; #[error("{}", .error_type)] pub struct OAuthError { #[source] - pub error_type: OAuthErrorType, + pub error_type: Box, pub state: Option, pub valid_redirect_uri: Option, @@ -32,7 +32,7 @@ impl OAuthError { /// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1) pub fn error(error_type: impl Into) -> Self { Self { - error_type: error_type.into(), + error_type: Box::new(error_type.into()), valid_redirect_uri: None, state: None, } @@ -48,7 +48,7 @@ impl OAuthError { valid_redirect_uri: &ValidatedRedirectUri, ) -> Self { Self { - error_type: err.into(), + error_type: Box::new(err.into()), state: state.clone(), valid_redirect_uri: Some(valid_redirect_uri.clone()), } @@ -57,7 +57,7 @@ impl OAuthError { impl actix_web::ResponseError for OAuthError { fn status_code(&self) -> StatusCode { - match self.error_type { + match *self.error_type { OAuthErrorType::AuthenticationError(_) | OAuthErrorType::FailedScopeParse(_) | OAuthErrorType::ScopesTooBroad diff --git a/apps/labrinth/src/auth/oauth/mod.rs b/apps/labrinth/src/auth/oauth/mod.rs index 0c476b841..5a5d54670 100644 --- a/apps/labrinth/src/auth/oauth/mod.rs +++ b/apps/labrinth/src/auth/oauth/mod.rs @@ -1,3 +1,5 @@ +use std::fmt::Write; + use crate::auth::get_user_from_headers; use crate::auth::oauth::uris::{OAuthRedirectUris, ValidatedRedirectUri}; use crate::auth::validate::extract_authorization_header; @@ -17,7 +19,7 @@ use crate::queue::session::AuthQueue; use actix_web::http::header::{CACHE_CONTROL, LOCATION, PRAGMA}; use actix_web::web::{Data, Query, ServiceConfig}; use actix_web::{HttpRequest, HttpResponse, get, post, web}; -use chrono::Duration; +use chrono::{DateTime, Duration}; use rand::distributions::Alphanumeric; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; @@ -280,8 +282,8 @@ pub async fn request_token( authorization_id, token_hash, scopes, - created: Default::default(), - expires: Default::default(), + created: DateTime::default(), + expires: DateTime::default(), last_used: None, client_id, user_id, @@ -456,7 +458,7 @@ fn append_params_to_uri(uri: &str, params: &[impl AsRef]) -> String { let mut uri = uri.to_string(); let mut connector = if uri.contains('?') { "&" } else { "?" }; for param in params { - uri.push_str(&format!("{}{}", connector, param.as_ref())); + write!(&mut uri, "{connector}{}", param.as_ref()).unwrap(); connector = "&"; } diff --git a/apps/labrinth/src/auth/oauth/uris.rs b/apps/labrinth/src/auth/oauth/uris.rs index a3600dc7e..83b712946 100644 --- a/apps/labrinth/src/auth/oauth/uris.rs +++ b/apps/labrinth/src/auth/oauth/uris.rs @@ -101,7 +101,7 @@ mod tests { ); assert!(validated.is_err_and(|e| matches!( - e.error_type, + *e.error_type, OAuthErrorType::RedirectUriNotConfigured(_) ))); } diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs index 6820e8552..806eaa126 100644 --- a/apps/labrinth/src/auth/validate.rs +++ b/apps/labrinth/src/auth/validate.rs @@ -10,6 +10,40 @@ use actix_web::HttpRequest; use actix_web::http::header::{AUTHORIZATION, HeaderValue}; use chrono::Utc; +pub async fn get_maybe_user_from_headers<'a, E>( + req: &HttpRequest, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, + required_scopes: Scopes, +) -> Result, AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + if !req.headers().contains_key(AUTHORIZATION) { + return Ok(None); + } + + // Fetch DB user record and minos user from headers + let Some((scopes, db_user)) = get_user_record_from_bearer_token( + req, + None, + executor, + redis, + session_queue, + ) + .await? + else { + return Ok(None); + }; + + if !scopes.contains(required_scopes) { + return Ok(None); + } + + Ok(Some((scopes, User::from_full(db_user)))) +} + pub async fn get_user_from_headers<'a, E>( req: &HttpRequest, executor: E, @@ -93,12 +127,11 @@ where .await?; let rate_limit_ignore = dotenvy::var("RATE_LIMIT_IGNORE_KEY")?; - if !req + if req .headers() .get("x-ratelimit-key") .and_then(|x| x.to_str().ok()) - .map(|x| x == rate_limit_ignore) - .unwrap_or(false) + .is_none_or(|x| x != rate_limit_ignore) { let metadata = get_session_metadata(req).await?; session_queue.add_session(session.id, metadata).await; @@ -130,7 +163,7 @@ where user.map(|u| (access_token.scopes, u)) } - Some(("github", _)) | Some(("gho", _)) | Some(("ghp", _)) => { + Some(("github" | "gho" | "ghp", _)) => { let user = AuthProvider::GitHub.get_user(token).await?; let id = AuthProvider::GitHub.get_user_id(&user.id, executor).await?; diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs index fe1ffe6e7..e7c2215f5 100644 --- a/apps/labrinth/src/clickhouse/mod.rs +++ b/apps/labrinth/src/clickhouse/mod.rs @@ -1,5 +1,4 @@ -use hyper_tls::{HttpsConnector, native_tls}; -use hyper_util::client::legacy::connect::HttpConnector; +use hyper_rustls::HttpsConnectorBuilder; use hyper_util::rt::TokioExecutor; mod fetch; @@ -15,13 +14,11 @@ pub async fn init_client_with_database( database: &str, ) -> clickhouse::error::Result { let client = { - let mut http_connector = HttpConnector::new(); - http_connector.enforce_http(false); // allow https URLs - - let tls_connector = - native_tls::TlsConnector::builder().build().unwrap().into(); - let https_connector = - HttpsConnector::from((http_connector, tls_connector)); + let https_connector = HttpsConnectorBuilder::new() + .with_native_roots()? + .https_or_http() + .enable_all_versions() + .build(); let hyper_client = hyper_util::client::legacy::Client::builder(TokioExecutor::new()) .build(https_connector); diff --git a/apps/labrinth/src/database/models/friend_item.rs b/apps/labrinth/src/database/models/friend_item.rs index d39d627e6..1514057d1 100644 --- a/apps/labrinth/src/database/models/friend_item.rs +++ b/apps/labrinth/src/database/models/friend_item.rs @@ -105,7 +105,7 @@ impl DBFriend { created: row.created, accepted: row.accepted, }) - .filter(|x| accepted.map(|y| y == x.accepted).unwrap_or(true)) + .filter(|x| accepted.is_none_or(|y| y == x.accepted)) .collect::>(); Ok(friends) diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index a7efc85ca..795862cef 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -3,8 +3,9 @@ use crate::models::ids::{ ChargeId, CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId, - ProductPriceId, ProjectId, ReportId, SessionId, TeamId, TeamMemberId, - ThreadId, ThreadMessageId, UserSubscriptionId, VersionId, + ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId, + SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId, + UserSubscriptionId, VersionId, }; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range}; @@ -88,39 +89,50 @@ macro_rules! generate_bulk_ids { }; } +macro_rules! impl_db_id_interface { + ($id_struct:ident, $db_id_struct:ident, $(, generator: $generator_function:ident @ $db_table:expr, $(bulk_generator: $bulk_generator_function:ident,)?)?) => { + #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)] + #[sqlx(transparent)] + pub struct $db_id_struct(pub i64); + + impl From<$id_struct> for $db_id_struct { + fn from(id: $id_struct) -> Self { + Self(id.0 as i64) + } + } + + impl From<$db_id_struct> for $id_struct { + fn from(id: $db_id_struct) -> Self { + Self(id.0 as u64) + } + } + + $( + generate_ids!( + $generator_function, + $db_id_struct, + "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id=$1)" + ); + + $( + generate_bulk_ids!( + $bulk_generator_function, + $db_id_struct, + "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id = ANY($1))" + ); + )? + )? + }; +} + macro_rules! db_id_interface { ($id_struct:ident $(, generator: $generator_function:ident @ $db_table:expr, $(bulk_generator: $bulk_generator_function:ident,)?)?) => { paste! { - #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)] - #[sqlx(transparent)] - pub struct [< DB $id_struct >](pub i64); - - impl From<$id_struct> for [< DB $id_struct >] { - fn from(id: $id_struct) -> Self { - Self(id.0 as i64) - } - } - impl From<[< DB $id_struct >]> for $id_struct { - fn from(id: [< DB $id_struct >]) -> Self { - Self(id.0 as u64) - } - } - - $( - generate_ids!( - $generator_function, - [< DB $id_struct >], - "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id=$1)" - ); - - $( - generate_bulk_ids!( - $bulk_generator_function, - [< DB $id_struct >], - "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id = ANY($1))" - ); - )? - )? + impl_db_id_interface!( + $id_struct, + [< DB $id_struct >], + $(, generator: $generator_function @ $db_table, $(bulk_generator: $bulk_generator_function,)?)? + ); } }; } @@ -212,6 +224,14 @@ db_id_interface!( SessionId, generator: generate_session_id @ "sessions", ); +db_id_interface!( + SharedInstanceId, + generator: generate_shared_instance_id @ "shared_instances", +); +db_id_interface!( + SharedInstanceVersionId, + generator: generate_shared_instance_version_id @ "shared_instance_versions", +); db_id_interface!( TeamId, generator: generate_team_id @ "teams", diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/database/models/loader_fields.rs index fcfcd9593..da293ae6c 100644 --- a/apps/labrinth/src/database/models/loader_fields.rs +++ b/apps/labrinth/src/database/models/loader_fields.rs @@ -675,7 +675,7 @@ impl LoaderFieldEnumValue { .into_iter() .filter(|x| { let mut bool = true; - for (key, value) in filter.iter() { + for (key, value) in &filter { if let Some(metadata_value) = x.metadata.get(key) { bool &= metadata_value == value; } else { @@ -713,7 +713,7 @@ impl VersionField { query_version_fields.push(base.clone().with_string_value(s)) } VersionFieldValue::Boolean(b) => query_version_fields - .push(base.clone().with_int_value(if b { 1 } else { 0 })), + .push(base.clone().with_int_value(b as i32)), VersionFieldValue::ArrayInteger(v) => { for i in v { query_version_fields @@ -728,9 +728,8 @@ impl VersionField { } VersionFieldValue::ArrayBoolean(v) => { for b in v { - query_version_fields.push( - base.clone().with_int_value(if b { 1 } else { 0 }), - ); + query_version_fields + .push(base.clone().with_int_value(b as i32)); } } VersionFieldValue::Enum(_, v) => query_version_fields @@ -757,7 +756,7 @@ impl VersionField { l.field_id.0, l.version_id.0, l.int_value, - l.enum_value.as_ref().map(|e| e.0).unwrap_or(-1), + l.enum_value.as_ref().map_or(-1, |e| e.0), l.string_value.clone(), ) }) @@ -849,12 +848,11 @@ impl VersionField { query_loader_fields .iter() .flat_map(|q| { - let loader_field_type = match LoaderFieldType::build( + let Some(loader_field_type) = LoaderFieldType::build( &q.field_type, q.enum_type.map(|l| l.0), - ) { - Some(lft) => lft, - None => return vec![], + ) else { + return vec![]; }; let loader_field = LoaderField { id: q.id, @@ -1085,23 +1083,17 @@ impl VersionFieldValue { }; // Check errors- version_id must all be the same + // If the field type is a non-array, then the reason for multiple version ids is that there are multiple versions being aggregated, and those version ids are contained within. + // If the field type is an array, then the reason for multiple version ids is that there are multiple values for a single version + // (or a greater aggregation between multiple arrays, in which case the per-field version is lost, so we just take the first one and use it for that) let version_id = qvfs .iter() .map(|qvf| qvf.version_id) .unique() - .collect::>(); - // If the field type is a non-array, then the reason for multiple version ids is that there are multiple versions being aggregated, and those version ids are contained within. - // If the field type is an array, then the reason for multiple version ids is that there are multiple values for a single version - // (or a greater aggregation between multiple arrays, in which case the per-field version is lost, so we just take the first one and use it for that) - let version_id = - version_id.into_iter().next().unwrap_or(DBVersionId(0)); + .next() + .unwrap_or(DBVersionId(0)); - let field_id = qvfs - .iter() - .map(|qvf| qvf.field_id) - .unique() - .collect::>(); - if field_id.len() > 1 { + if qvfs.iter().map(|qvf| qvf.field_id).unique().count() > 1 { return Err(DatabaseError::SchemaError(format!( "Multiple field ids for field {field_name}" ))); @@ -1274,7 +1266,7 @@ impl VersionFieldValue { }; // Sort arrayenums by ordering, then by created - for (_, v) in value.iter_mut() { + for (_, v) in &mut value { if let VersionFieldValue::ArrayEnum(_, v) = v { v.sort_by(|a, b| { a.ordering.cmp(&b.ordering).then(a.created.cmp(&b.created)) @@ -1317,8 +1309,8 @@ impl VersionFieldValue { } } - // For conversion to an interanl string(s), such as for search facets, filtering, or direct hardcoding - // No matter the type, it will be converted to a Vec, whre the non-array types will have a single element + // For conversion to an internal string(s), such as for search facets, filtering, or direct hardcoding + // No matter the type, it will be converted to a Vec, where the non-array types will have a single element pub fn as_strings(&self) -> Vec { match self { VersionFieldValue::Integer(i) => vec![i.to_string()], @@ -1343,22 +1335,19 @@ impl VersionFieldValue { VersionFieldValue::Integer(i) => value.as_i64() == Some(*i as i64), VersionFieldValue::Text(s) => value.as_str() == Some(s), VersionFieldValue::Boolean(b) => value.as_bool() == Some(*b), - VersionFieldValue::ArrayInteger(v) => value - .as_i64() - .map(|i| v.contains(&(i as i32))) - .unwrap_or(false), - VersionFieldValue::ArrayText(v) => value - .as_str() - .map(|s| v.contains(&s.to_string())) - .unwrap_or(false), + VersionFieldValue::ArrayInteger(v) => { + value.as_i64().is_some_and(|i| v.contains(&(i as i32))) + } + VersionFieldValue::ArrayText(v) => { + value.as_str().is_some_and(|s| v.contains(&s.to_string())) + } VersionFieldValue::ArrayBoolean(v) => { - value.as_bool().map(|b| v.contains(&b)).unwrap_or(false) + value.as_bool().is_some_and(|b| v.contains(&b)) } VersionFieldValue::Enum(_, v) => value.as_str() == Some(&v.value), VersionFieldValue::ArrayEnum(_, v) => value .as_str() - .map(|s| v.iter().any(|v| v.value == s)) - .unwrap_or(false), + .is_some_and(|s| v.iter().any(|v| v.value == s)), } } } diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 20dabaad0..6a051b436 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -20,6 +20,7 @@ pub mod product_item; pub mod project_item; pub mod report_item; pub mod session_item; +pub mod shared_instance_item; pub mod team_item; pub mod thread_item; pub mod user_item; diff --git a/apps/labrinth/src/database/models/organization_item.rs b/apps/labrinth/src/database/models/organization_item.rs index 30922bf24..56637d01f 100644 --- a/apps/labrinth/src/database/models/organization_item.rs +++ b/apps/labrinth/src/database/models/organization_item.rs @@ -122,7 +122,7 @@ impl DBOrganization { |ids| async move { let org_ids: Vec = ids .iter() - .flat_map(|x| parse_base62(&x.to_string()).ok()) + .filter_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) .collect(); let slugs = ids diff --git a/apps/labrinth/src/database/models/pat_item.rs b/apps/labrinth/src/database/models/pat_item.rs index 6cf308213..5f760d579 100644 --- a/apps/labrinth/src/database/models/pat_item.rs +++ b/apps/labrinth/src/database/models/pat_item.rs @@ -108,7 +108,7 @@ impl DBPersonalAccessToken { |ids| async move { let pat_ids: Vec = ids .iter() - .flat_map(|x| parse_base62(&x.to_string()).ok()) + .filter_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) .collect(); let slugs = ids.into_iter().map(|x| x.to_string()).collect::>(); diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index 6bfe3320f..3c432eb79 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -6,7 +6,9 @@ use super::{DBUser, ids::*}; use crate::database::models; use crate::database::models::DatabaseError; use crate::database::redis::RedisPool; -use crate::models::projects::{MonetizationStatus, ProjectStatus}; +use crate::models::projects::{ + MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, +}; use ariadne::ids::base62_impl::parse_base62; use chrono::{DateTime, Utc}; use dashmap::{DashMap, DashSet}; @@ -211,6 +213,8 @@ impl ProjectBuilder { webhook_sent: false, color: self.color, monetization_status: self.monetization_status, + side_types_migration_review_status: + SideTypesMigrationReviewStatus::Reviewed, loaders: vec![], }; project_struct.insert(&mut *transaction).await?; @@ -289,6 +293,7 @@ pub struct DBProject { pub webhook_sent: bool, pub color: Option, pub monetization_status: MonetizationStatus, + pub side_types_migration_review_status: SideTypesMigrationReviewStatus, pub loaders: Vec, } @@ -303,13 +308,15 @@ impl DBProject { id, team_id, name, summary, description, published, downloads, icon_url, raw_icon_url, status, requested_status, license_url, license, - slug, color, monetization_status, organization_id + slug, color, monetization_status, organization_id, + side_types_migration_review_status ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, - LOWER($14), $15, $16, $17 + LOWER($14), $15, $16, $17, + $18 ) ", self.id as DBProjectId, @@ -329,6 +336,7 @@ impl DBProject { self.color.map(|x| x as i32), self.monetization_status.as_str(), self.organization_id.map(|x| x.0 as i64), + self.side_types_migration_review_status.as_str() ) .execute(&mut **transaction) .await?; @@ -552,7 +560,7 @@ impl DBProject { let mut exec = exec.acquire().await?; let project_ids_parsed: Vec = ids .iter() - .flat_map(|x| parse_base62(&x.to_string()).ok()) + .filter_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) .collect(); let slugs = ids @@ -730,7 +738,7 @@ impl DBProject { // Add loader fields to the set we need to fetch let loader_loader_field_ids = m.loader_fields.unwrap_or_default().into_iter().map(LoaderFieldId).collect::>(); - for loader_field_id in loader_loader_field_ids.iter() { + for loader_field_id in &loader_loader_field_ids { loader_field_ids.insert(*loader_field_id); } @@ -777,6 +785,7 @@ impl DBProject { m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, m.webhook_sent, m.color, t.id thread_id, m.monetization_status monetization_status, + m.side_types_migration_review_status side_types_migration_review_status, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories, ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories FROM mods m @@ -842,6 +851,9 @@ impl DBProject { monetization_status: MonetizationStatus::from_string( &m.monetization_status, ), + side_types_migration_review_status: SideTypesMigrationReviewStatus::from_string( + &m.side_types_migration_review_status, + ), loaders, }, categories: m.categories.unwrap_or_default(), diff --git a/apps/labrinth/src/database/models/session_item.rs b/apps/labrinth/src/database/models/session_item.rs index c728a8367..0203d52e6 100644 --- a/apps/labrinth/src/database/models/session_item.rs +++ b/apps/labrinth/src/database/models/session_item.rs @@ -153,7 +153,7 @@ impl DBSession { |ids| async move { let session_ids: Vec = ids .iter() - .flat_map(|x| parse_base62(&x.to_string()).ok()) + .filter_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) .collect(); let slugs = ids diff --git a/apps/labrinth/src/database/models/shared_instance_item.rs b/apps/labrinth/src/database/models/shared_instance_item.rs new file mode 100644 index 000000000..2be240850 --- /dev/null +++ b/apps/labrinth/src/database/models/shared_instance_item.rs @@ -0,0 +1,335 @@ +use crate::database::models::{ + DBSharedInstanceId, DBSharedInstanceVersionId, DBUserId, +}; +use crate::database::redis::RedisPool; +use crate::models::shared_instances::SharedInstanceUserPermissions; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use futures_util::TryStreamExt; +use serde::{Deserialize, Serialize}; + +//region shared_instances +pub struct DBSharedInstance { + pub id: DBSharedInstanceId, + pub title: String, + pub owner_id: DBUserId, + pub public: bool, + pub current_version_id: Option, +} + +struct SharedInstanceQueryResult { + id: i64, + title: String, + owner_id: i64, + public: bool, + current_version_id: Option, +} + +impl From for DBSharedInstance { + fn from(val: SharedInstanceQueryResult) -> Self { + DBSharedInstance { + id: DBSharedInstanceId(val.id), + title: val.title, + owner_id: DBUserId(val.owner_id), + public: val.public, + current_version_id: val + .current_version_id + .map(DBSharedInstanceVersionId), + } + } +} + +impl DBSharedInstance { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO shared_instances (id, title, owner_id, current_version_id) + VALUES ($1, $2, $3, $4) + ", + self.id as DBSharedInstanceId, + self.title, + self.owner_id as DBUserId, + self.current_version_id.map(|x| x.0), + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get( + id: DBSharedInstanceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let result = sqlx::query_as!( + SharedInstanceQueryResult, + " + SELECT id, title, owner_id, public, current_version_id + FROM shared_instances + WHERE id = $1 + ", + id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(Into::into)) + } + + pub async fn list_for_user( + user: DBUserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let results = sqlx::query_as!( + SharedInstanceQueryResult, + r#" + -- See https://github.com/launchbadge/sqlx/issues/1266 for why we need all the "as" + SELECT + id as "id!", + title as "title!", + public as "public!", + owner_id as "owner_id!", + current_version_id + FROM shared_instances + WHERE owner_id = $1 + UNION + SELECT + id as "id!", + title as "title!", + public as "public!", + owner_id as "owner_id!", + current_version_id + FROM shared_instances + JOIN shared_instance_users ON id = shared_instance_id + WHERE user_id = $1 + "#, + user.0, + ) + .fetch_all(exec) + .await?; + + Ok(results.into_iter().map(Into::into).collect()) + } +} +//endregion + +//region shared_instance_users +const USERS_NAMESPACE: &str = "shared_instance_users"; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct DBSharedInstanceUser { + pub user_id: DBUserId, + pub shared_instance_id: DBSharedInstanceId, + pub permissions: SharedInstanceUserPermissions, +} + +impl DBSharedInstanceUser { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO shared_instance_users (user_id, shared_instance_id, permissions) + VALUES ($1, $2, $3) + ", + self.user_id as DBUserId, + self.shared_instance_id as DBSharedInstanceId, + self.permissions.bits() as i64, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get_user_permissions( + instance_id: DBSharedInstanceId, + user_id: DBUserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, super::DatabaseError> + { + let permissions = sqlx::query!( + " + SELECT permissions + FROM shared_instance_users + WHERE shared_instance_id = $1 AND user_id = $2 + ", + instance_id as DBSharedInstanceId, + user_id as DBUserId, + ) + .fetch_optional(exec) + .await? + .map(|x| { + SharedInstanceUserPermissions::from_bits(x.permissions as u64) + .unwrap_or(SharedInstanceUserPermissions::empty()) + }); + + Ok(permissions) + } + + pub async fn get_from_instance( + instance_id: DBSharedInstanceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, super::DatabaseError> { + Self::get_from_instance_many(&[instance_id], exec, redis).await + } + + pub async fn get_from_instance_many( + instance_ids: &[DBSharedInstanceId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, super::DatabaseError> { + if instance_ids.is_empty() { + return Ok(vec![]); + } + + let users = redis + .get_cached_keys( + USERS_NAMESPACE, + &instance_ids.iter().map(|id| id.0).collect::>(), + async |user_ids| { + let users = sqlx::query!( + " + SELECT shared_instance_id, user_id, permissions + FROM shared_instance_users + WHERE shared_instance_id = ANY($1) + ", + &user_ids + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc: DashMap<_, Vec<_>>, m| { + acc.entry(m.shared_instance_id).or_default().push( + DBSharedInstanceUser { + user_id: DBUserId(m.user_id), + shared_instance_id: DBSharedInstanceId( + m.shared_instance_id, + ), + permissions: + SharedInstanceUserPermissions::from_bits( + m.permissions as u64, + ) + .unwrap_or( + SharedInstanceUserPermissions::empty(), + ), + }, + ); + + async move { Ok(acc) } + }) + .await?; + + Ok(users) + }, + ) + .await?; + + Ok(users.into_iter().flatten().collect()) + } + + pub async fn clear_cache( + instance_id: DBSharedInstanceId, + redis: &RedisPool, + ) -> Result<(), super::DatabaseError> { + let mut redis = redis.connect().await?; + redis.delete(USERS_NAMESPACE, instance_id.0).await?; + Ok(()) + } +} +//endregion + +//region shared_instance_versions +pub struct DBSharedInstanceVersion { + pub id: DBSharedInstanceVersionId, + pub shared_instance_id: DBSharedInstanceId, + pub size: u64, + pub sha512: Vec, + pub created: DateTime, +} + +struct SharedInstanceVersionQueryResult { + id: i64, + shared_instance_id: i64, + size: i64, + sha512: Vec, + created: DateTime, +} + +impl From for DBSharedInstanceVersion { + fn from(val: SharedInstanceVersionQueryResult) -> Self { + DBSharedInstanceVersion { + id: DBSharedInstanceVersionId(val.id), + shared_instance_id: DBSharedInstanceId(val.shared_instance_id), + size: val.size as u64, + sha512: val.sha512, + created: val.created, + } + } +} + +impl DBSharedInstanceVersion { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO shared_instance_versions (id, shared_instance_id, size, sha512, created) + VALUES ($1, $2, $3, $4, $5) + ", + self.id as DBSharedInstanceVersionId, + self.shared_instance_id as DBSharedInstanceId, + self.size as i64, + self.sha512, + self.created, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get( + id: DBSharedInstanceVersionId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let result = sqlx::query_as!( + SharedInstanceVersionQueryResult, + " + SELECT id, shared_instance_id, size, sha512, created + FROM shared_instance_versions + WHERE id = $1 + ", + id as DBSharedInstanceVersionId, + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(Into::into)) + } + + pub async fn get_for_instance( + instance_id: DBSharedInstanceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let results = sqlx::query_as!( + SharedInstanceVersionQueryResult, + " + SELECT id, shared_instance_id, size, sha512, created + FROM shared_instance_versions + WHERE shared_instance_id = $1 + ORDER BY created DESC + ", + instance_id as DBSharedInstanceId, + ) + .fetch_all(exec) + .await?; + + Ok(results.into_iter().map(Into::into).collect()) + } +} +//endregion diff --git a/apps/labrinth/src/database/models/team_item.rs b/apps/labrinth/src/database/models/team_item.rs index 641b89fb1..ec32a143b 100644 --- a/apps/labrinth/src/database/models/team_item.rs +++ b/apps/labrinth/src/database/models/team_item.rs @@ -45,7 +45,7 @@ impl TeamBuilder { .await?; let mut team_member_ids = Vec::new(); - for _ in self.members.iter() { + for _ in &self.members { team_member_ids.push(generate_team_member_id(transaction).await?.0); } let TeamBuilder { members } = self; diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index d38ed8460..6a2e4aba6 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -163,7 +163,7 @@ impl DBUser { |ids| async move { let user_ids: Vec = ids .iter() - .flat_map(|x| parse_base62(&x.to_string()).ok()) + .filter_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) .collect(); let slugs = ids @@ -511,6 +511,18 @@ impl DBUser { .execute(&mut **transaction) .await?; + sqlx::query!( + " + UPDATE shared_instances + SET owner_id = $1 + WHERE owner_id = $2 + ", + deleted_user as DBUserId, + id as DBUserId, + ) + .execute(&mut **transaction) + .await?; + use futures::TryStreamExt; let notifications: Vec = sqlx::query!( " diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index 0a1271e26..263130494 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -52,7 +52,7 @@ impl DependencyBuilder { transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<(), DatabaseError> { let mut project_ids = Vec::new(); - for dependency in builders.iter() { + for dependency in &builders { project_ids.push( dependency .try_get_project_id(transaction) @@ -333,9 +333,7 @@ impl DBVersion { ) -> Result, DatabaseError> { let result = Self::get(id, &mut **transaction, redis).await?; - let result = if let Some(result) = result { - result - } else { + let Some(result) = result else { return Ok(None); }; @@ -551,7 +549,7 @@ impl DBVersion { // Add loader fields to the set we need to fetch let loader_loader_field_ids = m.loader_fields.unwrap_or_default().into_iter().map(LoaderFieldId).collect::>(); - for loader_field_id in loader_loader_field_ids.iter() { + for loader_field_id in &loader_loader_field_ids { loader_field_ids.insert(*loader_field_id); } @@ -757,7 +755,7 @@ impl DBVersion { let mut files = files.into_iter().map(|x| { let mut file_hashes = HashMap::new(); - for hash in hashes.iter() { + for hash in &hashes { if hash.file_id == x.id { file_hashes.insert( hash.algorithm.clone(), @@ -853,7 +851,7 @@ impl DBVersion { ORDER BY v.date_published ", algorithm, - &file_ids.into_iter().flat_map(|x| x.split('_').last().map(|x| x.as_bytes().to_vec())).collect::>(), + &file_ids.into_iter().filter_map(|x| x.split('_').last().map(|x| x.as_bytes().to_vec())).collect::>(), ) .fetch(executor) .try_fold(DashMap::new(), |acc, f| { @@ -1043,14 +1041,14 @@ mod tests { date_published, project_id: DBProjectId(0), author_id: DBUserId(0), - name: Default::default(), - version_number: Default::default(), - changelog: Default::default(), - downloads: Default::default(), - version_type: Default::default(), - featured: Default::default(), + name: String::new(), + version_number: String::new(), + changelog: String::new(), + downloads: 0, + version_type: String::new(), + featured: false, status: VersionStatus::Listed, - requested_status: Default::default(), + requested_status: None, } } } diff --git a/apps/labrinth/src/database/redis.rs b/apps/labrinth/src/database/redis.rs index 913701ed1..9d3197c48 100644 --- a/apps/labrinth/src/database/redis.rs +++ b/apps/labrinth/src/database/redis.rs @@ -3,6 +3,7 @@ use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::{TimeZone, Utc}; use dashmap::DashMap; use deadpool_redis::{Config, Runtime}; +use futures::future::Either; use prometheus::{IntGauge, Registry}; use redis::{Cmd, ExistenceCheck, SetExpiry, SetOptions, cmd}; use serde::de::DeserializeOwned; @@ -11,7 +12,6 @@ use std::collections::HashMap; use std::fmt::{Debug, Display}; use std::future::Future; use std::hash::Hash; -use std::pin::Pin; use std::time::Duration; const DEFAULT_EXPIRY: i64 = 60 * 60 * 12; // 12 hours @@ -378,22 +378,10 @@ impl RedisPool { } } - #[allow(clippy::type_complexity)] - let mut fetch_tasks: Vec< - Pin< - Box< - dyn Future< - Output = Result< - HashMap>, - DatabaseError, - >, - >, - >, - >, - > = Vec::new(); + let mut fetch_tasks = Vec::new(); if !ids.is_empty() { - fetch_tasks.push(Box::pin(async { + fetch_tasks.push(Either::Left(async { let fetch_ids = ids.iter().map(|x| x.value().clone()).collect::>(); @@ -491,7 +479,7 @@ impl RedisPool { } if !subscribe_ids.is_empty() { - fetch_tasks.push(Box::pin(async { + fetch_tasks.push(Either::Right(async { let mut interval = tokio::time::interval(Duration::from_millis(100)); let start = Utc::now(); diff --git a/apps/labrinth/src/file_hosting/backblaze.rs b/apps/labrinth/src/file_hosting/backblaze.rs deleted file mode 100644 index 28d302245..000000000 --- a/apps/labrinth/src/file_hosting/backblaze.rs +++ /dev/null @@ -1,108 +0,0 @@ -use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; -use async_trait::async_trait; -use bytes::Bytes; -use reqwest::Response; -use serde::Deserialize; -use sha2::Digest; - -mod authorization; -mod delete; -mod upload; - -pub struct BackblazeHost { - upload_url_data: authorization::UploadUrlData, - authorization_data: authorization::AuthorizationData, -} - -impl BackblazeHost { - pub async fn new(key_id: &str, key: &str, bucket_id: &str) -> Self { - let authorization_data = - authorization::authorize_account(key_id, key).await.unwrap(); - let upload_url_data = - authorization::get_upload_url(&authorization_data, bucket_id) - .await - .unwrap(); - - BackblazeHost { - upload_url_data, - authorization_data, - } - } -} - -#[async_trait] -impl FileHost for BackblazeHost { - async fn upload_file( - &self, - content_type: &str, - file_name: &str, - file_bytes: Bytes, - ) -> Result { - let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes)); - - let upload_data = upload::upload_file( - &self.upload_url_data, - content_type, - file_name, - file_bytes, - ) - .await?; - Ok(UploadFileData { - file_id: upload_data.file_id, - file_name: upload_data.file_name, - content_length: upload_data.content_length, - content_sha512, - content_sha1: upload_data.content_sha1, - content_md5: upload_data.content_md5, - content_type: upload_data.content_type, - upload_timestamp: upload_data.upload_timestamp, - }) - } - - /* - async fn upload_file_streaming( - &self, - content_type: &str, - file_name: &str, - stream: reqwest::Body - ) -> Result { - use futures::stream::StreamExt; - - let mut data = Vec::new(); - while let Some(chunk) = stream.next().await { - data.extend_from_slice(&chunk.map_err(|e| FileHostingError::Other(e))?); - } - self.upload_file(content_type, file_name, data).await - } - */ - - async fn delete_file_version( - &self, - file_id: &str, - file_name: &str, - ) -> Result { - let delete_data = delete::delete_file_version( - &self.authorization_data, - file_id, - file_name, - ) - .await?; - Ok(DeleteFileData { - file_id: delete_data.file_id, - file_name: delete_data.file_name, - }) - } -} - -pub async fn process_response( - response: Response, -) -> Result -where - T: for<'de> Deserialize<'de>, -{ - if response.status().is_success() { - Ok(response.json().await?) - } else { - Err(FileHostingError::BackblazeError(response.json().await?)) - } -} diff --git a/apps/labrinth/src/file_hosting/backblaze/authorization.rs b/apps/labrinth/src/file_hosting/backblaze/authorization.rs deleted file mode 100644 index 9ab9e5982..000000000 --- a/apps/labrinth/src/file_hosting/backblaze/authorization.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::file_hosting::FileHostingError; -use base64::Engine; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct AuthorizationPermissions { - bucket_id: Option, - bucket_name: Option, - capabilities: Vec, - name_prefix: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct AuthorizationData { - pub absolute_minimum_part_size: i32, - pub account_id: String, - pub allowed: AuthorizationPermissions, - pub api_url: String, - pub authorization_token: String, - pub download_url: String, - pub recommended_part_size: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct UploadUrlData { - pub bucket_id: String, - pub upload_url: String, - pub authorization_token: String, -} - -pub async fn authorize_account( - key_id: &str, - application_key: &str, -) -> Result { - let combined_key = format!("{key_id}:{application_key}"); - let formatted_key = format!( - "Basic {}", - base64::engine::general_purpose::STANDARD.encode(combined_key) - ); - - let response = reqwest::Client::new() - .get("https://api.backblazeb2.com/b2api/v2/b2_authorize_account") - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header(reqwest::header::AUTHORIZATION, formatted_key) - .send() - .await?; - - super::process_response(response).await -} - -pub async fn get_upload_url( - authorization_data: &AuthorizationData, - bucket_id: &str, -) -> Result { - let response = reqwest::Client::new() - .post( - format!( - "{}/b2api/v2/b2_get_upload_url", - authorization_data.api_url - ) - .to_string(), - ) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header( - reqwest::header::AUTHORIZATION, - &authorization_data.authorization_token, - ) - .body( - serde_json::json!({ - "bucketId": bucket_id, - }) - .to_string(), - ) - .send() - .await?; - - super::process_response(response).await -} diff --git a/apps/labrinth/src/file_hosting/backblaze/delete.rs b/apps/labrinth/src/file_hosting/backblaze/delete.rs deleted file mode 100644 index 87e24ac3c..000000000 --- a/apps/labrinth/src/file_hosting/backblaze/delete.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::authorization::AuthorizationData; -use crate::file_hosting::FileHostingError; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct DeleteFileData { - pub file_id: String, - pub file_name: String, -} - -pub async fn delete_file_version( - authorization_data: &AuthorizationData, - file_id: &str, - file_name: &str, -) -> Result { - let response = reqwest::Client::new() - .post(format!( - "{}/b2api/v2/b2_delete_file_version", - authorization_data.api_url - )) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header( - reqwest::header::AUTHORIZATION, - &authorization_data.authorization_token, - ) - .body( - serde_json::json!({ - "fileName": file_name, - "fileId": file_id - }) - .to_string(), - ) - .send() - .await?; - - super::process_response(response).await -} diff --git a/apps/labrinth/src/file_hosting/backblaze/upload.rs b/apps/labrinth/src/file_hosting/backblaze/upload.rs deleted file mode 100644 index 44bed4697..000000000 --- a/apps/labrinth/src/file_hosting/backblaze/upload.rs +++ /dev/null @@ -1,47 +0,0 @@ -use super::authorization::UploadUrlData; -use crate::file_hosting::FileHostingError; -use bytes::Bytes; -use hex::ToHex; -use serde::{Deserialize, Serialize}; -use sha1::Digest; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct UploadFileData { - pub file_id: String, - pub file_name: String, - pub account_id: String, - pub bucket_id: String, - pub content_length: u32, - pub content_sha1: String, - pub content_md5: Option, - pub content_type: String, - pub upload_timestamp: u64, -} - -//Content Types found here: https://www.backblaze.com/b2/docs/content-types.html -pub async fn upload_file( - url_data: &UploadUrlData, - content_type: &str, - file_name: &str, - file_bytes: Bytes, -) -> Result { - let response = reqwest::Client::new() - .post(&url_data.upload_url) - .header( - reqwest::header::AUTHORIZATION, - &url_data.authorization_token, - ) - .header("X-Bz-File-Name", file_name) - .header(reqwest::header::CONTENT_TYPE, content_type) - .header(reqwest::header::CONTENT_LENGTH, file_bytes.len()) - .header( - "X-Bz-Content-Sha1", - sha1::Sha1::digest(&file_bytes).encode_hex::(), - ) - .body(file_bytes) - .send() - .await?; - - super::process_response(response).await -} diff --git a/apps/labrinth/src/file_hosting/mock.rs b/apps/labrinth/src/file_hosting/mock.rs index aef633e58..c04f92420 100644 --- a/apps/labrinth/src/file_hosting/mock.rs +++ b/apps/labrinth/src/file_hosting/mock.rs @@ -1,9 +1,13 @@ -use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; +use super::{ + DeleteFileData, FileHost, FileHostPublicity, FileHostingError, + UploadFileData, +}; use async_trait::async_trait; use bytes::Bytes; use chrono::Utc; use hex::ToHex; use sha2::Digest; +use std::path::PathBuf; #[derive(Default)] pub struct MockHost(()); @@ -20,11 +24,10 @@ impl FileHost for MockHost { &self, content_type: &str, file_name: &str, + file_publicity: FileHostPublicity, file_bytes: Bytes, ) -> Result { - let path = - std::path::Path::new(&dotenvy::var("MOCK_FILE_PATH").unwrap()) - .join(file_name.replace("../", "")); + let path = get_file_path(file_name, file_publicity); std::fs::create_dir_all( path.parent().ok_or(FileHostingError::InvalidFilename)?, )?; @@ -33,8 +36,8 @@ impl FileHost for MockHost { std::fs::write(path, &*file_bytes)?; Ok(UploadFileData { - file_id: String::from("MOCK_FILE_ID"), file_name: file_name.to_string(), + file_publicity, content_length: file_bytes.len() as u32, content_sha512, content_sha1, @@ -44,20 +47,40 @@ impl FileHost for MockHost { }) } - async fn delete_file_version( + async fn get_url_for_private_file( &self, - file_id: &str, file_name: &str, + _expiry_secs: u32, + ) -> Result { + let cdn_url = dotenvy::var("CDN_URL").unwrap(); + Ok(format!("{cdn_url}/private/{file_name}")) + } + + async fn delete_file( + &self, + file_name: &str, + file_publicity: FileHostPublicity, ) -> Result { - let path = - std::path::Path::new(&dotenvy::var("MOCK_FILE_PATH").unwrap()) - .join(file_name.replace("../", "")); + let path = get_file_path(file_name, file_publicity); if path.exists() { std::fs::remove_file(path)?; } Ok(DeleteFileData { - file_id: file_id.to_string(), file_name: file_name.to_string(), }) } } + +fn get_file_path( + file_name: &str, + file_publicity: FileHostPublicity, +) -> PathBuf { + let mut path = PathBuf::from(dotenvy::var("MOCK_FILE_PATH").unwrap()); + + if matches!(file_publicity, FileHostPublicity::Private) { + path.push("private"); + } + path.push(file_name.replace("../", "")); + + path +} diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index b89d35cbb..7de0ff6a9 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -1,23 +1,17 @@ use async_trait::async_trait; use thiserror::Error; -mod backblaze; mod mock; mod s3_host; -pub use backblaze::BackblazeHost; use bytes::Bytes; pub use mock::MockHost; -pub use s3_host::S3Host; +pub use s3_host::{S3BucketConfig, S3Host}; #[derive(Error, Debug)] pub enum FileHostingError { - #[error("Error while accessing the data from backblaze")] - HttpError(#[from] reqwest::Error), - #[error("Backblaze error: {0}")] - BackblazeError(serde_json::Value), - #[error("S3 error: {0}")] - S3Error(String), + #[error("S3 error when {0}: {1}")] + S3Error(&'static str, s3::error::S3Error), #[error("File system error in file hosting: {0}")] FileSystemError(#[from] std::io::Error), #[error("Invalid Filename")] @@ -26,8 +20,8 @@ pub enum FileHostingError { #[derive(Debug, Clone)] pub struct UploadFileData { - pub file_id: String, pub file_name: String, + pub file_publicity: FileHostPublicity, pub content_length: u32, pub content_sha512: String, pub content_sha1: String, @@ -38,22 +32,34 @@ pub struct UploadFileData { #[derive(Debug, Clone)] pub struct DeleteFileData { - pub file_id: String, pub file_name: String, } +#[derive(Debug, Copy, Clone)] +pub enum FileHostPublicity { + Public, + Private, +} + #[async_trait] pub trait FileHost { async fn upload_file( &self, content_type: &str, file_name: &str, + file_publicity: FileHostPublicity, file_bytes: Bytes, ) -> Result; - async fn delete_file_version( + async fn get_url_for_private_file( &self, - file_id: &str, file_name: &str, + expiry_secs: u32, + ) -> Result; + + async fn delete_file( + &self, + file_name: &str, + file_publicity: FileHostPublicity, ) -> Result; } diff --git a/apps/labrinth/src/file_hosting/s3_host.rs b/apps/labrinth/src/file_hosting/s3_host.rs index 369ee52d6..a1a7c02df 100644 --- a/apps/labrinth/src/file_hosting/s3_host.rs +++ b/apps/labrinth/src/file_hosting/s3_host.rs @@ -1,5 +1,6 @@ use crate::file_hosting::{ - DeleteFileData, FileHost, FileHostingError, UploadFileData, + DeleteFileData, FileHost, FileHostPublicity, FileHostingError, + UploadFileData, }; use async_trait::async_trait; use bytes::Bytes; @@ -10,50 +11,70 @@ use s3::creds::Credentials; use s3::region::Region; use sha2::Digest; +pub struct S3BucketConfig { + pub name: String, + pub uses_path_style: bool, + pub region: String, + pub url: String, + pub access_token: String, + pub secret: String, +} + pub struct S3Host { - bucket: Bucket, + public_bucket: Bucket, + private_bucket: Bucket, } impl S3Host { pub fn new( - bucket_name: &str, - bucket_region: &str, - url: &str, - access_token: &str, - secret: &str, + public_bucket: S3BucketConfig, + private_bucket: S3BucketConfig, ) -> Result { - let bucket = Bucket::new( - bucket_name, - if bucket_region == "r2" { - Region::R2 { - account_id: url.to_string(), - } - } else { - Region::Custom { - region: bucket_region.to_string(), - endpoint: url.to_string(), - } - }, - Credentials::new( - Some(access_token), - Some(secret), - None, - None, - None, - ) - .map_err(|_| { - FileHostingError::S3Error( - "Error while creating credentials".to_string(), + let create_bucket = + |config: S3BucketConfig| -> Result<_, FileHostingError> { + let mut bucket = Bucket::new( + "", + if config.region == "r2" { + Region::R2 { + account_id: config.url, + } + } else { + Region::Custom { + region: config.region, + endpoint: config.url, + } + }, + Credentials { + access_key: Some(config.access_token), + secret_key: Some(config.secret), + ..Credentials::anonymous().unwrap() + }, ) - })?, - ) - .map_err(|_| { - FileHostingError::S3Error( - "Error while creating Bucket instance".to_string(), - ) - })?; + .map_err(|e| { + FileHostingError::S3Error("creating Bucket instance", e) + })?; - Ok(S3Host { bucket: *bucket }) + bucket.name = config.name; + if config.uses_path_style { + bucket.set_path_style(); + } else { + bucket.set_subdomain_style(); + } + + Ok(bucket) + }; + + Ok(S3Host { + public_bucket: *create_bucket(public_bucket)?, + private_bucket: *create_bucket(private_bucket)?, + }) + } + + fn get_bucket(&self, publicity: FileHostPublicity) -> &Bucket { + match publicity { + FileHostPublicity::Public => &self.public_bucket, + FileHostPublicity::Private => &self.private_bucket, + } } } @@ -63,27 +84,24 @@ impl FileHost for S3Host { &self, content_type: &str, file_name: &str, + file_publicity: FileHostPublicity, file_bytes: Bytes, ) -> Result { let content_sha1 = sha1::Sha1::digest(&file_bytes).encode_hex(); let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes)); - self.bucket + self.get_bucket(file_publicity) .put_object_with_content_type( format!("/{file_name}"), &file_bytes, content_type, ) .await - .map_err(|err| { - FileHostingError::S3Error(format!( - "Error while uploading file {file_name} to S3: {err}" - )) - })?; + .map_err(|e| FileHostingError::S3Error("uploading file", e))?; Ok(UploadFileData { - file_id: file_name.to_string(), file_name: file_name.to_string(), + file_publicity, content_length: file_bytes.len() as u32, content_sha512, content_sha1, @@ -93,22 +111,32 @@ impl FileHost for S3Host { }) } - async fn delete_file_version( + async fn get_url_for_private_file( &self, - file_id: &str, file_name: &str, + expiry_secs: u32, + ) -> Result { + let url = self + .private_bucket + .presign_get(format!("/{file_name}"), expiry_secs, None) + .await + .map_err(|e| { + FileHostingError::S3Error("generating presigned URL", e) + })?; + Ok(url) + } + + async fn delete_file( + &self, + file_name: &str, + file_publicity: FileHostPublicity, ) -> Result { - self.bucket + self.get_bucket(file_publicity) .delete_object(format!("/{file_name}")) .await - .map_err(|err| { - FileHostingError::S3Error(format!( - "Error while deleting file {file_name} to S3: {err}" - )) - })?; + .map_err(|e| FileHostingError::S3Error("deleting file", e))?; Ok(DeleteFileData { - file_id: file_id.to_string(), file_name: file_name.to_string(), }) } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 66007a9a2..24bfcc93f 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -16,6 +16,7 @@ use util::cors::default_cors; use crate::background_task::update_versions; use crate::queue::moderation::AutomatedModerationQueue; +use crate::queue::payouts::insert_bank_balances; use crate::util::env::{parse_strings_from_var, parse_var}; use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters}; use sync::friends::handle_pubsub; @@ -252,6 +253,23 @@ pub fn app_setup( }; let payouts_queue = web::Data::new(PayoutsQueue::new()); + + let payouts_queue_ref = payouts_queue.clone(); + let pool_ref = pool.clone(); + scheduler.run(Duration::from_secs(60 * 60 * 6), move || { + let payouts_queue_ref = payouts_queue_ref.clone(); + let pool_ref = pool_ref.clone(); + async move { + info!("Started updating bank balances"); + let result = + insert_bank_balances(&payouts_queue_ref, &pool_ref).await; + if let Err(e) = result { + warn!("Bank balance update failed: {:?}", e); + } + info!("Done updating bank balances"); + } + }); + let active_sockets = web::Data::new(ActiveSockets::default()); { @@ -313,13 +331,16 @@ pub fn app_config( .app_data(labrinth_config.automated_moderation_queue.clone()) .app_data(web::Data::new(labrinth_config.stripe_client.clone())) .app_data(labrinth_config.rate_limiter.clone()) - .configure( - #[allow(unused_variables)] - |cfg| { - #[cfg(target_os = "linux")] - routes::debug::config(cfg) - }, - ) + .configure({ + #[cfg(target_os = "linux")] + { + |cfg| routes::debug::config(cfg) + } + #[cfg(not(target_os = "linux"))] + { + |_cfg| () + } + }) .configure(routes::v2::config) .configure(routes::v3::config) .configure(routes::internal::config) @@ -331,7 +352,7 @@ pub fn app_config( pub fn check_env_vars() -> bool { let mut failed = false; - fn check_var(var: &'static str) -> bool { + fn check_var(var: &str) -> bool { let check = parse_var::(var).is_none(); if check { warn!( @@ -358,25 +379,33 @@ pub fn check_env_vars() -> bool { let storage_backend = dotenvy::var("STORAGE_BACKEND").ok(); match storage_backend.as_deref() { - Some("backblaze") => { - failed |= check_var::("BACKBLAZE_KEY_ID"); - failed |= check_var::("BACKBLAZE_KEY"); - failed |= check_var::("BACKBLAZE_BUCKET_ID"); - } Some("s3") => { - failed |= check_var::("S3_ACCESS_TOKEN"); - failed |= check_var::("S3_SECRET"); - failed |= check_var::("S3_URL"); - failed |= check_var::("S3_REGION"); - failed |= check_var::("S3_BUCKET_NAME"); + let mut check_var_set = |var_prefix| { + failed |= check_var::(&format!( + "S3_{var_prefix}_BUCKET_NAME" + )); + failed |= check_var::(&format!( + "S3_{var_prefix}_USES_PATH_STYLE_BUCKET" + )); + failed |= + check_var::(&format!("S3_{var_prefix}_REGION")); + failed |= check_var::(&format!("S3_{var_prefix}_URL")); + failed |= check_var::(&format!( + "S3_{var_prefix}_ACCESS_TOKEN" + )); + failed |= + check_var::(&format!("S3_{var_prefix}_SECRET")); + }; + + check_var_set("PUBLIC"); + check_var_set("PRIVATE"); } Some("local") => { failed |= check_var::("MOCK_FILE_PATH"); } Some(backend) => { warn!( - "Variable `STORAGE_BACKEND` contains an invalid value: {}. Expected \"backblaze\", \"s3\", or \"local\".", - backend + "Variable `STORAGE_BACKEND` contains an invalid value: {backend}. Expected \"s3\" or \"local\"." ); failed |= true; } diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 25a7b9f3f..0e9bc762e 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -4,10 +4,12 @@ use actix_web_prom::PrometheusMetricsBuilder; use clap::Parser; use labrinth::background_task::BackgroundTask; use labrinth::database::redis::RedisPool; -use labrinth::file_hosting::S3Host; +use labrinth::file_hosting::{S3BucketConfig, S3Host}; use labrinth::search; +use labrinth::util::env::parse_var; use labrinth::util::ratelimit::rate_limit_middleware; use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue}; +use std::ffi::CStr; use std::sync::Arc; use tracing::{error, info}; use tracing_actix_web::TracingLogger; @@ -16,10 +18,8 @@ use tracing_actix_web::TracingLogger; #[global_allocator] static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; -#[allow(non_upper_case_globals)] #[unsafe(export_name = "malloc_conf")] -pub static malloc_conf: &[u8] = - b"prof:true,prof_active:true,lg_prof_sample:19\0"; +pub static MALLOC_CONF: &CStr = c"prof:true,prof_active:true,lg_prof_sample:19"; #[derive(Clone)] pub struct Pepper { @@ -52,6 +52,7 @@ async fn main() -> std::io::Result<()> { if check_env_vars() { error!("Some environment variables are missing!"); + std::process::exit(1); } // DSN is from SENTRY_DSN env variable. @@ -94,24 +95,33 @@ async fn main() -> std::io::Result<()> { let file_host: Arc = match storage_backend.as_str() { - "backblaze" => Arc::new( - file_hosting::BackblazeHost::new( - &dotenvy::var("BACKBLAZE_KEY_ID").unwrap(), - &dotenvy::var("BACKBLAZE_KEY").unwrap(), - &dotenvy::var("BACKBLAZE_BUCKET_ID").unwrap(), + "s3" => { + let config_from_env = |bucket_type| S3BucketConfig { + name: parse_var(&format!("S3_{bucket_type}_BUCKET_NAME")) + .unwrap(), + uses_path_style: parse_var(&format!( + "S3_{bucket_type}_USES_PATH_STYLE_BUCKET" + )) + .unwrap(), + region: parse_var(&format!("S3_{bucket_type}_REGION")) + .unwrap(), + url: parse_var(&format!("S3_{bucket_type}_URL")).unwrap(), + access_token: parse_var(&format!( + "S3_{bucket_type}_ACCESS_TOKEN" + )) + .unwrap(), + secret: parse_var(&format!("S3_{bucket_type}_SECRET")) + .unwrap(), + }; + + Arc::new( + S3Host::new( + config_from_env("PUBLIC"), + config_from_env("PRIVATE"), + ) + .unwrap(), ) - .await, - ), - "s3" => Arc::new( - S3Host::new( - &dotenvy::var("S3_BUCKET_NAME").unwrap(), - &dotenvy::var("S3_REGION").unwrap(), - &dotenvy::var("S3_URL").unwrap(), - &dotenvy::var("S3_ACCESS_TOKEN").unwrap(), - &dotenvy::var("S3_SECRET").unwrap(), - ) - .unwrap(), - ), + } "local" => Arc::new(file_hosting::MockHost::new()), _ => panic!("Invalid storage backend specified. Aborting startup!"), }; diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index aea510d79..8b31a04c7 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -16,6 +16,7 @@ pub use v3::payouts; pub use v3::projects; pub use v3::reports; pub use v3::sessions; +pub use v3::shared_instances; pub use v3::teams; pub use v3::threads; pub use v3::users; diff --git a/apps/labrinth/src/models/v2/projects.rs b/apps/labrinth/src/models/v2/projects.rs index 35a340f0b..0af1e53fe 100644 --- a/apps/labrinth/src/models/v2/projects.rs +++ b/apps/labrinth/src/models/v2/projects.rs @@ -127,7 +127,7 @@ impl LegacyProject { .collect(); if let Some(versions_item) = versions_item { - // Extract side types from remaining fields (singleplayer, client_only, etc) + // Extract side types from remaining fields let fields = versions_item .version_fields .iter() @@ -135,10 +135,11 @@ impl LegacyProject { (f.field_name.clone(), f.value.clone().serialize_internal()) }) .collect::>(); - (client_side, server_side) = v2_reroute::convert_side_types_v2( - &fields, - Some(&*og_project_type), - ); + (client_side, server_side) = + v2_reroute::convert_v3_side_types_to_v2_side_types( + &fields, + Some(&*og_project_type), + ); // - if loader is mrpack, this is a modpack // the loaders are whatever the corresponding loader fields are diff --git a/apps/labrinth/src/models/v2/search.rs b/apps/labrinth/src/models/v2/search.rs index dfc9356b7..1aabaca14 100644 --- a/apps/labrinth/src/models/v2/search.rs +++ b/apps/labrinth/src/models/v2/search.rs @@ -102,28 +102,20 @@ impl LegacyResultSearchProject { let project_loader_fields = result_search_project.project_loader_fields.clone(); - let get_one_bool_loader_field = |key: &str| { + let get_one_string_loader_field = |key: &str| { project_loader_fields .get(key) - .cloned() - .unwrap_or_default() + .map_or(&[][..], |values| values.as_slice()) .first() - .and_then(|s| s.as_bool()) + .and_then(|s| s.as_str()) }; - let singleplayer = get_one_bool_loader_field("singleplayer"); - let client_only = - get_one_bool_loader_field("client_only").unwrap_or(false); - let server_only = - get_one_bool_loader_field("server_only").unwrap_or(false); - let client_and_server = get_one_bool_loader_field("client_and_server"); + let environment = + get_one_string_loader_field("environment").unwrap_or("unknown"); let (client_side, server_side) = - v2_reroute::convert_side_types_v2_bools( - singleplayer, - client_only, - server_only, - client_and_server, + v2_reroute::convert_v3_environment_to_v2_side_types( + environment, Some(&*og_project_type), ); let client_side = client_side.to_string(); diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 2e1c9745e..a95729712 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -17,6 +17,8 @@ base62_id!(ProductPriceId); base62_id!(ProjectId); base62_id!(ReportId); base62_id!(SessionId); +base62_id!(SharedInstanceId); +base62_id!(SharedInstanceVersionId); base62_id!(TeamId); base62_id!(TeamMemberId); base62_id!(ThreadId); diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index d9ffb8451..c51c026fa 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -12,6 +12,7 @@ pub mod payouts; pub mod projects; pub mod reports; pub mod sessions; +pub mod shared_instances; pub mod teams; pub mod threads; pub mod users; diff --git a/apps/labrinth/src/models/v3/pats.rs b/apps/labrinth/src/models/v3/pats.rs index edfb557ee..fc700238e 100644 --- a/apps/labrinth/src/models/v3/pats.rs +++ b/apps/labrinth/src/models/v3/pats.rs @@ -100,6 +100,24 @@ bitflags::bitflags! { // only accessible by modrinth-issued sessions const SESSION_ACCESS = 1 << 39; + // create a shared instance + const SHARED_INSTANCE_CREATE = 1 << 40; + // read a shared instance + const SHARED_INSTANCE_READ = 1 << 41; + // write to a shared instance + const SHARED_INSTANCE_WRITE = 1 << 42; + // delete a shared instance + const SHARED_INSTANCE_DELETE = 1 << 43; + + // create a shared instance version + const SHARED_INSTANCE_VERSION_CREATE = 1 << 44; + // read a shared instance version + const SHARED_INSTANCE_VERSION_READ = 1 << 45; + // write to a shared instance version + const SHARED_INSTANCE_VERSION_WRITE = 1 << 46; + // delete a shared instance version + const SHARED_INSTANCE_VERSION_DELETE = 1 << 47; + const NONE = 0b0; } } diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index e509f0b89..56d241d3b 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -92,6 +92,9 @@ pub struct Project { /// The monetization status of this project pub monetization_status: MonetizationStatus, + /// The status of the manual review of the migration of side types of this project + pub side_types_migration_review_status: SideTypesMigrationReviewStatus, + /// Aggregated loader-fields across its myriad of versions #[serde(flatten)] pub fields: HashMap>, @@ -122,7 +125,7 @@ pub fn from_duplicate_version_fields( } // Remove duplicates - for (_, v) in fields.iter_mut() { + for v in fields.values_mut() { *v = mem::take(v).into_iter().unique().collect_vec(); } fields @@ -206,6 +209,8 @@ impl From for Project { color: m.color, thread_id: data.thread_id.into(), monetization_status: m.monetization_status, + side_types_migration_review_status: m + .side_types_migration_review_status, fields, } } @@ -588,6 +593,35 @@ impl MonetizationStatus { } } +/// Represents the status of the manual review of the migration of side types of this +/// project to the new environment field. +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum SideTypesMigrationReviewStatus { + /// The project has been reviewed to use the new environment side types appropriately. + Reviewed, + /// The project has been automatically migrated to the new environment side types, but + /// the appropriateness of such migration has not been reviewed. + Pending, +} + +impl SideTypesMigrationReviewStatus { + pub fn as_str(&self) -> &'static str { + match self { + SideTypesMigrationReviewStatus::Reviewed => "reviewed", + SideTypesMigrationReviewStatus::Pending => "pending", + } + } + + pub fn from_string(string: &str) -> SideTypesMigrationReviewStatus { + match string { + "reviewed" => SideTypesMigrationReviewStatus::Reviewed, + "pending" => SideTypesMigrationReviewStatus::Pending, + _ => SideTypesMigrationReviewStatus::Reviewed, + } + } +} + /// A specific version of a project #[derive(Serialize, Deserialize, Clone)] pub struct Version { @@ -846,7 +880,6 @@ impl std::fmt::Display for VersionType { } impl VersionType { - // These are constant, so this can remove unneccessary allocations (`to_string`) pub fn as_str(&self) -> &'static str { match self { VersionType::Release => "release", diff --git a/apps/labrinth/src/models/v3/shared_instances.rs b/apps/labrinth/src/models/v3/shared_instances.rs new file mode 100644 index 000000000..abbf773f9 --- /dev/null +++ b/apps/labrinth/src/models/v3/shared_instances.rs @@ -0,0 +1,89 @@ +use crate::bitflags_serde_impl; +use crate::database::models::shared_instance_item::{ + DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion, +}; +use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId}; +use ariadne::ids::UserId; +use bitflags::bitflags; +use chrono::{DateTime, Utc}; +use hex::ToHex; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedInstance { + pub id: SharedInstanceId, + pub title: String, + pub owner: UserId, + pub public: bool, + pub current_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_users: Option>, +} + +impl SharedInstance { + pub fn from_db( + instance: DBSharedInstance, + users: Option>, + current_version: Option, + ) -> Self { + SharedInstance { + id: instance.id.into(), + title: instance.title, + owner: instance.owner_id.into(), + public: instance.public, + current_version: current_version.map(Into::into), + additional_users: users + .map(|x| x.into_iter().map(Into::into).collect()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedInstanceVersion { + pub id: SharedInstanceVersionId, + pub shared_instance: SharedInstanceId, + pub size: u64, + pub sha512: String, + pub created: DateTime, +} + +impl From for SharedInstanceVersion { + fn from(value: DBSharedInstanceVersion) -> Self { + let version_id = value.id.into(); + let shared_instance_id = value.shared_instance_id.into(); + SharedInstanceVersion { + id: version_id, + shared_instance: shared_instance_id, + size: value.size, + sha512: value.sha512.encode_hex(), + created: value.created, + } + } +} + +bitflags! { + #[derive(Copy, Clone, Debug)] + pub struct SharedInstanceUserPermissions: u64 { + const EDIT = 1 << 0; + const DELETE = 1 << 1; + const UPLOAD_VERSION = 1 << 2; + const DELETE_VERSION = 1 << 3; + } +} + +bitflags_serde_impl!(SharedInstanceUserPermissions, u64); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedInstanceUser { + pub user: UserId, + pub permissions: SharedInstanceUserPermissions, +} + +impl From for SharedInstanceUser { + fn from(user: DBSharedInstanceUser) -> Self { + SharedInstanceUser { + user: user.user_id.into(), + permissions: user.permissions, + } + } +} diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 107a9fba4..6e50d2475 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize}; use sha1::Digest; use sqlx::PgPool; use std::collections::HashMap; +use std::fmt::Write; use std::io::{Cursor, Read}; use std::time::Duration; use zip::ZipArchive; @@ -33,29 +34,31 @@ impl ModerationMessages { } pub fn markdown(&self, auto_mod: bool) -> String { - let mut str = "".to_string(); + let mut str = String::new(); for message in &self.messages { - str.push_str(&format!("## {}\n", message.header())); - str.push_str(&format!("{}\n", message.body())); - str.push('\n'); + write!(&mut str, "## {}\n{}\n\n", message.header(), message.body()) + .unwrap(); } for (version_num, messages) in &self.version_specific { for message in messages { - str.push_str(&format!( - "## Version {}: {}\n", + write!( + &mut str, + "### Version {}: {}\n{}\n\n", version_num, - message.header() - )); - str.push_str(&format!("{}\n", message.body())); - str.push('\n'); + message.header(), + message.body() + ) + .unwrap(); } } if auto_mod { - str.push_str("
\n\n"); - str.push_str("🤖 This is an automated message generated by AutoMod (BETA). If you are facing issues, please [contact support](https://support.modrinth.com)."); + str.push_str( + "
\n\n\ + 🤖 This is an automated message generated by AutoMod (BETA). If you are facing issues, please [contact support](https://support.modrinth.com)." + ); } str @@ -146,14 +149,13 @@ impl ModerationMessage { match self { ModerationMessage::NoPrimaryFile => "Please attach a file to this version. All files on Modrinth must have files associated with their versions.\n".to_string(), ModerationMessage::PackFilesNotAllowed { files, .. } => { - let mut str = "".to_string(); - str.push_str("This pack redistributes copyrighted material. Please refer to [Modrinth's guide on obtaining modpack permissions](https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions) for more information.\n\n"); + let mut str = String::from("This pack redistributes copyrighted material. Please refer to [Modrinth's guide on obtaining modpack permissions](https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions) for more information.\n\n"); let mut attribute_mods = Vec::new(); let mut no_mods = Vec::new(); let mut permanent_no_mods = Vec::new(); let mut unidentified_mods = Vec::new(); - for (_, approval) in files.iter() { + for approval in files.values() { match approval.status { ApprovalType::Yes | ApprovalType::WithAttributionAndSource => {} ApprovalType::WithAttribution => attribute_mods.push(&approval.file_name), @@ -166,7 +168,7 @@ impl ModerationMessage { fn print_mods(projects: Vec<&String>, headline: &str, val: &mut String) { if projects.is_empty() { return } - val.push_str(&format!("{headline}\n\n")); + write!(val, "{headline}\n\n").unwrap(); for project in &projects { let additional_text = if project.contains("ftb-quests") { @@ -181,11 +183,11 @@ impl ModerationMessage { None }; - val.push_str(&if let Some(additional_text) = additional_text { - format!("- {project} (consider using [{}](https://modrinth.com/project/{}) instead)\n", additional_text.0, additional_text.1) + if let Some(additional_text) = additional_text { + writeln!(val, "- {project} (consider using [{}](https://modrinth.com/project/{}) instead)", additional_text.0, additional_text.1).unwrap(); } else { - format!("- {project}\n") - }) + writeln!(val, "- {project}").unwrap(); + } } if !projects.is_empty() { @@ -205,7 +207,7 @@ Keep in mind that you should:\n - Set a featured image that best represents your pack. - Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description. - Upload any relevant images in your Description to your Gallery tab for best results.".to_string(), - ModerationMessage::MissingLicense => "You must select a License before your project can be published publicly, having a License associated with your project is important to protecting your rights and allowing others to use your content as you intend. For more information, you can see our [Guide to Licensing Mods]().".to_string(), + ModerationMessage::MissingLicense => "You must select a License before your project can be published publicly, having a License associated with your project is important to protecting your rights and allowing others to use your content as you intend. For more information, you can see our [Guide to Licensing Mods]().".to_string(), ModerationMessage::MissingCustomLicenseUrl { license } => format!("It looks like you've selected the License \"{license}\" without providing a valid License link. When using a custom License you must provide a link directly to the License in the License Link field."), ModerationMessage::NoSideTypes => "Your project's side types are currently set to Unknown on both sides. Please set accurate side types!".to_string(), } @@ -242,7 +244,7 @@ impl AutomatedModerationQueue { version_specific: HashMap::new(), }; - if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| ["server_only", "client_only", "client_and_server", "singleplayer"].contains(&&*x.field_name)) { + if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| x.field_name == "environment") { mod_messages.messages.push(ModerationMessage::NoSideTypes); } @@ -278,10 +280,7 @@ impl AutomatedModerationQueue { let mut zip = ZipArchive::new(reader)?; let pack: PackFormat = { - let mut file = - if let Ok(file) = zip.by_name("modrinth.index.json") { - file - } else { + let Ok(mut file) = zip.by_name("modrinth.index.json") else { continue; }; @@ -301,7 +300,7 @@ impl AutomatedModerationQueue { .files .clone() .into_iter() - .flat_map(|x| { + .filter_map(|x| { let hash = x.hashes.get(&PackFileHash::Sha1); if let Some(hash) = hash { @@ -397,8 +396,8 @@ impl AutomatedModerationQueue { ", serde_json::to_value(&MissingMetadata { identified: final_hashes, - flame_files: Default::default(), - unknown_files: Default::default(), + flame_files: HashMap::new(), + unknown_files: HashMap::new(), })?, primary_file.id.0 ) @@ -432,8 +431,8 @@ impl AutomatedModerationQueue { if hashes.is_empty() { let metadata = MissingMetadata { identified: final_hashes, - flame_files: Default::default(), - unknown_files: Default::default(), + flame_files: HashMap::new(), + unknown_files: HashMap::new(), }; sqlx::query!( @@ -533,8 +532,8 @@ impl AutomatedModerationQueue { if hashes.is_empty() { let metadata = MissingMetadata { identified: final_hashes, - flame_files: Default::default(), - unknown_files: Default::default(), + flame_files: HashMap::new(), + unknown_files: HashMap::new(), }; sqlx::query!( @@ -622,8 +621,7 @@ impl AutomatedModerationQueue { if !mod_messages.is_empty() { let first_time = database::models::DBThread::get(project.thread_id, &pool).await? - .map(|x| x.messages.iter().all(|x| x.author_id == Some(database::models::DBUserId(AUTOMOD_ID)) || x.hide_identity)) - .unwrap_or(true); + .is_none_or(|x| x.messages.iter().all(|x| x.author_id == Some(database::models::DBUserId(AUTOMOD_ID)) || x.hide_identity)); let mut transaction = pool.begin().await?; let id = ThreadMessageBuilder { @@ -734,10 +732,12 @@ impl AutomatedModerationQueue { if let Err(err) = res { let err = err.as_api_error(); - let mut str = String::new(); - str.push_str("## Internal AutoMod Error\n\n"); - str.push_str(&format!("Error code: {}\n\n", err.error)); - str.push_str(&format!("Error description: {}\n\n", err.description)); + let str = format!( + "## Internal AutoMod Error\n\n\ + Error code: {}\n\n\ + Error description: {}\n\n", + err.error, err.description + ); let mut transaction = pool.begin().await?; ThreadMessageBuilder { diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs index ccc51a6da..740e91dd5 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts.rs @@ -5,7 +5,7 @@ use crate::models::payouts::{ use crate::models::projects::MonetizationStatus; use crate::routes::ApiError; use base64::Engine; -use chrono::{DateTime, Datelike, Duration, TimeZone, Utc}; +use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; use dashmap::DashMap; use futures::TryStreamExt; use reqwest::Method; @@ -441,8 +441,8 @@ impl PayoutsQueue { } } else { PayoutMethodFee { - percentage: Default::default(), - min: Default::default(), + percentage: Decimal::default(), + min: Decimal::default(), max: None, } }, @@ -833,7 +833,7 @@ pub async fn process_payout( .map(|x| (x.project_id, x.page_views)) .collect::>(); - for (key, value) in downloads_values.iter() { + for (key, value) in &downloads_values { let counter = views_values.entry(*key).or_insert(0); *counter += *value; } @@ -1072,3 +1072,63 @@ pub async fn insert_payouts( .execute(&mut **transaction) .await } + +pub async fn insert_bank_balances( + payouts: &PayoutsQueue, + pool: &PgPool, +) -> Result<(), ApiError> { + let mut transaction = pool.begin().await?; + + let (paypal, brex, tremendous) = futures::future::try_join3( + PayoutsQueue::get_paypal_balance(), + PayoutsQueue::get_brex_balance(), + payouts.get_tremendous_balance(), + ) + .await?; + + let mut insert_account_types = Vec::new(); + let mut insert_amounts = Vec::new(); + let mut insert_pending = Vec::new(); + let mut insert_recorded = Vec::new(); + + let now = Utc::now(); + let today = now.date_naive().and_time(NaiveTime::MIN).and_utc(); + + let mut add_balance = + |account_type: &str, balance: Option| { + if let Some(balance) = balance { + insert_account_types.push(account_type.to_string()); + insert_amounts.push(balance.available); + insert_pending.push(false); + insert_recorded.push(today); + + insert_account_types.push(account_type.to_string()); + insert_amounts.push(balance.pending); + insert_pending.push(true); + insert_recorded.push(today); + } + }; + + add_balance("paypal", paypal); + add_balance("brex", brex); + add_balance("tremendous", tremendous); + + sqlx::query!( + " + INSERT INTO payout_sources_balance (account_type, amount, pending, recorded) + SELECT * FROM UNNEST ($1::text[], $2::numeric[], $3::boolean[], $4::timestamptz[]) + ON CONFLICT (recorded, account_type, pending) + DO UPDATE SET amount = EXCLUDED.amount + ", + &insert_account_types[..], + &insert_amounts[..], + &insert_pending[..], + &insert_recorded[..], + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(()) +} diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs index c5667798d..5f4fd5a5a 100644 --- a/apps/labrinth/src/routes/analytics.rs +++ b/apps/labrinth/src/routes/analytics.rs @@ -216,7 +216,7 @@ pub async fn playtime_ingest( version_id: version.inner.id.0 as u64, loader: playtime.loader, game_version: playtime.game_version, - parent: playtime.parent.map(|x| x.0).unwrap_or(0), + parent: playtime.parent.map_or(0, |x| x.0), }); } } diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs index 817803e94..3be7e013f 100644 --- a/apps/labrinth/src/routes/internal/admin.rs +++ b/apps/labrinth/src/routes/internal/admin.rs @@ -8,16 +8,16 @@ use crate::models::threads::MessageBody; use crate::queue::analytics::AnalyticsQueue; use crate::queue::maxmind::MaxMindIndexer; use crate::queue::moderation::AUTOMOD_ID; -use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::SearchConfig; use crate::util::date::get_current_tenths_of_ms; use crate::util::guards::admin_key_guard; -use actix_web::{HttpRequest, HttpResponse, get, patch, post, web}; +use actix_web::{HttpRequest, HttpResponse, patch, post, web}; use serde::Deserialize; use sqlx::PgPool; use std::collections::HashMap; +use std::fmt::Write; use std::net::Ipv4Addr; use std::sync::Arc; use tracing::info; @@ -27,7 +27,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("admin") .service(count_download) .service(force_reindex) - .service(get_balances) .service(delphi_result_ingest), ); } @@ -165,24 +164,6 @@ pub async fn force_reindex( Ok(HttpResponse::NoContent().finish()) } -#[get("/_balances", guard = "admin_key_guard")] -pub async fn get_balances( - payouts: web::Data, -) -> Result { - let (paypal, brex, tremendous) = futures::future::try_join3( - PayoutsQueue::get_paypal_balance(), - PayoutsQueue::get_brex_balance(), - payouts.get_tremendous_balance(), - ) - .await?; - - Ok(HttpResponse::Ok().json(serde_json::json!({ - "paypal": paypal, - "brex": brex, - "tremendous": tremendous, - }))) -} - #[derive(Deserialize)] pub struct DelphiIngest { pub url: String, @@ -221,9 +202,11 @@ pub async fn delphi_result_ingest( for (issue, trace) in &body.issues { for (path, code) in trace { - header.push_str(&format!( + write!( + &mut header, "\n issue {issue} found at file {path}: \n ```\n{code}\n```" - )); + ) + .unwrap(); } } @@ -244,12 +227,15 @@ pub async fn delphi_result_ingest( for (issue, trace) in &body.issues { for path in trace.keys() { - thread_header - .push_str(&format!("\n\n- issue {issue} found at file {path}")); + write!( + &mut thread_header, + "\n\n- issue {issue} found at file {path}" + ) + .unwrap(); } if trace.is_empty() { - thread_header.push_str(&format!("\n\n- issue {issue} found",)); + write!(&mut thread_header, "\n\n- issue {issue} found").unwrap(); } } diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index b82c4f72c..a5fcbc176 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -457,20 +457,16 @@ pub async fn edit_subscription( ) })?; - // Plan downgrade, update future charge - if current_amount > amount { + // First branch: Plan downgrade, update future charge + // Second branch: For small transactions (under 30 cents), we make a loss on the + // proration due to fees. In these situations, just give it to them for free, because + // their next charge will be in a day or two anyway. + if current_amount > amount || proration < 30 { open_charge.price_id = product_price.id; open_charge.amount = amount as i64; None } else { - // For small transactions (under 30 cents), we make a loss on the proration due to fees - if proration < 30 { - return Err(ApiError::InvalidInput( - "Proration is too small!".to_string(), - )); - } - let charge_id = generate_charge_id(&mut transaction).await?; let customer_id = get_or_create_customer( @@ -531,11 +527,9 @@ pub async fn edit_subscription( if let Some(payment_method) = &edit_subscription.payment_method { - let payment_method_id = if let Ok(id) = + let Ok(payment_method_id) = PaymentMethodId::from_str(payment_method) - { - id - } else { + else { return Err(ApiError::InvalidInput( "Invalid payment method id".to_string(), )); @@ -743,9 +737,7 @@ pub async fn edit_payment_method( let (id,) = info.into_inner(); - let payment_method_id = if let Ok(id) = PaymentMethodId::from_str(&id) { - id - } else { + let Ok(payment_method_id) = PaymentMethodId::from_str(&id) else { return Err(ApiError::NotFound); }; @@ -766,10 +758,7 @@ pub async fn edit_payment_method( ) .await?; - if payment_method - .customer - .map(|x| x.id() == customer) - .unwrap_or(false) + if payment_method.customer.is_some_and(|x| x.id() == customer) || user.role.is_admin() { stripe::Customer::update( @@ -812,9 +801,7 @@ pub async fn remove_payment_method( let (id,) = info.into_inner(); - let payment_method_id = if let Ok(id) = PaymentMethodId::from_str(&id) { - id - } else { + let Ok(payment_method_id) = PaymentMethodId::from_str(&id) else { return Err(ApiError::NotFound); }; @@ -864,10 +851,7 @@ pub async fn remove_payment_method( } } - if payment_method - .customer - .map(|x| x.id() == customer) - .unwrap_or(false) + if payment_method.customer.is_some_and(|x| x.id() == customer) || user.role.is_admin() { stripe::PaymentMethod::detach(&stripe_client, &payment_method_id) @@ -1437,8 +1421,6 @@ pub async fn stripe_webhook( pub user_subscription_item: Option, pub payment_metadata: Option, - #[allow(dead_code)] - pub charge_type: ChargeType, } #[allow(clippy::too_many_arguments)] @@ -1453,24 +1435,20 @@ pub async fn stripe_webhook( transaction: &mut Transaction<'_, Postgres>, ) -> Result { 'metadata: { - let user_id = if let Some(user_id) = metadata + let Some(user_id) = metadata .get("modrinth_user_id") .and_then(|x| parse_base62(x).ok()) .map(|x| crate::database::models::ids::DBUserId(x as i64)) - { - user_id - } else { + else { break 'metadata; }; - let user = if let Some(user) = + let Some(user) = crate::database::models::user_item::DBUser::get_id( user_id, pool, redis, ) .await? - { - user - } else { + else { break 'metadata; }; @@ -1478,22 +1456,20 @@ pub async fn stripe_webhook( .get("modrinth_payment_metadata") .and_then(|x| serde_json::from_str(x).ok()); - let charge_id = if let Some(charge_id) = metadata + let Some(charge_id) = metadata .get("modrinth_charge_id") .and_then(|x| parse_base62(x).ok()) - .map(|x| crate::database::models::ids::DBChargeId(x as i64)) - { - charge_id - } else { + .map(|x| { + crate::database::models::ids::DBChargeId(x as i64) + }) + else { break 'metadata; }; - let charge_type = if let Some(charge_type) = metadata + let Some(charge_type) = metadata .get("modrinth_charge_type") .map(|x| ChargeType::from_string(x)) - { - charge_type - } else { + else { break 'metadata; }; @@ -1505,21 +1481,19 @@ pub async fn stripe_webhook( ) .await? { - let price = if let Some(price) = - product_item::DBProductPrice::get(charge.price_id, pool) - .await? - { - price - } else { + let Some(price) = product_item::DBProductPrice::get( + charge.price_id, + pool, + ) + .await? + else { break 'metadata; }; - let product = if let Some(product) = + let Some(product) = product_item::DBProduct::get(price.product_id, pool) .await? - { - product - } else { + else { break 'metadata; }; @@ -1530,15 +1504,13 @@ pub async fn stripe_webhook( charge.upsert(transaction).await?; if let Some(subscription_id) = charge.subscription_id { - let mut subscription = if let Some(subscription) = + let Some(mut subscription) = user_subscription_item::DBUserSubscription::get( subscription_id, pool, ) .await? - { - subscription - } else { + else { break 'metadata; }; @@ -1567,58 +1539,49 @@ pub async fn stripe_webhook( (charge, price, product, None) } } else { - let price_id = if let Some(price_id) = metadata + let Some(price_id) = metadata .get("modrinth_price_id") .and_then(|x| parse_base62(x).ok()) .map(|x| { crate::database::models::ids::DBProductPriceId( x as i64, ) - }) { - price_id - } else { + }) + else { break 'metadata; }; - let price = if let Some(price) = + let Some(price) = product_item::DBProductPrice::get(price_id, pool) .await? - { - price - } else { + else { break 'metadata; }; - let product = if let Some(product) = + let Some(product) = product_item::DBProduct::get(price.product_id, pool) .await? - { - product - } else { + else { break 'metadata; }; let subscription = match &price.prices { Price::OneTime { .. } => None, Price::Recurring { intervals } => { - let interval = if let Some(interval) = metadata + let Some(interval) = metadata .get("modrinth_subscription_interval") .map(|x| PriceDuration::from_string(x)) - { - interval - } else { + else { break 'metadata; }; if intervals.get(&interval).is_some() { - let subscription_id = if let Some(subscription_id) = metadata + let Some(subscription_id) = metadata .get("modrinth_subscription_id") .and_then(|x| parse_base62(x).ok()) .map(|x| { crate::database::models::ids::DBUserSubscriptionId(x as i64) - }) { - subscription_id - } else { + }) else { break 'metadata; }; @@ -1687,7 +1650,6 @@ pub async fn stripe_webhook( charge_item: charge, user_subscription_item: subscription, payment_metadata, - charge_type, }); } @@ -2049,10 +2011,9 @@ pub async fn stripe_webhook( ) .await?; - if !customer + if customer .invoice_settings - .map(|x| x.default_payment_method.is_some()) - .unwrap_or(false) + .is_none_or(|x| x.default_payment_method.is_none()) { stripe::Customer::update( &stripe_client, @@ -2187,12 +2148,10 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { .await?; for charge in all_charges { - let subscription = if let Some(subscription) = all_subscriptions + let Some(subscription) = all_subscriptions .iter_mut() .find(|x| Some(x.id) == charge.subscription_id) - { - subscription - } else { + else { continue; }; @@ -2200,29 +2159,23 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { continue; } - let product_price = if let Some(product_price) = subscription_prices + let Some(product_price) = subscription_prices .iter() .find(|x| x.id == subscription.price_id) - { - product_price - } else { + else { continue; }; - let product = if let Some(product) = subscription_products + let Some(product) = subscription_products .iter() .find(|x| x.id == product_price.product_id) - { - product - } else { + else { continue; }; - let user = if let Some(user) = + let Some(user) = users.iter().find(|x| x.id == subscription.user_id) - { - user - } else { + else { continue; }; @@ -2350,19 +2303,14 @@ pub async fn index_billing( .await?; for mut charge in charges_to_do { - let product_price = if let Some(price) = + let Some(product_price) = prices.iter().find(|x| x.id == charge.price_id) - { - price - } else { + else { continue; }; - let user = if let Some(user) = - users.iter().find(|x| x.id == charge.user_id) - { - user - } else { + let Some(user) = users.iter().find(|x| x.id == charge.user_id) + else { continue; }; @@ -2399,17 +2347,14 @@ pub async fn index_billing( ) .await?; - let currency = match Currency::from_str( + let Ok(currency) = Currency::from_str( &product_price.currency_code.to_lowercase(), - ) { - Ok(x) => x, - Err(_) => { - warn!( - "Could not find currency for {}", - product_price.currency_code - ); - continue; - } + ) else { + warn!( + "Could not find currency for {}", + product_price.currency_code + ); + continue; }; let mut intent = diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 1117c9516..e8d97a65a 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -4,7 +4,7 @@ use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers}; use crate::database::models::DBUser; use crate::database::models::flow_item::DBFlow; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::pats::Scopes; use crate::models::users::{Badges, Role}; use crate::queue::session::AuthQueue; @@ -51,7 +51,8 @@ pub fn config(cfg: &mut ServiceConfig) { .service(resend_verify_email) .service(set_email) .service(verify_email) - .service(subscribe_newsletter), + .service(subscribe_newsletter) + .service(get_newsletter_subscription_status), ); } @@ -136,6 +137,7 @@ impl TempUser { let upload_result = upload_image_optimized( &format!("user/{}", ariadne::ids::UserId::from(user_id)), + FileHostPublicity::Public, bytes, ext, Some(96), @@ -1249,7 +1251,7 @@ pub async fn delete_auth_provider( .await? .1; - if !user.auth_providers.map(|x| x.len() > 1).unwrap_or(false) + if user.auth_providers.is_none_or(|x| x.len() <= 1) && !user.has_password.unwrap_or(false) { return Err(ApiError::InvalidInput( @@ -1320,6 +1322,37 @@ pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> { Ok(()) } +pub async fn check_sendy_subscription( + email: &str, +) -> Result { + let url = dotenvy::var("SENDY_URL")?; + let id = dotenvy::var("SENDY_LIST_ID")?; + let api_key = dotenvy::var("SENDY_API_KEY")?; + + if url.is_empty() || url == "none" { + tracing::info!( + "Sendy URL not set, returning false for subscription check" + ); + return Ok(false); + } + + let mut form = HashMap::new(); + form.insert("api_key", &*api_key); + form.insert("email", email); + form.insert("list_id", &*id); + + let client = reqwest::Client::new(); + let response = client + .post(format!("{url}/api/subscribers/subscription-status.php")) + .form(&form) + .send() + .await? + .text() + .await?; + + Ok(response.trim() == "Subscribed") +} + #[derive(Deserialize, Validate)] pub struct NewAccount { #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] @@ -2030,7 +2063,9 @@ pub async fn change_password( Some(user) } else { - None + return Err(ApiError::CustomAuthentication( + "The password change flow code is invalid or has expired. Did you copy it promptly and correctly?".to_string(), + )); } } else { None @@ -2384,6 +2419,35 @@ pub async fn subscribe_newsletter( } } +#[get("email/subscribe")] +pub async fn get_newsletter_subscription_status( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_READ, + ) + .await? + .1; + + if let Some(email) = user.email { + let is_subscribed = check_sendy_subscription(&email).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "subscribed": is_subscribed + }))) + } else { + Ok(HttpResponse::Ok().json(serde_json::json!({ + "subscribed": false + }))) + } +} + fn send_email_verify( email: String, flow: String, diff --git a/apps/labrinth/src/routes/internal/statuses.rs b/apps/labrinth/src/routes/internal/statuses.rs index 0f4efae44..7be8d40ea 100644 --- a/apps/labrinth/src/routes/internal/statuses.rs +++ b/apps/labrinth/src/routes/internal/statuses.rs @@ -217,8 +217,7 @@ pub async fn ws_init( if status .profile_name .as_ref() - .map(|x| x.len() > 64) - .unwrap_or(false) + .is_some_and(|x| x.len() > 64) { return; } diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 937971708..878f6dabc 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -250,7 +250,7 @@ fn find_file<'a>( // Minecraft mods are not going to be both a mod and a modpack, so this minecraft-specific handling is fine // As there can be multiple project types, returns the first allowable match let mut fileexts = vec![]; - for project_type in version.project_types.iter() { + for project_type in &version.project_types { match project_type.as_str() { "mod" => fileexts.push("jar"), "modpack" => fileexts.push("mrpack"), @@ -381,8 +381,10 @@ pub async fn version_file_sha1( Ok(find_file(&project_id, &vnum, &version, &file) .and_then(|file| file.hashes.get("sha1")) - .map(|hash_str| HttpResponse::Ok().body(hash_str.clone())) - .unwrap_or_else(|| HttpResponse::NotFound().body(""))) + .map_or_else( + || HttpResponse::NotFound().body(""), + |hash_str| HttpResponse::Ok().body(hash_str.clone()), + )) } #[get("maven/modrinth/{id}/{versionnum}/{file}.sha512")] @@ -426,6 +428,8 @@ pub async fn version_file_sha512( Ok(find_file(&project_id, &vnum, &version, &file) .and_then(|file| file.hashes.get("sha512")) - .map(|hash_str| HttpResponse::Ok().body(hash_str.clone())) - .unwrap_or_else(|| HttpResponse::NotFound().body(""))) + .map_or_else( + || HttpResponse::NotFound().body(""), + |hash_str| HttpResponse::Ok().body(hash_str.clone()), + )) } diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs index 540b3a6e1..9a4562430 100644 --- a/apps/labrinth/src/routes/v2/project_creation.rs +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -158,10 +158,12 @@ pub async fn project_create( .into_iter() .map(|v| { let mut fields = HashMap::new(); - fields.extend(v2_reroute::convert_side_types_v3( - client_side, - server_side, - )); + fields.extend( + v2_reroute::convert_v2_side_types_to_v3_side_types( + client_side, + server_side, + ), + ); fields.insert( "game_versions".to_string(), json!(v.game_versions), diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index a1275660d..8bf75fb41 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -84,7 +84,7 @@ pub async fn project_search( val ) } else { - facet.to_string() + facet } }) .collect::>() @@ -558,6 +558,7 @@ pub async fn project_edit( moderation_message: v2_new_project.moderation_message, moderation_message_body: v2_new_project.moderation_message_body, monetization_status: v2_new_project.monetization_status, + side_types_migration_review_status: None, // Not to be exposed in v2 }; // This returns 204 or failure so we don't need to do anything with it @@ -594,10 +595,12 @@ pub async fn project_edit( let version = Version::from(version); let mut fields = version.fields; let (current_client_side, current_server_side) = - v2_reroute::convert_side_types_v2(&fields, None); + v2_reroute::convert_v3_side_types_to_v2_side_types( + &fields, None, + ); let client_side = client_side.unwrap_or(current_client_side); let server_side = server_side.unwrap_or(current_server_side); - fields.extend(v2_reroute::convert_side_types_v3( + fields.extend(v2_reroute::convert_v2_side_types_to_v3_side_types( client_side, server_side, )); diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs index 093f1736b..9406ce556 100644 --- a/apps/labrinth/src/routes/v2/version_creation.rs +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -105,7 +105,7 @@ pub async fn version_create( json!(legacy_create.game_versions), ); - // Get all possible side-types for loaders given- we will use these to check if we need to convert/apply singleplayer, etc. + // Get all possible side-types for loaders given- we will use these to check if we need to convert/apply side types let loaders = match v3::tags::loader_list(client.clone(), redis.clone()) .await @@ -136,53 +136,32 @@ pub async fn version_create( .collect::>(); // Copies side types of another version of the project. - // If no version exists, defaults to all false. + // If no version exists, defaults to an unknown side type. // This is inherently lossy, but not much can be done about it, as side types are no longer associated with projects, - // so the 'missing' ones can't be easily accessed, and versions do need to have these fields explicitly set. - let side_type_loader_field_names = [ - "singleplayer", - "client_and_server", - "client_only", - "server_only", - ]; + // so the 'missing' ones can't be easily accessed, and versions do need to have that field explicitly set. - // Check if loader_fields_aggregate contains any of these side types + // Check if loader_fields_aggregate contains the side types // We assume these four fields are linked together. if loader_fields_aggregate .iter() - .any(|f| side_type_loader_field_names.contains(&f.as_str())) + .any(|field| field == "environment") { - // If so, we get the fields of the example version of the project, and set the side types to match. - fields.extend( - side_type_loader_field_names - .iter() - .map(|f| (f.to_string(), json!(false))), - ); - if let Some(example_version_fields) = + // If so, we get the field of an example version of the project, and set the side types to match. + fields.insert( + "environment".into(), get_example_version_fields( legacy_create.project_id, client, &redis, ) .await? - { - fields.extend( - example_version_fields.into_iter().filter_map( - |f| { - if side_type_loader_field_names - .contains(&f.field_name.as_str()) - { - Some(( - f.field_name, - f.value.serialize_internal(), - )) - } else { - None - } - }, - ), - ); - } + .into_iter() + .flatten() + .find(|f| f.field_name == "environment") + .map_or(json!("unknown"), |f| { + f.value.serialize_internal() + }), + ); } // Handle project type via file extension prediction let mut project_type = None; @@ -283,28 +262,23 @@ async fn get_example_version_fields( pool: Data, redis: &RedisPool, ) -> Result>, CreateError> { - let project_id = match project_id { - Some(project_id) => project_id, - None => return Ok(None), + let Some(project_id) = project_id else { + return Ok(None); }; - let vid = match project_item::DBProject::get_id( - project_id.into(), - &**pool, - redis, - ) - .await? - .and_then(|p| p.versions.first().cloned()) - { - Some(vid) => vid, - None => return Ok(None), + let Some(vid) = + project_item::DBProject::get_id(project_id.into(), &**pool, redis) + .await? + .and_then(|p| p.versions.first().copied()) + else { + return Ok(None); }; - let example_version = - match version_item::DBVersion::get(vid, &**pool, redis).await? { - Some(version) => version, - None => return Ok(None), - }; + let Some(example_version) = + version_item::DBVersion::get(vid, &**pool, redis).await? + else { + return Ok(None); + }; Ok(Some(example_version.version_fields)) } diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs index b6a193757..0a99b0796 100644 --- a/apps/labrinth/src/routes/v2_reroute.rs +++ b/apps/labrinth/src/routes/v2_reroute.rs @@ -164,69 +164,46 @@ where Ok(new_multipart) } -// Converts a "client_side" and "server_side" pair into the new v3 corresponding fields -pub fn convert_side_types_v3( +/// Converts V2 side types to V3 side types. +pub fn convert_v2_side_types_to_v3_side_types( client_side: LegacySideType, server_side: LegacySideType, ) -> HashMap { - use LegacySideType::{Optional, Required}; + use LegacySideType::{Optional, Required, Unsupported}; - let singleplayer = client_side == Required - || client_side == Optional - || server_side == Required - || server_side == Optional; - let client_and_server = singleplayer; - let client_only = (client_side == Required || client_side == Optional) - && server_side != Required; - let server_only = (server_side == Required || server_side == Optional) - && client_side != Required; + let environment = match (client_side, server_side) { + (Required, Required) => "client_and_server", // Or "singleplayer_only" + (Required, Unsupported) => "client_only", + (Required, Optional) => "client_only_server_optional", + (Unsupported, Required) => "server_only", // Or "dedicated_server_only" + (Optional, Required) => "server_only_client_optional", + (Optional, Optional) => "client_or_server", // Or "client_or_server_prefers_both" + _ => "unknown", + }; - let mut fields = HashMap::new(); - fields.insert("singleplayer".to_string(), json!(singleplayer)); - fields.insert("client_and_server".to_string(), json!(client_and_server)); - fields.insert("client_only".to_string(), json!(client_only)); - fields.insert("server_only".to_string(), json!(server_only)); - fields + [("environment".to_string(), json!(environment))] + .into_iter() + .collect() } -// Convert search facets from V3 back to v2 -// this is not lossless. (See tests) -pub fn convert_side_types_v2( +/// Converts a V3 side types map into the corresponding V2 side types. +pub fn convert_v3_side_types_to_v2_side_types( side_types: &HashMap, project_type: Option<&str>, ) -> (LegacySideType, LegacySideType) { - let client_and_server = side_types - .get("client_and_server") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - let singleplayer = side_types - .get("singleplayer") - .and_then(|x| x.as_bool()) - .unwrap_or(client_and_server); - let client_only = side_types - .get("client_only") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - let server_only = side_types - .get("server_only") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - - convert_side_types_v2_bools( - Some(singleplayer), - client_only, - server_only, - Some(client_and_server), + convert_v3_environment_to_v2_side_types( + side_types + .get("environment") + .and_then(|x| x.as_str()) + .unwrap_or("unknown"), project_type, ) } -// Client side, server side -pub fn convert_side_types_v2_bools( - singleplayer: Option, - client_only: bool, - server_only: bool, - client_and_server: Option, +/// Converts a V3 environment and project type into the corresponding V2 side types. +/// The first side type is for the client, the second is for the server. +pub fn convert_v3_environment_to_v2_side_types( + environment: &str, project_type: Option<&str>, ) -> (LegacySideType, LegacySideType) { use LegacySideType::{Optional, Required, Unknown, Unsupported}; @@ -236,30 +213,18 @@ pub fn convert_side_types_v2_bools( Some("datapack") => (Optional, Required), Some("shader") => (Required, Unsupported), Some("resourcepack") => (Required, Unsupported), - _ => { - let singleplayer = - singleplayer.or(client_and_server).unwrap_or(false); - - match (singleplayer, client_only, server_only) { - // Only singleplayer - (true, false, false) => (Required, Required), - - // Client only and not server only - (false, true, false) => (Required, Unsupported), - (true, true, false) => (Required, Unsupported), - - // Server only and not client only - (false, false, true) => (Unsupported, Required), - (true, false, true) => (Unsupported, Required), - - // Both server only and client only - (true, true, true) => (Optional, Optional), - (false, true, true) => (Optional, Optional), - - // Bad type - (false, false, false) => (Unknown, Unknown), - } - } + _ => match environment { + "client_and_server" => (Required, Required), + "client_only" => (Required, Unsupported), + "client_only_server_optional" => (Required, Optional), + "singleplayer_only" => (Required, Required), + "server_only" => (Unsupported, Required), + "server_only_client_optional" => (Optional, Required), + "dedicated_server_only" => (Unsupported, Required), + "client_or_server" => (Optional, Optional), + "client_or_server_prefers_both" => (Optional, Optional), + _ => (Unknown, Unknown), // "unknown" + }, } } @@ -279,13 +244,14 @@ mod tests { }; #[test] - fn convert_types() { - // Converting types from V2 to V3 and back should be idempotent- for certain pairs + fn v2_v3_side_type_conversion() { + // Only nonsensical V2 side types cannot be round-tripped from V2 to V3 and back. + // When converting from V3 to V2, only additional information about the + // singleplayer-only, multiplayer-only, or install on both sides nature of the + // project is lost. let lossy_pairs = [ (Optional, Unsupported), (Unsupported, Optional), - (Required, Optional), - (Optional, Required), (Unsupported, Unsupported), ]; @@ -294,10 +260,13 @@ mod tests { if lossy_pairs.contains(&(client_side, server_side)) { continue; } - let side_types = - convert_side_types_v3(client_side, server_side); + let side_types = convert_v2_side_types_to_v3_side_types( + client_side, + server_side, + ); let (client_side2, server_side2) = - convert_side_types_v2(&side_types, None); + convert_v3_side_types_to_v2_side_types(&side_types, None); + assert_eq!(client_side, client_side2); assert_eq!(server_side, server_side2); } diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index 071cefadd..6f795de95 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -4,7 +4,7 @@ use crate::database::models::{ collection_item, generate_collection_id, project_item, }; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::collections::{Collection, CollectionStatus}; use crate::models::ids::{CollectionId, ProjectId}; use crate::models::pats::Scopes; @@ -12,7 +12,7 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::routes::v3::project_creation::CreateError; use crate::util::img::delete_old_images; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::web::Data; @@ -413,11 +413,12 @@ pub async fn collection_icon_edit( delete_old_images( collection_item.icon_url, collection_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -427,6 +428,7 @@ pub async fn collection_icon_edit( let collection_id: CollectionId = collection_item.id.into(); let upload_result = crate::util::img::upload_image_optimized( &format!("data/{collection_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -493,6 +495,7 @@ pub async fn delete_collection_icon( delete_old_images( collection_item.icon_url, collection_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs index 90e54cbb8..93669a1aa 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -8,13 +8,13 @@ use crate::database::models::{ project_item, report_item, thread_item, version_item, }; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::{ReportId, ThreadMessageId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::img::upload_image_optimized; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use actix_web::{HttpRequest, HttpResponse, web}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -176,7 +176,7 @@ pub async fn images_add( } // Upload the image to the file host - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 1_048_576, "Icons must be smaller than 1MiB", @@ -186,6 +186,7 @@ pub async fn images_add( let content_length = bytes.len(); let upload_result = upload_image_optimized( "data/cached_images", + FileHostPublicity::Public, // FIXME: Maybe use private images for threads bytes.freeze(), &data.ext, None, diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 336a773f1..9b5040a9f 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -13,6 +13,8 @@ pub mod payouts; pub mod project_creation; pub mod projects; pub mod reports; +pub mod shared_instance_version_creation; +pub mod shared_instances; pub mod statistics; pub mod tags; pub mod teams; @@ -36,6 +38,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(project_creation::config) .configure(projects::config) .configure(reports::config) + .configure(shared_instance_version_creation::config) + .configure(shared_instances::config) .configure(statistics::config) .configure(tags::config) .configure(teams::config) diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs index b0fd6406f..e738c7e52 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -1,6 +1,9 @@ use std::{collections::HashSet, fmt::Display, sync::Arc}; use super::ApiError; +use crate::file_hosting::FileHostPublicity; +use crate::models::ids::OAuthClientId; +use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::{ auth::{checks::ValidateAuthorized, get_user_from_headers}, database::{ @@ -23,7 +26,7 @@ use crate::{ }; use crate::{ file_hosting::FileHost, models::oauth_clients::DeleteOAuthClientQueryParam, - util::routes::read_from_payload, + util::routes::read_limited_from_payload, }; use actix_web::{ HttpRequest, HttpResponse, delete, get, patch, post, @@ -38,9 +41,6 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; -use crate::models::ids::OAuthClientId; -use crate::util::img::{delete_old_images, upload_image_optimized}; - pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( scope("oauth") @@ -381,11 +381,12 @@ pub async fn oauth_client_icon_edit( delete_old_images( client.icon_url.clone(), client.raw_icon_url.clone(), + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -393,6 +394,7 @@ pub async fn oauth_client_icon_edit( .await?; let upload_result = upload_image_optimized( &format!("data/{client_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -447,6 +449,7 @@ pub async fn oauth_client_icon_delete( delete_old_images( client.icon_url.clone(), client.raw_icon_url.clone(), + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 2a6388f92..f943ccd7c 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -8,14 +8,14 @@ use crate::database::models::{ DBOrganization, generate_organization_id, team_item, }; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::OrganizationId; use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::util::img::delete_old_images; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::{HttpRequest, HttpResponse, web}; @@ -256,13 +256,11 @@ pub async fn organization_get( .filter(|x| { logged_in || x.accepted - || user_id - .map(|y: crate::database::models::DBUserId| { - y == x.user_id - }) - .unwrap_or(false) + || user_id.is_some_and( + |y: crate::database::models::DBUserId| y == x.user_id, + ) }) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, @@ -345,13 +343,11 @@ pub async fn organizations_get( .filter(|x| { logged_in || x.accepted - || user_id - .map(|y: crate::database::models::DBUserId| { - y == x.user_id - }) - .unwrap_or(false) + || user_id.is_some_and( + |y: crate::database::models::DBUserId| y == x.user_id, + ) }) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, @@ -635,7 +631,7 @@ pub async fn organization_delete( .try_collect::>() .await?; - for organization_project_team in organization_project_teams.iter() { + for organization_project_team in &organization_project_teams { let new_id = crate::database::models::ids::generate_team_member_id( &mut transaction, ) @@ -671,8 +667,13 @@ pub async fn organization_delete( ) .await?; - for team_id in organization_project_teams { - database::models::DBTeamMember::clear_cache(team_id, &redis).await?; + for team_id in &organization_project_teams { + database::models::DBTeamMember::clear_cache(*team_id, &redis).await?; + } + + if !organization_project_teams.is_empty() { + database::models::DBUser::clear_project_cache(&[owner_id], &redis) + .await?; } if result.is_some() { @@ -1094,11 +1095,12 @@ pub async fn organization_icon_edit( delete_old_images( organization_item.icon_url, organization_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -1108,6 +1110,7 @@ pub async fn organization_icon_edit( let organization_id: OrganizationId = organization_item.id.into(); let upload_result = crate::util::img::upload_image_optimized( &format!("data/{organization_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -1197,6 +1200,7 @@ pub async fn delete_organization_icon( delete_old_images( organization_item.icon_url, organization_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 84c7ae309..1dca7939d 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -5,11 +5,11 @@ use crate::database::redis::RedisPool; use crate::models::ids::PayoutId; use crate::models::pats::Scopes; use crate::models::payouts::{PayoutMethodType, PayoutStatus}; -use crate::queue::payouts::{PayoutsQueue, make_aditude_request}; +use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; -use chrono::{DateTime, Datelike, Duration, TimeZone, Utc, Weekday}; +use chrono::{DateTime, Duration, Utc}; use hex::ToHex; use hmac::{Hmac, Mac}; use reqwest::Method; @@ -830,14 +830,13 @@ async fn get_user_balance( .fetch_optional(pool) .await?; - let (withdrawn, fees) = withdrawn - .map(|x| { + let (withdrawn, fees) = + withdrawn.map_or((Decimal::ZERO, Decimal::ZERO), |x| { ( x.amount.unwrap_or(Decimal::ZERO), x.fee.unwrap_or(Decimal::ZERO), ) - }) - .unwrap_or((Decimal::ZERO, Decimal::ZERO)); + }); Ok(UserBalance { available: available.round_dp(16) @@ -851,9 +850,16 @@ async fn get_user_balance( }) } +#[derive(Deserialize)] +pub struct RevenueQuery { + pub start: Option>, + pub end: Option>, +} + #[derive(Serialize, Deserialize)] pub struct RevenueResponse { pub all_time: Decimal, + pub all_time_available: Decimal, pub data: Vec, } @@ -866,21 +872,9 @@ pub struct RevenueData { #[get("platform_revenue")] pub async fn platform_revenue( + query: web::Query, pool: web::Data, - redis: web::Data, ) -> Result { - let mut redis = redis.connect().await?; - - const PLATFORM_REVENUE_NAMESPACE: &str = "platform_revenue"; - - let res: Option = redis - .get_deserialized_from_json(PLATFORM_REVENUE_NAMESPACE, "0") - .await?; - - if let Some(res) = res { - return Ok(HttpResponse::Ok().json(res)); - } - let all_time_payouts = sqlx::query!( " SELECT SUM(amount) from payouts_values @@ -891,111 +885,47 @@ pub async fn platform_revenue( .and_then(|x| x.sum) .unwrap_or(Decimal::ZERO); - let points = make_aditude_request( - &["METRIC_REVENUE", "METRIC_IMPRESSIONS"], - "30d", - "1d", + let all_available = sqlx::query!( + " + SELECT SUM(amount) from payouts_values WHERE date_available <= NOW() + ", ) - .await?; + .fetch_optional(&**pool) + .await? + .and_then(|x| x.sum) + .unwrap_or(Decimal::ZERO); - let mut points_map = HashMap::new(); + let utc = Utc::now(); + let start = query.start.unwrap_or(utc - Duration::days(30)); + let end = query.end.unwrap_or(utc); - for point in points { - for point in point.points_list { - let entry = - points_map.entry(point.time.seconds).or_insert((None, None)); - - if let Some(revenue) = point.metric.revenue { - entry.0 = Some(revenue); - } - - if let Some(impressions) = point.metric.impressions { - entry.1 = Some(impressions); - } - } - } - - let mut revenue_data = Vec::new(); - let now = Utc::now(); - - for i in 1..=30 { - let time = now - Duration::days(i); - let start = time - .date_naive() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp(); - - if let Some((revenue, impressions)) = points_map.remove(&(start as u64)) - { - // Before 9/5/24, when legacy payouts were in effect. - if start >= 1725494400 { - let revenue = revenue.unwrap_or(Decimal::ZERO); - let impressions = impressions.unwrap_or(0); - - // Modrinth's share of ad revenue - let modrinth_cut = Decimal::from(1) / Decimal::from(4); - // Clean.io fee (ad antimalware). Per 1000 impressions. - let clean_io_fee = Decimal::from(8) / Decimal::from(1000); - - let net_revenue = revenue - - (clean_io_fee * Decimal::from(impressions) - / Decimal::from(1000)); - - let payout = net_revenue * (Decimal::from(1) - modrinth_cut); - - revenue_data.push(RevenueData { - time: start as u64, - revenue: net_revenue, - creator_revenue: payout, - }); - - continue; - } - } - - revenue_data.push(get_legacy_data_point(start as u64)); - } + let revenue_data = sqlx::query!( + " + SELECT created, SUM(amount) sum + FROM payouts_values + WHERE created BETWEEN $1 AND $2 + GROUP BY created + ORDER BY created DESC + ", + start, + end + ) + .fetch_all(&**pool) + .await? + .into_iter() + .map(|x| RevenueData { + time: x.created.timestamp() as u64, + revenue: x.sum.unwrap_or(Decimal::ZERO) * Decimal::from(25) + / Decimal::from(75), + creator_revenue: x.sum.unwrap_or(Decimal::ZERO), + }) + .collect(); let res = RevenueResponse { all_time: all_time_payouts, + all_time_available: all_available, data: revenue_data, }; - redis - .set_serialized_to_json( - PLATFORM_REVENUE_NAMESPACE, - 0, - &res, - Some(60 * 60), - ) - .await?; - Ok(HttpResponse::Ok().json(res)) } - -fn get_legacy_data_point(timestamp: u64) -> RevenueData { - let start = Utc.timestamp_opt(timestamp as i64, 0).unwrap(); - - let old_payouts_budget = Decimal::from(10_000); - - let days = Decimal::from(28); - let weekdays = Decimal::from(20); - let weekend_bonus = Decimal::from(5) / Decimal::from(4); - - let weekday_amount = - old_payouts_budget / (weekdays + (weekend_bonus) * (days - weekdays)); - let weekend_amount = weekday_amount * weekend_bonus; - - let payout = match start.weekday() { - Weekday::Sat | Weekday::Sun => weekend_amount, - _ => weekday_amount, - }; - - RevenueData { - time: timestamp, - revenue: payout, - creator_revenue: payout * (Decimal::from(9) / Decimal::from(10)), - } -} diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 3f16e6101..cc5c89b1e 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -6,13 +6,14 @@ use crate::database::models::loader_fields::{ use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, DBUser, image_item}; use crate::database::redis::RedisPool; -use crate::file_hosting::{FileHost, FileHostingError}; +use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError}; use crate::models::error::ApiError; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ - License, Link, MonetizationStatus, ProjectStatus, VersionStatus, + License, Link, MonetizationStatus, ProjectStatus, + SideTypesMigrationReviewStatus, VersionStatus, }; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::models::threads::ThreadType; @@ -239,18 +240,16 @@ pub struct NewGalleryItem { } pub struct UploadedFile { - pub file_id: String, - pub file_name: String, + pub name: String, + pub publicity: FileHostPublicity, } pub async fn undo_uploads( file_host: &dyn FileHost, uploaded_files: &[UploadedFile], -) -> Result<(), CreateError> { +) -> Result<(), FileHostingError> { for file in uploaded_files { - file_host - .delete_file_version(&file.file_id, &file.file_name) - .await?; + file_host.delete_file(&file.name, file.publicity).await?; } Ok(()) } @@ -308,13 +307,13 @@ Get logged in user 2. Upload - Icon: check file format & size - - Upload to backblaze & record URL + - Upload to S3 & record URL - Project files - Check for matching version - File size limits? - Check file type - Eventually, malware scan - - Upload to backblaze & create VersionFileBuilder + - Upload to S3 & create VersionFileBuilder - 3. Creation @@ -333,7 +332,7 @@ async fn project_create_inner( redis: &RedisPool, session_queue: &AuthQueue, ) -> Result { - // The base URL for files uploaded to backblaze + // The base URL for files uploaded to S3 let cdn_url = dotenvy::var("CDN_URL")?; // The currently logged in user @@ -360,15 +359,14 @@ async fn project_create_inner( // The first multipart field must be named "data" and contain a // JSON `ProjectCreateData` object. - let mut field = payload - .next() - .await - .map(|m| m.map_err(CreateError::MultipartError)) - .unwrap_or_else(|| { + let mut field = payload.next().await.map_or_else( + || { Err(CreateError::MissingValueError(String::from( "No `data` field in multipart upload", ))) - })?; + }, + |m| m.map_err(CreateError::MultipartError), + )?; let name = field.name().ok_or_else(|| { CreateError::MissingValueError(String::from("Missing content name")) @@ -516,6 +514,7 @@ async fn project_create_inner( let url = format!("data/{project_id}/images"); let upload_result = upload_image_optimized( &url, + FileHostPublicity::Public, data.freeze(), file_extension, Some(350), @@ -526,8 +525,8 @@ async fn project_create_inner( .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?; uploaded_files.push(UploadedFile { - file_id: upload_result.raw_url_path.clone(), - file_name: upload_result.raw_url_path, + name: upload_result.raw_url_path, + publicity: FileHostPublicity::Public, }); gallery_urls.push(crate::models::projects::GalleryItem { url: upload_result.url, @@ -550,8 +549,8 @@ async fn project_create_inner( ))); }; // `index` is always valid for these lists - let created_version = versions.get_mut(index).unwrap(); - let version_data = project_create_data.initial_versions.get(index).unwrap(); + let created_version = &mut versions[index]; + let version_data = &project_create_data.initial_versions[index]; // TODO: maybe redundant is this calculation done elsewhere? let existing_file_names = created_version @@ -670,10 +669,9 @@ async fn project_create_inner( &team_member, ); - if !perms - .map(|x| x.contains(OrganizationPermissions::ADD_PROJECT)) - .unwrap_or(false) - { + if !perms.is_some_and(|x| { + x.contains(OrganizationPermissions::ADD_PROJECT) + }) { return Err(CreateError::CustomAuthenticationError( "You do not have the permissions to create projects in this organization!" .to_string(), @@ -903,6 +901,9 @@ async fn project_create_inner( color: project_builder.color, thread_id: thread_id.into(), monetization_status: MonetizationStatus::Monetized, + // New projects are considered reviewed with respect to side types migrations + side_types_migration_review_status: + SideTypesMigrationReviewStatus::Reviewed, fields: HashMap::new(), // Fields instantiate to empty }; @@ -1008,6 +1009,7 @@ async fn process_icon_upload( .await?; let upload_result = crate::util::img::upload_image_optimized( &format!("data/{}", to_base62(id)), + FileHostPublicity::Public, data.freeze(), file_extension, Some(96), @@ -1018,13 +1020,13 @@ async fn process_icon_upload( .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?; uploaded_files.push(UploadedFile { - file_id: upload_result.raw_url_path.clone(), - file_name: upload_result.raw_url_path, + name: upload_result.raw_url_path, + publicity: FileHostPublicity::Public, }); uploaded_files.push(UploadedFile { - file_id: upload_result.url_path.clone(), - file_name: upload_result.url_path, + name: upload_result.url_path, + publicity: FileHostPublicity::Public, }); Ok(( diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 49110b91f..09f252d1a 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -9,7 +9,7 @@ use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::{DBTeamMember, ids as db_ids, image_item}; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models; use crate::models::ids::ProjectId; use crate::models::images::ImageContext; @@ -17,6 +17,7 @@ use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::{ MonetizationStatus, Project, ProjectStatus, SearchRequest, + SideTypesMigrationReviewStatus, }; use crate::models::teams::ProjectPermissions; use crate::models::threads::MessageBody; @@ -27,7 +28,7 @@ use crate::search::indexing::remove_documents; use crate::search::{SearchConfig, SearchError, search_for_project}; use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::base62_impl::parse_base62; @@ -248,6 +249,8 @@ pub struct EditProject { #[validate(length(max = 65536))] pub moderation_message_body: Option>, pub monetization_status: Option, + pub side_types_migration_review_status: + Option, } #[allow(clippy::too_many_arguments)] @@ -275,641 +278,647 @@ pub async fn project_edit( ApiError::Validation(validation_errors_to_string(err, None)) })?; - let string = info.into_inner().0; - let result = db_models::DBProject::get(&string, &**pool, &redis).await?; - if let Some(project_item) = result { - let id = project_item.inner.id; + let Some(project_item) = + db_models::DBProject::get(&info.into_inner().0, &**pool, &redis) + .await? + else { + return Err(ApiError::NotFound); + }; - let (team_member, organization_team_member) = - db_models::DBTeamMember::get_for_project_permissions( - &project_item.inner, - user.id.into(), - &**pool, - ) - .await?; + let id = project_item.inner.id; - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member, - &organization_team_member, - ); + let (team_member, organization_team_member) = + db_models::DBTeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; - if let Some(perms) = permissions { - let mut transaction = pool.begin().await?; + let Some(perms) = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to edit this project!".to_string(), + )); + }; - if let Some(name) = &new_project.name { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the name of this project!" - .to_string(), - )); - } + let mut transaction = pool.begin().await?; - sqlx::query!( - " - UPDATE mods - SET name = $1 - WHERE (id = $2) - ", - name.trim(), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(summary) = &new_project.summary { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the summary of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET summary = $1 - WHERE (id = $2) - ", - summary, - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(status) = &new_project.status { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the status of this project!" - .to_string(), - )); - } - - if !(user.role.is_mod() - || !project_item.inner.status.is_approved() - && status == &ProjectStatus::Processing - || project_item.inner.status.is_approved() - && status.can_be_requested()) - { - return Err(ApiError::CustomAuthentication( - "You don't have permission to set this status!" - .to_string(), - )); - } - - if status == &ProjectStatus::Processing { - if project_item.versions.is_empty() { - return Err(ApiError::InvalidInput(String::from( - "Project submitted for review with no initial versions", - ))); - } - - sqlx::query!( - " - UPDATE mods - SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() - WHERE (id = $1) - ", - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - - moderation_queue - .projects - .insert(project_item.inner.id.into()); - } - - if status.is_approved() - && !project_item.inner.status.is_approved() - { - sqlx::query!( - " - UPDATE mods - SET approved = NOW() - WHERE id = $1 AND approved IS NULL - ", - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - if status.is_searchable() && !project_item.inner.webhook_sent { - if let Ok(webhook_url) = - dotenvy::var("PUBLIC_DISCORD_WEBHOOK") - { - crate::util::webhook::send_discord_webhook( - project_item.inner.id.into(), - &pool, - &redis, - webhook_url, - None, - ) - .await - .ok(); - - sqlx::query!( - " - UPDATE mods - SET webhook_sent = TRUE - WHERE id = $1 - ", - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - } - - if user.role.is_mod() { - if let Ok(webhook_url) = - dotenvy::var("MODERATION_SLACK_WEBHOOK") - { - crate::util::webhook::send_slack_webhook( - project_item.inner.id.into(), - &pool, - &redis, - webhook_url, - Some( - format!( - "*<{}/user/{}|{}>* changed project status from *{}* to *{}*", - dotenvy::var("SITE_URL")?, - user.username, - user.username, - &project_item.inner.status.as_friendly_str(), - status.as_friendly_str(), - ) - .to_string(), - ), - ) - .await - .ok(); - } - } - - if team_member.map(|x| !x.accepted).unwrap_or(true) { - let notified_members = sqlx::query!( - " - SELECT tm.user_id id - FROM team_members tm - WHERE tm.team_id = $1 AND tm.accepted - ", - project_item.inner.team_id as db_ids::DBTeamId - ) - .fetch(&mut *transaction) - .map_ok(|c| db_models::DBUserId(c.id)) - .try_collect::>() - .await?; - - NotificationBuilder { - body: NotificationBody::StatusChange { - project_id: project_item.inner.id.into(), - old_status: project_item.inner.status, - new_status: *status, - }, - } - .insert_many(notified_members, &mut transaction, &redis) - .await?; - } - - ThreadMessageBuilder { - author_id: Some(user.id.into()), - body: MessageBody::StatusChange { - new_status: *status, - old_status: project_item.inner.status, - }, - thread_id: project_item.thread_id, - hide_identity: user.role.is_mod(), - } - .insert(&mut transaction) - .await?; - - sqlx::query!( - " - UPDATE mods - SET status = $1 - WHERE (id = $2) - ", - status.as_str(), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - - if project_item.inner.status.is_searchable() - && !status.is_searchable() - { - remove_documents( - &project_item - .versions - .into_iter() - .map(|x| x.into()) - .collect::>(), - &search_config, - ) - .await?; - } - } - - if let Some(requested_status) = &new_project.requested_status { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the requested status of this project!" - .to_string(), - )); - } - - if !requested_status - .map(|x| x.can_be_requested()) - .unwrap_or(true) - { - return Err(ApiError::InvalidInput(String::from( - "Specified status cannot be requested!", - ))); - } - - sqlx::query!( - " - UPDATE mods - SET requested_status = $1 - WHERE (id = $2) - ", - requested_status.map(|x| x.as_str()), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if perms.contains(ProjectPermissions::EDIT_DETAILS) { - if new_project.categories.is_some() { - sqlx::query!( - " - DELETE FROM mods_categories - WHERE joining_mod_id = $1 AND is_additional = FALSE - ", - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if new_project.additional_categories.is_some() { - sqlx::query!( - " - DELETE FROM mods_categories - WHERE joining_mod_id = $1 AND is_additional = TRUE - ", - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - } - - if let Some(categories) = &new_project.categories { - edit_project_categories( - categories, - &perms, - id as db_ids::DBProjectId, - false, - &mut transaction, - ) - .await?; - } - - if let Some(categories) = &new_project.additional_categories { - edit_project_categories( - categories, - &perms, - id as db_ids::DBProjectId, - true, - &mut transaction, - ) - .await?; - } - - if let Some(license_url) = &new_project.license_url { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the license URL of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET license_url = $1 - WHERE (id = $2) - ", - license_url.as_deref(), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(slug) = &new_project.slug { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the slug of this project!" - .to_string(), - )); - } - - let slug_project_id_option: Option = - parse_base62(slug).ok(); - if let Some(slug_project_id) = slug_project_id_option { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) - ", - slug_project_id as i64 - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Slug collides with other project's id!" - .to_string(), - )); - } - } - - // Make sure the new slug is different from the old one - // We are able to unwrap here because the slug is always set - if !slug.eq(&project_item - .inner - .slug - .clone() - .unwrap_or_default()) - { - let results = sqlx::query!( - " - SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) - ", - slug - ) - .fetch_one(&mut *transaction) - .await?; - - if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Slug collides with other project's id!" - .to_string(), - )); - } - } - - sqlx::query!( - " - UPDATE mods - SET slug = LOWER($1) - WHERE (id = $2) - ", - Some(slug), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(license) = &new_project.license_id { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the license of this project!" - .to_string(), - )); - } - - let mut license = license.clone(); - - if license.to_lowercase() == "arr" { - license = models::projects::DEFAULT_LICENSE_ID.to_string(); - } - - spdx::Expression::parse(&license).map_err(|err| { - ApiError::InvalidInput(format!( - "Invalid SPDX license identifier: {err}" - )) - })?; - - sqlx::query!( - " - UPDATE mods - SET license = $1 - WHERE (id = $2) - ", - license, - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - if let Some(links) = &new_project.link_urls { - if !links.is_empty() { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the links of this project!" - .to_string(), - )); - } - - let ids_to_delete = - links.keys().cloned().collect::>(); - // Deletes all links from hashmap- either will be deleted or be replaced - sqlx::query!( - " - DELETE FROM mods_links - WHERE joining_mod_id = $1 AND joining_platform_id IN ( - SELECT id FROM link_platforms WHERE name = ANY($2) - ) - ", - id as db_ids::DBProjectId, - &ids_to_delete - ) - .execute(&mut *transaction) - .await?; - - for (platform, url) in links { - if let Some(url) = url { - let platform_id = - db_models::categories::LinkPlatform::get_id( - platform, - &mut *transaction, - ) - .await? - .ok_or_else( - || { - ApiError::InvalidInput(format!( - "Platform {} does not exist.", - platform.clone() - )) - }, - )?; - sqlx::query!( - " - INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) - VALUES ($1, $2, $3) - ", - id as db_ids::DBProjectId, - platform_id as db_ids::LinkPlatformId, - url - ) - .execute(&mut *transaction) - .await?; - } - } - } - } - if let Some(moderation_message) = &new_project.moderation_message { - if !user.role.is_mod() - && (!project_item.inner.status.is_approved() - || moderation_message.is_some()) - { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the moderation message of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET moderation_message = $1 - WHERE (id = $2) - ", - moderation_message.as_deref(), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(moderation_message_body) = - &new_project.moderation_message_body - { - if !user.role.is_mod() - && (!project_item.inner.status.is_approved() - || moderation_message_body.is_some()) - { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the moderation message body of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET moderation_message_body = $1 - WHERE (id = $2) - ", - moderation_message_body.as_deref(), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(description) = &new_project.description { - if !perms.contains(ProjectPermissions::EDIT_BODY) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the description (body) of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET description = $1 - WHERE (id = $2) - ", - description, - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - if let Some(monetization_status) = &new_project.monetization_status - { - if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the monetization status of this project!" - .to_string(), - )); - } - - if (*monetization_status - == MonetizationStatus::ForceDemonetized - || project_item.inner.monetization_status - == MonetizationStatus::ForceDemonetized) - && !user.role.is_mod() - { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the monetization status of this project!" - .to_string(), - )); - } - - sqlx::query!( - " - UPDATE mods - SET monetization_status = $1 - WHERE (id = $2) - ", - monetization_status.as_str(), - id as db_ids::DBProjectId, - ) - .execute(&mut *transaction) - .await?; - } - - // check new description and body for links to associated images - // if they no longer exist in the description or body, delete them - let checkable_strings: Vec<&str> = - vec![&new_project.description, &new_project.summary] - .into_iter() - .filter_map(|x| x.as_ref().map(|y| y.as_str())) - .collect(); - - let context = ImageContext::Project { - project_id: Some(id.into()), - }; - - img::delete_unused_images( - context, - checkable_strings, - &mut transaction, - &redis, - ) - .await?; - - transaction.commit().await?; - db_models::DBProject::clear_cache( - project_item.inner.id, - project_item.inner.slug, - None, - None, - &redis, - ) - .await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to edit this project!".to_string(), - )) + if let Some(name) = &new_project.name { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the name of this project!" + .to_string(), + )); } - } else { - Err(ApiError::NotFound) + + sqlx::query!( + " + UPDATE mods + SET name = $1 + WHERE (id = $2) + ", + name.trim(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; } + + if let Some(summary) = &new_project.summary { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the summary of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET summary = $1 + WHERE (id = $2) + ", + summary, + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_project.status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the status of this project!" + .to_string(), + )); + } + + if !(user.role.is_mod() + || !project_item.inner.status.is_approved() + && status == &ProjectStatus::Processing + || project_item.inner.status.is_approved() + && status.can_be_requested()) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set this status!".to_string(), + )); + } + + if status == &ProjectStatus::Processing { + if project_item.versions.is_empty() { + return Err(ApiError::InvalidInput(String::from( + "Project submitted for review with no initial versions", + ))); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() + WHERE (id = $1) + ", + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + + moderation_queue + .projects + .insert(project_item.inner.id.into()); + } + + if status.is_approved() && !project_item.inner.status.is_approved() { + sqlx::query!( + " + UPDATE mods + SET approved = NOW() + WHERE id = $1 AND approved IS NULL + ", + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if status.is_searchable() && !project_item.inner.webhook_sent { + if let Ok(webhook_url) = dotenvy::var("PUBLIC_DISCORD_WEBHOOK") { + crate::util::webhook::send_discord_webhook( + project_item.inner.id.into(), + &pool, + &redis, + webhook_url, + None, + ) + .await + .ok(); + + sqlx::query!( + " + UPDATE mods + SET webhook_sent = TRUE + WHERE id = $1 + ", + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + } + + if user.role.is_mod() { + if let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK") { + crate::util::webhook::send_slack_webhook( + project_item.inner.id.into(), + &pool, + &redis, + webhook_url, + Some( + format!( + "*<{}/user/{}|{}>* changed project status from *{}* to *{}*", + dotenvy::var("SITE_URL")?, + user.username, + user.username, + &project_item.inner.status.as_friendly_str(), + status.as_friendly_str(), + ) + .to_string(), + ), + ) + .await + .ok(); + } + } + + if team_member.is_none_or(|x| !x.accepted) { + let notified_members = sqlx::query!( + " + SELECT tm.user_id id + FROM team_members tm + WHERE tm.team_id = $1 AND tm.accepted + ", + project_item.inner.team_id as db_ids::DBTeamId + ) + .fetch(&mut *transaction) + .map_ok(|c| db_models::DBUserId(c.id)) + .try_collect::>() + .await?; + + NotificationBuilder { + body: NotificationBody::StatusChange { + project_id: project_item.inner.id.into(), + old_status: project_item.inner.status, + new_status: *status, + }, + } + .insert_many(notified_members, &mut transaction, &redis) + .await?; + } + + ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: MessageBody::StatusChange { + new_status: *status, + old_status: project_item.inner.status, + }, + thread_id: project_item.thread_id, + hide_identity: user.role.is_mod(), + } + .insert(&mut transaction) + .await?; + + sqlx::query!( + " + UPDATE mods + SET status = $1 + WHERE (id = $2) + ", + status.as_str(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(requested_status) = &new_project.requested_status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the requested status of this project!" + .to_string(), + )); + } + + if !requested_status + .map(|x| x.can_be_requested()) + .unwrap_or(true) + { + return Err(ApiError::InvalidInput(String::from( + "Specified status cannot be requested!", + ))); + } + + sqlx::query!( + " + UPDATE mods + SET requested_status = $1 + WHERE (id = $2) + ", + requested_status.map(|x| x.as_str()), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if perms.contains(ProjectPermissions::EDIT_DETAILS) { + if new_project.categories.is_some() { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = FALSE + ", + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if new_project.additional_categories.is_some() { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = TRUE + ", + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(categories) = &new_project.categories { + edit_project_categories( + categories, + &perms, + id as db_ids::DBProjectId, + false, + &mut transaction, + ) + .await?; + } + + if let Some(categories) = &new_project.additional_categories { + edit_project_categories( + categories, + &perms, + id as db_ids::DBProjectId, + true, + &mut transaction, + ) + .await?; + } + + if let Some(license_url) = &new_project.license_url { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the license URL of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET license_url = $1 + WHERE (id = $2) + ", + license_url.as_deref(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(slug) = &new_project.slug { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the slug of this project!" + .to_string(), + )); + } + + let slug_project_id_option: Option = parse_base62(slug).ok(); + if let Some(slug_project_id) = slug_project_id_option { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) + ", + slug_project_id as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Slug collides with other project's id!".to_string(), + )); + } + } + + // Make sure the new slug is different from the old one + // We are able to unwrap here because the slug is always set + if !slug.eq(&project_item.inner.slug.clone().unwrap_or_default()) { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) + ", + slug + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Slug collides with other project's id!".to_string(), + )); + } + } + + sqlx::query!( + " + UPDATE mods + SET slug = LOWER($1) + WHERE (id = $2) + ", + Some(slug), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(license) = &new_project.license_id { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the license of this project!" + .to_string(), + )); + } + + let mut license = license.clone(); + + if license.to_lowercase() == "arr" { + license = models::projects::DEFAULT_LICENSE_ID.to_string(); + } + + spdx::Expression::parse(&license).map_err(|err| { + ApiError::InvalidInput(format!( + "Invalid SPDX license identifier: {err}" + )) + })?; + + sqlx::query!( + " + UPDATE mods + SET license = $1 + WHERE (id = $2) + ", + license, + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(links) = &new_project.link_urls { + if !links.is_empty() { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the links of this project!" + .to_string(), + )); + } + + let ids_to_delete = links.keys().cloned().collect::>(); + // Deletes all links from hashmap- either will be deleted or be replaced + sqlx::query!( + " + DELETE FROM mods_links + WHERE joining_mod_id = $1 AND joining_platform_id IN ( + SELECT id FROM link_platforms WHERE name = ANY($2) + ) + ", + id as db_ids::DBProjectId, + &ids_to_delete + ) + .execute(&mut *transaction) + .await?; + + for (platform, url) in links { + if let Some(url) = url { + let platform_id = + db_models::categories::LinkPlatform::get_id( + platform, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Platform {} does not exist.", + platform.clone() + )) + })?; + sqlx::query!( + " + INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) + VALUES ($1, $2, $3) + ", + id as db_ids::DBProjectId, + platform_id as db_ids::LinkPlatformId, + url + ) + .execute(&mut *transaction) + .await?; + } + } + } + } + if let Some(moderation_message) = &new_project.moderation_message { + if !user.role.is_mod() + && (!project_item.inner.status.is_approved() + || moderation_message.is_some()) + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the moderation message of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message = $1 + WHERE (id = $2) + ", + moderation_message.as_deref(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(moderation_message_body) = &new_project.moderation_message_body + { + if !user.role.is_mod() + && (!project_item.inner.status.is_approved() + || moderation_message_body.is_some()) + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the moderation message body of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message_body = $1 + WHERE (id = $2) + ", + moderation_message_body.as_deref(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(description) = &new_project.description { + if !perms.contains(ProjectPermissions::EDIT_BODY) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the description (body) of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET description = $1 + WHERE (id = $2) + ", + description, + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(monetization_status) = &new_project.monetization_status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the monetization status of this project!" + .to_string(), + )); + } + + if (*monetization_status == MonetizationStatus::ForceDemonetized + || project_item.inner.monetization_status + == MonetizationStatus::ForceDemonetized) + && !user.role.is_mod() + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the monetization status of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET monetization_status = $1 + WHERE (id = $2) + ", + monetization_status.as_str(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(side_types_migration_review_status) = + &new_project.side_types_migration_review_status + { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the side types migration review status of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET side_types_migration_review_status = $1 + WHERE id = $2 + ", + side_types_migration_review_status.as_str(), + id as db_ids::DBProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + // check new description and body for links to associated images + // if they no longer exist in the description or body, delete them + let checkable_strings: Vec<&str> = + vec![&new_project.description, &new_project.summary] + .into_iter() + .filter_map(|x| x.as_ref().map(|y| y.as_str())) + .collect(); + + let context = ImageContext::Project { + project_id: Some(id.into()), + }; + + img::delete_unused_images( + context, + checkable_strings, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + db_models::DBProject::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + None, + &redis, + ) + .await?; + + // Remove no longer searchable projects from search index + if let (true, Some(false)) = ( + project_item.inner.status.is_searchable(), + new_project.status.map(|status| status.is_searchable()), + ) { + remove_documents( + &project_item + .versions + .into_iter() + .map(|x| x.into()) + .collect::>(), + &search_config, + ) + .await?; + } + + Ok(HttpResponse::NoContent().body("")) } pub async fn edit_project_categories( @@ -1557,11 +1566,12 @@ pub async fn project_icon_edit( delete_old_images( project_item.inner.icon_url, project_item.inner.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -1571,6 +1581,7 @@ pub async fn project_icon_edit( let project_id: ProjectId = project_item.inner.id.into(); let upload_result = upload_image_optimized( &format!("data/{project_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -1668,6 +1679,7 @@ pub async fn delete_project_icon( delete_old_images( project_item.inner.icon_url, project_item.inner.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; @@ -1781,7 +1793,7 @@ pub async fn add_gallery_item( } } - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 2 * (1 << 20), "Gallery image exceeds the maximum of 2MiB.", @@ -1791,6 +1803,7 @@ pub async fn add_gallery_item( let id: ProjectId = project_item.inner.id.into(); let upload_result = upload_image_optimized( &format!("data/{id}/images"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(350), @@ -2123,6 +2136,7 @@ pub async fn delete_gallery_item( delete_old_images( Some(item.image_url), Some(item.raw_image_url), + FileHostPublicity::Public, &***file_host, ) .await?; @@ -2481,13 +2495,11 @@ pub async fn project_get_organization( .filter(|x| { logged_in || x.accepted - || user_id - .map(|y: crate::database::models::DBUserId| { - y == x.user_id - }) - .unwrap_or(false) + || user_id.is_some_and( + |y: crate::database::models::DBUserId| y == x.user_id, + ) }) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index 61a3cc223..8708054a8 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -14,11 +14,11 @@ use crate::models::threads::{MessageBody, ThreadType}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::img; +use crate::util::routes::read_typed_from_payload; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::UserId; use ariadne::ids::base62_impl::parse_base62; use chrono::Utc; -use futures::StreamExt; use serde::Deserialize; use sqlx::PgPool; use validator::Validate; @@ -63,15 +63,7 @@ pub async fn report_create( .await? .1; - let mut bytes = web::BytesMut::new(); - while let Some(item) = body.next().await { - bytes.extend_from_slice(&item.map_err(|_| { - ApiError::InvalidInput( - "Error while parsing request payload!".to_string(), - ) - })?); - } - let new_report: CreateReport = serde_json::from_slice(bytes.as_ref())?; + let new_report: CreateReport = read_typed_from_payload(&mut body).await?; let id = crate::database::models::generate_report_id(&mut transaction).await?; diff --git a/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs new file mode 100644 index 000000000..aaf35b6fb --- /dev/null +++ b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs @@ -0,0 +1,200 @@ +use crate::auth::get_user_from_headers; +use crate::database::models::shared_instance_item::{ + DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion, +}; +use crate::database::models::{ + DBSharedInstanceId, DBSharedInstanceVersionId, + generate_shared_instance_version_id, +}; +use crate::database::redis::RedisPool; +use crate::file_hosting::{FileHost, FileHostPublicity}; +use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId}; +use crate::models::pats::Scopes; +use crate::models::shared_instances::{ + SharedInstanceUserPermissions, SharedInstanceVersion, +}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::routes::v3::project_creation::UploadedFile; +use crate::util::ext::MRPACK_MIME_TYPE; +use actix_web::http::header::ContentLength; +use actix_web::web::Data; +use actix_web::{HttpRequest, HttpResponse, web}; +use bytes::BytesMut; +use chrono::Utc; +use futures_util::StreamExt; +use hex::FromHex; +use sqlx::{PgPool, Postgres, Transaction}; +use std::sync::Arc; + +const MAX_FILE_SIZE: usize = 500 * 1024 * 1024; +const MAX_FILE_SIZE_TEXT: &str = "500 MB"; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route( + "shared-instance/{id}/version", + web::post().to(shared_instance_version_create), + ); +} + +#[allow(clippy::too_many_arguments)] +pub async fn shared_instance_version_create( + req: HttpRequest, + pool: Data, + payload: web::Payload, + web::Header(ContentLength(content_length)): web::Header, + redis: Data, + file_host: Data>, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + if content_length > MAX_FILE_SIZE { + return Err(ApiError::InvalidInput(format!( + "File size exceeds the maximum limit of {MAX_FILE_SIZE_TEXT}" + ))); + } + + let mut transaction = pool.begin().await?; + let mut uploaded_files = vec![]; + + let result = shared_instance_version_create_inner( + req, + &pool, + payload, + content_length, + &redis, + &***file_host, + info.into_inner().0.into(), + &session_queue, + &mut transaction, + &mut uploaded_files, + ) + .await; + + if result.is_err() { + let undo_result = super::project_creation::undo_uploads( + &***file_host, + &uploaded_files, + ) + .await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} + +#[allow(clippy::too_many_arguments)] +async fn shared_instance_version_create_inner( + req: HttpRequest, + pool: &PgPool, + mut payload: web::Payload, + content_length: usize, + redis: &RedisPool, + file_host: &dyn FileHost, + instance_id: DBSharedInstanceId, + session_queue: &AuthQueue, + transaction: &mut Transaction<'_, Postgres>, + uploaded_files: &mut Vec, +) -> Result { + let user = get_user_from_headers( + &req, + pool, + redis, + session_queue, + Scopes::SHARED_INSTANCE_VERSION_CREATE, + ) + .await? + .1; + + let Some(instance) = DBSharedInstance::get(instance_id, pool).await? else { + return Err(ApiError::NotFound); + }; + if !user.role.is_mod() && instance.owner_id != user.id.into() { + let permissions = DBSharedInstanceUser::get_user_permissions( + instance_id, + user.id.into(), + pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions + .contains(SharedInstanceUserPermissions::UPLOAD_VERSION) + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to upload a version for this shared instance.".to_string() + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + let version_id = + generate_shared_instance_version_id(&mut *transaction).await?; + + let mut file_data = BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk.map_err(|_| { + ApiError::InvalidInput( + "Unable to parse bytes in payload sent!".to_string(), + ) + })?; + + if file_data.len() + chunk.len() <= MAX_FILE_SIZE { + file_data.extend_from_slice(&chunk); + } else { + file_data + .extend_from_slice(&chunk[..MAX_FILE_SIZE - file_data.len()]); + break; + } + } + + let file_data = file_data.freeze(); + let file_path = format!( + "shared_instance/{}.mrpack", + SharedInstanceVersionId::from(version_id), + ); + + let upload_data = file_host + .upload_file( + MRPACK_MIME_TYPE, + &file_path, + FileHostPublicity::Private, + file_data, + ) + .await?; + + uploaded_files.push(UploadedFile { + name: file_path, + publicity: upload_data.file_publicity, + }); + + let sha512 = Vec::::from_hex(upload_data.content_sha512).unwrap(); + + let new_version = DBSharedInstanceVersion { + id: version_id, + shared_instance_id: instance_id, + size: content_length as u64, + sha512, + created: Utc::now(), + }; + new_version.insert(transaction).await?; + + sqlx::query!( + "UPDATE shared_instances SET current_version_id = $1 WHERE id = $2", + new_version.id as DBSharedInstanceVersionId, + instance_id as DBSharedInstanceId, + ) + .execute(&mut **transaction) + .await?; + + let version: SharedInstanceVersion = new_version.into(); + Ok(HttpResponse::Created().json(version)) +} diff --git a/apps/labrinth/src/routes/v3/shared_instances.rs b/apps/labrinth/src/routes/v3/shared_instances.rs new file mode 100644 index 000000000..fa1fc402e --- /dev/null +++ b/apps/labrinth/src/routes/v3/shared_instances.rs @@ -0,0 +1,612 @@ +use crate::auth::get_user_from_headers; +use crate::auth::validate::get_maybe_user_from_headers; +use crate::database::models::shared_instance_item::{ + DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion, +}; +use crate::database::models::{ + DBSharedInstanceId, DBSharedInstanceVersionId, generate_shared_instance_id, +}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId}; +use crate::models::pats::Scopes; +use crate::models::shared_instances::{ + SharedInstance, SharedInstanceUserPermissions, SharedInstanceVersion, +}; +use crate::models::users::User; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::routes::read_typed_from_payload; +use actix_web::web::{Data, Redirect}; +use actix_web::{HttpRequest, HttpResponse, web}; +use futures_util::future::try_join_all; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("shared-instance", web::post().to(shared_instance_create)); + cfg.route("shared-instance", web::get().to(shared_instance_list)); + cfg.service( + web::scope("shared-instance") + .route("{id}", web::get().to(shared_instance_get)) + .route("{id}", web::patch().to(shared_instance_edit)) + .route("{id}", web::delete().to(shared_instance_delete)) + .route("{id}/version", web::get().to(shared_instance_version_list)), + ); + cfg.service( + web::scope("shared-instance-version") + .route("{id}", web::get().to(shared_instance_version_get)) + .route("{id}", web::delete().to(shared_instance_version_delete)) + .route( + "{id}/download", + web::get().to(shared_instance_version_download), + ), + ); +} + +#[derive(Deserialize, Validate)] +pub struct CreateSharedInstance { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: String, + #[serde(default)] + pub public: bool, +} + +pub async fn shared_instance_create( + req: HttpRequest, + pool: Data, + mut body: web::Payload, + redis: Data, + session_queue: Data, +) -> Result { + let new_instance: CreateSharedInstance = + read_typed_from_payload(&mut body).await?; + + let mut transaction = pool.begin().await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_CREATE, + ) + .await? + .1; + + let id = generate_shared_instance_id(&mut transaction).await?; + + let instance = DBSharedInstance { + id, + title: new_instance.title, + owner_id: user.id.into(), + public: new_instance.public, + current_version_id: None, + }; + instance.insert(&mut transaction).await?; + + transaction.commit().await?; + + Ok(HttpResponse::Created().json(SharedInstance { + id: id.into(), + title: instance.title, + owner: user.id, + public: instance.public, + current_version: None, + additional_users: Some(vec![]), + })) +} + +pub async fn shared_instance_list( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .1; + + // TODO: Something for moderators to be able to see all instances? + let instances = + DBSharedInstance::list_for_user(user.id.into(), &**pool).await?; + let instances = try_join_all(instances.into_iter().map( + async |instance| -> Result { + let version = if let Some(version_id) = instance.current_version_id + { + DBSharedInstanceVersion::get(version_id, &**pool).await? + } else { + None + }; + let instance_id = instance.id; + Ok(SharedInstance::from_db( + instance, + Some( + DBSharedInstanceUser::get_from_instance( + instance_id, + &**pool, + &redis, + ) + .await?, + ), + version, + )) + }, + )) + .await?; + + Ok(HttpResponse::Ok().json(instances)) +} + +pub async fn shared_instance_get( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .map(|(_, user)| user); + + let shared_instance = DBSharedInstance::get(id, &**pool).await?; + + if let Some(shared_instance) = shared_instance { + let users = + DBSharedInstanceUser::get_from_instance(id, &**pool, &redis) + .await?; + + let privately_accessible = user.is_some_and(|user| { + can_access_instance_privately(&shared_instance, &users, &user) + }); + if !shared_instance.public && !privately_accessible { + return Err(ApiError::NotFound); + } + + let current_version = + if let Some(version_id) = shared_instance.current_version_id { + DBSharedInstanceVersion::get(version_id, &**pool).await? + } else { + None + }; + let shared_instance = SharedInstance::from_db( + shared_instance, + privately_accessible.then_some(users), + current_version, + ); + + Ok(HttpResponse::Ok().json(shared_instance)) + } else { + Err(ApiError::NotFound) + } +} + +fn can_access_instance_privately( + instance: &DBSharedInstance, + users: &[DBSharedInstanceUser], + user: &User, +) -> bool { + user.role.is_mod() + || instance.owner_id == user.id.into() + || users.iter().any(|x| x.user_id == user.id.into()) +} + +#[derive(Deserialize, Validate)] +pub struct EditSharedInstance { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: Option, + pub public: Option, +} + +pub async fn shared_instance_edit( + req: HttpRequest, + pool: Data, + mut body: web::Payload, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id = info.into_inner().0.into(); + let edit_instance: EditSharedInstance = + read_typed_from_payload(&mut body).await?; + + let mut transaction = pool.begin().await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_WRITE, + ) + .await? + .1; + + let Some(instance) = DBSharedInstance::get(id, &**pool).await? else { + return Err(ApiError::NotFound); + }; + + if !user.role.is_mod() && instance.owner_id != user.id.into() { + let permissions = DBSharedInstanceUser::get_user_permissions( + id, + user.id.into(), + &**pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions.contains(SharedInstanceUserPermissions::EDIT) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to edit this shared instance." + .to_string(), + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + if let Some(title) = edit_instance.title { + sqlx::query!( + " + UPDATE shared_instances + SET title = $1 + WHERE id = $2 + ", + title, + id as DBSharedInstanceId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(public) = edit_instance.public { + sqlx::query!( + " + UPDATE shared_instances + SET public = $1 + WHERE id = $2 + ", + public, + id as DBSharedInstanceId, + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn shared_instance_delete( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id: DBSharedInstanceId = info.into_inner().0.into(); + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_DELETE, + ) + .await? + .1; + + let Some(instance) = DBSharedInstance::get(id, &**pool).await? else { + return Err(ApiError::NotFound); + }; + + if !user.role.is_mod() && instance.owner_id != user.id.into() { + let permissions = DBSharedInstanceUser::get_user_permissions( + id, + user.id.into(), + &**pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions.contains(SharedInstanceUserPermissions::DELETE) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete this shared instance.".to_string() + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + sqlx::query!( + " + DELETE FROM shared_instances + WHERE id = $1 + ", + id as DBSharedInstanceId, + ) + .execute(&**pool) + .await?; + + DBSharedInstanceUser::clear_cache(id, &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn shared_instance_version_list( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .map(|(_, user)| user); + + let shared_instance = DBSharedInstance::get(id, &**pool).await?; + + if let Some(shared_instance) = shared_instance { + if !can_access_instance_as_maybe_user( + &pool, + &redis, + &shared_instance, + user, + ) + .await? + { + return Err(ApiError::NotFound); + } + + let versions = + DBSharedInstanceVersion::get_for_instance(id, &**pool).await?; + let versions = versions + .into_iter() + .map(Into::into) + .collect::>(); + + Ok(HttpResponse::Ok().json(versions)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn shared_instance_version_get( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceVersionId,)>, + session_queue: Data, +) -> Result { + let version_id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .map(|(_, user)| user); + + let version = DBSharedInstanceVersion::get(version_id, &**pool).await?; + + if let Some(version) = version { + let instance = + DBSharedInstance::get(version.shared_instance_id, &**pool).await?; + if let Some(instance) = instance { + if !can_access_instance_as_maybe_user( + &pool, &redis, &instance, user, + ) + .await? + { + return Err(ApiError::NotFound); + } + + let version: SharedInstanceVersion = version.into(); + Ok(HttpResponse::Ok().json(version)) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} + +async fn can_access_instance_as_maybe_user( + pool: &PgPool, + redis: &RedisPool, + instance: &DBSharedInstance, + user: Option, +) -> Result { + if instance.public { + return Ok(true); + } + let users = + DBSharedInstanceUser::get_from_instance(instance.id, pool, redis) + .await?; + Ok(user.is_some_and(|user| { + can_access_instance_privately(instance, &users, &user) + })) +} + +pub async fn shared_instance_version_delete( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceVersionId,)>, + session_queue: Data, +) -> Result { + let version_id = info.into_inner().0.into(); + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_VERSION_DELETE, + ) + .await? + .1; + + let shared_instance_version = + DBSharedInstanceVersion::get(version_id, &**pool).await?; + + if let Some(shared_instance_version) = shared_instance_version { + let shared_instance = DBSharedInstance::get( + shared_instance_version.shared_instance_id, + &**pool, + ) + .await?; + if let Some(shared_instance) = shared_instance { + if !user.role.is_mod() && shared_instance.owner_id != user.id.into() + { + let permissions = DBSharedInstanceUser::get_user_permissions( + shared_instance.id, + user.id.into(), + &**pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions + .contains(SharedInstanceUserPermissions::DELETE) + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete this shared instance version.".to_string() + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + delete_instance_version(shared_instance.id, version_id, &pool) + .await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} + +async fn delete_instance_version( + instance_id: DBSharedInstanceId, + version_id: DBSharedInstanceVersionId, + pool: &PgPool, +) -> Result<(), ApiError> { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + DELETE FROM shared_instance_versions + WHERE id = $1 + ", + version_id as DBSharedInstanceVersionId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE shared_instances + SET current_version_id = ( + SELECT id FROM shared_instance_versions + WHERE shared_instance_id = $1 + ORDER BY created DESC + LIMIT 1 + ) + WHERE id = $1 + ", + instance_id as DBSharedInstanceId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + Ok(()) +} + +pub async fn shared_instance_version_download( + req: HttpRequest, + pool: Data, + redis: Data, + file_host: Data>, + info: web::Path<(SharedInstanceVersionId,)>, + session_queue: Data, +) -> Result { + let version_id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_VERSION_READ, + ) + .await? + .map(|(_, user)| user); + + let version = DBSharedInstanceVersion::get(version_id, &**pool).await?; + + if let Some(version) = version { + let instance = + DBSharedInstance::get(version.shared_instance_id, &**pool).await?; + if let Some(instance) = instance { + if !can_access_instance_as_maybe_user( + &pool, &redis, &instance, user, + ) + .await? + { + return Err(ApiError::NotFound); + } + + let file_name = format!( + "shared_instance/{}.mrpack", + SharedInstanceVersionId::from(version_id) + ); + let url = + file_host.get_url_for_private_file(&file_name, 180).await?; + + Ok(Redirect::to(url).see_other()) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index 20b46b833..fff789891 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -148,16 +148,15 @@ pub async fn loader_fields_list( )) })?; - let loader_field_enum_id = match loader_field.field_type { - LoaderFieldType::Enum(enum_id) - | LoaderFieldType::ArrayEnum(enum_id) => enum_id, - _ => { - return Err(ApiError::InvalidInput(format!( - "'{}' is not an enumerable field, but an '{}' field.", - query.loader_field, - loader_field.field_type.to_str() - ))); - } + let (LoaderFieldType::Enum(loader_field_enum_id) + | LoaderFieldType::ArrayEnum(loader_field_enum_id)) = + loader_field.field_type + else { + return Err(ApiError::InvalidInput(format!( + "'{}' is not an enumerable field, but an '{}' field.", + query.loader_field, + loader_field.field_type.to_str() + ))); }; let results: Vec<_> = if let Some(filters) = query.filters { diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index e46ade93b..29cd57a09 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -101,13 +101,11 @@ pub async fn team_members_get_project( .filter(|x| { logged_in || x.accepted - || user_id - .map(|y: crate::database::models::DBUserId| { - y == x.user_id - }) - .unwrap_or(false) + || user_id.is_some_and( + |y: crate::database::models::DBUserId| y == x.user_id, + ) }) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, @@ -176,13 +174,11 @@ pub async fn team_members_get_organization( .filter(|x| { logged_in || x.accepted - || user_id - .map(|y: crate::database::models::DBUserId| { - y == x.user_id - }) - .unwrap_or(false) + || user_id.is_some_and( + |y: crate::database::models::DBUserId| y == x.user_id, + ) }) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, @@ -242,11 +238,11 @@ pub async fn team_members_get( .filter(|x| { logged_in || x.accepted - || user_id - .map(|y: crate::database::models::DBUserId| y == x.user_id) - .unwrap_or(false) + || user_id.is_some_and( + |y: crate::database::models::DBUserId| y == x.user_id, + ) }) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, @@ -319,7 +315,7 @@ pub async fn teams_get( let team_members = members .into_iter() .filter(|x| logged_in || x.accepted) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, @@ -592,8 +588,7 @@ pub async fn add_team_member( }; if new_user_organization_team_member .as_ref() - .map(|tm| tm.is_owner) - .unwrap_or(false) + .is_some_and(|tm| tm.is_owner) && new_member.permissions != ProjectPermissions::all() { return Err(ApiError::InvalidInput( @@ -748,12 +743,10 @@ pub async fn edit_team_member( if organization_team_member .as_ref() - .map(|x| x.is_owner) - .unwrap_or(false) + .is_some_and(|x| x.is_owner) && edit_member .permissions - .map(|x| x != ProjectPermissions::all()) - .unwrap_or(false) + .is_some_and(|x| x != ProjectPermissions::all()) { return Err(ApiError::CustomAuthentication( "You cannot override the project permissions of the organization owner!" @@ -1011,7 +1004,7 @@ pub async fn transfer_ownership( .collect(); // If the owner of the organization is a member of the project, remove them - for team_id in team_ids.iter() { + for team_id in &team_ids { DBTeamMember::delete( *team_id, new_owner.user_id.into(), diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index f85016621..364b94a44 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -6,7 +6,7 @@ use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::{ThreadId, ThreadMessageId}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; @@ -119,7 +119,7 @@ pub async fn filter_authorized_threads( let project_thread_ids = check_threads .iter() .filter(|x| x.type_ == ThreadType::Project) - .flat_map(|x| x.project_id.map(|x| x.0)) + .filter_map(|x| x.project_id.map(|x| x.0)) .collect::>(); if !project_thread_ids.is_empty() { @@ -148,13 +148,12 @@ pub async fn filter_authorized_threads( .await?; } - let org_project_thread_ids = check_threads + let mut org_project_thread_ids = check_threads .iter() .filter(|x| x.type_ == ThreadType::Project) - .flat_map(|x| x.project_id.map(|x| x.0)) - .collect::>(); + .filter_map(|x| x.project_id.map(|x| x.0)); - if !org_project_thread_ids.is_empty() { + if org_project_thread_ids.next().is_some() { sqlx::query!( " SELECT m.id FROM mods m @@ -184,7 +183,7 @@ pub async fn filter_authorized_threads( let report_thread_ids = check_threads .iter() .filter(|x| x.type_ == ThreadType::Report) - .flat_map(|x| x.report_id.map(|x| x.0)) + .filter_map(|x| x.report_id.map(|x| x.0)) .collect::>(); if !report_thread_ids.is_empty() { @@ -607,7 +606,12 @@ pub async fn message_delete( for image in images { let name = image.url.split(&format!("{cdn_url}/")).nth(1); if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; + file_host + .delete_file( + icon_path, + FileHostPublicity::Public, // FIXME: Consider using private file storage? + ) + .await?; } database::DBImage::remove(image.id, &mut transaction, &redis) .await?; diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index cfb700822..d11c6b2d3 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use super::{ApiError, oauth_clients::get_user_clients}; +use crate::file_hosting::FileHostPublicity; use crate::util::img::delete_old_images; use crate::{ auth::{filter_visible_projects, get_user_from_headers}, @@ -14,7 +15,10 @@ use crate::{ users::{Badges, Role}, }, queue::session::AuthQueue, - util::{routes::read_from_payload, validate::validation_errors_to_string}, + util::{ + routes::read_limited_from_payload, + validate::validation_errors_to_string, + }, }; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::UserId; @@ -207,7 +211,7 @@ pub async fn user_get( .ok(); let response: crate::models::users::User = - if auth_user.map(|x| x.role.is_admin()).unwrap_or(false) { + if auth_user.is_some_and(|x| x.role.is_admin()) { crate::models::users::User::from_full(data) } else { data.into() @@ -242,9 +246,8 @@ pub async fn collections_list( if let Some(id) = id_option.map(|x| x.id) { let user_id: UserId = id.into(); - let can_view_private = user - .map(|y| y.role.is_mod() || y.id == user_id) - .unwrap_or(false); + let can_view_private = + user.is_some_and(|y| y.role.is_mod() || y.id == user_id); let project_data = DBUser::get_collections(id, &**pool).await?; @@ -334,7 +337,7 @@ pub async fn orgs_list( let team_members: Vec<_> = members_data .into_iter() .filter(|x| logged_in || x.accepted || id == x.user_id) - .flat_map(|data| { + .filter_map(|data| { users.iter().find(|x| x.id == data.user_id).map(|user| { crate::models::teams::TeamMember::from( data, @@ -412,8 +415,7 @@ pub async fn user_edit( if existing_user_id_option .map(|x| UserId::from(x.id)) - .map(|id| id == user.id) - .unwrap_or(true) + .is_none_or(|id| id == user.id) { sqlx::query!( " @@ -578,11 +580,12 @@ pub async fn user_icon_edit( delete_old_images( actual_user.avatar_url, actual_user.raw_avatar_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -592,6 +595,7 @@ pub async fn user_icon_edit( let user_id: UserId = actual_user.id.into(); let upload_result = crate::util::img::upload_image_optimized( &format!("data/{user_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -650,6 +654,7 @@ pub async fn user_icon_delete( delete_old_images( actual_user.avatar_url, actual_user.raw_avatar_url, + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 2a2fd222f..b3d9b0692 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -9,7 +9,7 @@ use crate::database::models::version_item::{ }; use crate::database::models::{self, DBOrganization, image_item}; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::{ImageId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; @@ -613,13 +613,10 @@ async fn upload_file_to_version_inner( let result = models::DBVersion::get(version_id, &**client, &redis).await?; - let version = match result { - Some(v) => v, - None => { - return Err(CreateError::InvalidInput( - "An invalid version id was supplied".to_string(), - )); - } + let Some(version) = result else { + return Err(CreateError::InvalidInput( + "An invalid version id was supplied".to_string(), + )); }; let all_loaders = @@ -962,12 +959,12 @@ pub async fn upload_file( format!("data/{}/versions/{}/{}", project_id, version_id, &file_name); let upload_data = file_host - .upload_file(content_type, &file_path, data) + .upload_file(content_type, &file_path, FileHostPublicity::Public, data) .await?; uploaded_files.push(UploadedFile { - file_id: upload_data.file_id, - file_name: file_path, + name: file_path, + publicity: FileHostPublicity::Public, }); let sha1_bytes = upload_data.content_sha1.into_bytes(); @@ -1072,7 +1069,7 @@ pub fn try_create_version_fields( .filter(|lf| !lf.optional) .map(|lf| lf.field.clone()) .collect::>(); - for (key, value) in submitted_fields.iter() { + for (key, value) in submitted_fields { let loader_field = loader_fields .iter() .find(|lf| &lf.field == key) diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index 680e30293..4dc839b51 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -796,8 +796,7 @@ pub async fn version_list( .filter(|version| { filters .featured - .map(|featured| featured == version.inner.featured) - .unwrap_or(true) + .is_none_or(|featured| featured == version.inner.featured) }) .cloned() .collect::>(); @@ -832,22 +831,20 @@ pub async fn version_list( } joined_filters.into_iter().for_each(|filter| { - versions - .iter() - .find(|version| { - // TODO: This is the bandaid fix for detecting auto-featured versions. - let game_versions = version - .version_fields - .iter() - .find(|vf| vf.field_name == "game_versions") - .map(|vf| vf.value.clone()) - .map(|v| v.as_strings()) - .unwrap_or_default(); - game_versions.contains(&filter.0.version) - && version.loaders.contains(&filter.1.loader) - }) - .map(|version| response.push(version.clone())) - .unwrap_or(()); + if let Some(version) = versions.iter().find(|version| { + // TODO: This is the bandaid fix for detecting auto-featured versions. + let game_versions = version + .version_fields + .iter() + .find(|vf| vf.field_name == "game_versions") + .map(|vf| vf.value.clone()) + .map(|v| v.as_strings()) + .unwrap_or_default(); + game_versions.contains(&filter.0.version) + && version.loaders.contains(&filter.1.loader) + }) { + response.push(version.clone()); + } }); if response.is_empty() { @@ -963,7 +960,7 @@ pub async fn version_delete( ) .await?; transaction.commit().await?; - remove_documents(&[version.inner.id.into()], &search_config).await?; + database::models::DBProject::clear_cache( version.inner.project_id, None, @@ -972,6 +969,7 @@ pub async fn version_delete( &redis, ) .await?; + remove_documents(&[version.inner.id.into()], &search_config).await?; if result.is_some() { Ok(HttpResponse::NoContent().body("")) diff --git a/apps/labrinth/src/search/indexing/local_import.rs b/apps/labrinth/src/search/indexing/local_import.rs index c6d2e408c..4fca8ea33 100644 --- a/apps/labrinth/src/search/indexing/local_import.rs +++ b/apps/labrinth/src/search/indexing/local_import.rs @@ -362,7 +362,7 @@ pub async fn index_local( let (_, v2_og_project_type) = LegacyProject::get_project_type(&project_types); let (client_side, server_side) = - v2_reroute::convert_side_types_v2( + v2_reroute::convert_v3_side_types_to_v2_side_types( &unvectorized_loader_fields, Some(&v2_og_project_type), ); @@ -522,7 +522,7 @@ async fn index_versions( // Convert to partial versions let mut res_versions: HashMap> = HashMap::new(); - for (project_id, version_ids) in versions.iter() { + for (project_id, version_ids) in &versions { for version_id in version_ids { // Extract version-specific data fetched // We use 'remove' as every version is only in the map once diff --git a/apps/labrinth/src/search/indexing/mod.rs b/apps/labrinth/src/search/indexing/mod.rs index 3f4dcdee9..1ecb70ad7 100644 --- a/apps/labrinth/src/search/indexing/mod.rs +++ b/apps/labrinth/src/search/indexing/mod.rs @@ -1,9 +1,13 @@ /// This module is used for the indexing from any source. pub mod local_import; +use std::time::Duration; + use crate::database::redis::RedisPool; use crate::search::{SearchConfig, UploadSearchProject}; use ariadne::ids::base62_impl::to_base62; +use futures::StreamExt; +use futures::stream::FuturesUnordered; use local_import::index_local; use meilisearch_sdk::client::{Client, SwapIndexes}; use meilisearch_sdk::indexes::Index; @@ -11,6 +15,7 @@ use meilisearch_sdk::settings::{PaginationSetting, Settings}; use sqlx::postgres::PgPool; use thiserror::Error; use tracing::info; + #[derive(Error, Debug)] pub enum IndexingError { #[error("Error while connecting to the MeiliSearch database")] @@ -41,12 +46,30 @@ pub async fn remove_documents( let mut indexes_next = get_indexes_for_indexing(config, true).await?; indexes.append(&mut indexes_next); - for index in indexes { - index - .delete_documents( - &ids.iter().map(|x| to_base62(x.0)).collect::>(), - ) - .await?; + let client = config.make_client()?; + let client = &client; + let mut deletion_tasks = FuturesUnordered::new(); + + for index in &indexes { + deletion_tasks.push(async move { + // After being successfully submitted, Meilisearch tasks are executed + // asynchronously, so wait some time for them to complete + index + .delete_documents( + &ids.iter().map(|x| to_base62(x.0)).collect::>(), + ) + .await? + .wait_for_completion( + client, + None, + Some(Duration::from_secs(15)), + ) + .await + }); + } + + while let Some(result) = deletion_tasks.next().await { + result?; } Ok(()) @@ -327,11 +350,8 @@ const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[ "color", // Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist). // TODO: remove these- as they should be automatically populated. This is a band-aid fix. - "server_only", - "client_only", + "environment", "game_versions", - "singleplayer", - "client_and_server", "mrpack_loaders", // V2 legacy fields for logical consistency "client_side", @@ -374,11 +394,8 @@ const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[ "color", // Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist). // TODO: remove these- as they should be automatically populated. This is a band-aid fix. - "server_only", - "client_only", + "environment", "game_versions", - "singleplayer", - "client_and_server", "mrpack_loaders", // V2 legacy fields for logical consistency "client_side", diff --git a/apps/labrinth/src/util/actix.rs b/apps/labrinth/src/util/actix.rs index f0c626b70..b016ec82c 100644 --- a/apps/labrinth/src/util/actix.rs +++ b/apps/labrinth/src/util/actix.rs @@ -13,7 +13,6 @@ pub struct MultipartSegment { } #[derive(Debug, Clone)] -#[allow(dead_code)] pub enum MultipartSegmentData { Text(String), Binary(Vec), diff --git a/apps/labrinth/src/util/env.rs b/apps/labrinth/src/util/env.rs index 9de970c6f..78a5b72de 100644 --- a/apps/labrinth/src/util/env.rs +++ b/apps/labrinth/src/util/env.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -pub fn parse_var(var: &'static str) -> Option { +pub fn parse_var(var: &str) -> Option { dotenvy::var(var).ok().and_then(|i| i.parse().ok()) } pub fn parse_strings_from_var(var: &'static str) -> Option> { diff --git a/apps/labrinth/src/util/ext.rs b/apps/labrinth/src/util/ext.rs index 1f2e9fd38..b54256829 100644 --- a/apps/labrinth/src/util/ext.rs +++ b/apps/labrinth/src/util/ext.rs @@ -1,3 +1,5 @@ +pub const MRPACK_MIME_TYPE: &str = "application/x-modrinth-modpack+zip"; + pub fn get_image_content_type(extension: &str) -> Option<&'static str> { match extension { "bmp" => Some("image/bmp"), @@ -24,7 +26,7 @@ pub fn project_file_type(ext: &str) -> Option<&str> { match ext { "jar" => Some("application/java-archive"), "zip" | "litemod" => Some("application/zip"), - "mrpack" => Some("application/x-modrinth-modpack+zip"), + "mrpack" => Some(MRPACK_MIME_TYPE), _ => None, } } diff --git a/apps/labrinth/src/util/img.rs b/apps/labrinth/src/util/img.rs index 079da9106..680b48417 100644 --- a/apps/labrinth/src/util/img.rs +++ b/apps/labrinth/src/util/img.rs @@ -1,7 +1,7 @@ use crate::database; use crate::database::models::image_item; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::images::ImageContext; use crate::routes::ApiError; use color_thief::ColorFormat; @@ -38,11 +38,14 @@ pub struct UploadImageResult { pub raw_url: String, pub raw_url_path: String, + pub publicity: FileHostPublicity, + pub color: Option, } pub async fn upload_image_optimized( upload_folder: &str, + publicity: FileHostPublicity, bytes: bytes::Bytes, file_extension: &str, target_width: Option, @@ -80,6 +83,7 @@ pub async fn upload_image_optimized( target_width.unwrap_or(0), processed_image_ext ), + publicity, processed_image, ) .await?, @@ -92,22 +96,25 @@ pub async fn upload_image_optimized( .upload_file( content_type, &format!("{upload_folder}/{hash}.{file_extension}"), + publicity, bytes, ) .await?; let url = format!("{}/{}", cdn_url, upload_data.file_name); Ok(UploadImageResult { - url: processed_upload_data - .clone() - .map(|x| format!("{}/{}", cdn_url, x.file_name)) - .unwrap_or_else(|| url.clone()), + url: processed_upload_data.clone().map_or_else( + || url.clone(), + |x| format!("{}/{}", cdn_url, x.file_name), + ), url_path: processed_upload_data - .map(|x| x.file_name) - .unwrap_or_else(|| upload_data.file_name.clone()), + .map_or_else(|| upload_data.file_name.clone(), |x| x.file_name), raw_url: url, raw_url_path: upload_data.file_name, + + publicity, + color, }) } @@ -119,7 +126,7 @@ fn process_image( min_aspect_ratio: Option, ) -> Result<(bytes::Bytes, String), ImageError> { if content_type.to_lowercase() == "image/gif" { - return Ok((image_bytes.clone(), "gif".to_string())); + return Ok((image_bytes, "gif".to_string())); } let mut img = image::load_from_memory(&image_bytes)?; @@ -166,6 +173,7 @@ fn convert_to_webp(img: &DynamicImage) -> Result, ImageError> { pub async fn delete_old_images( image_url: Option, raw_image_url: Option, + publicity: FileHostPublicity, file_host: &dyn FileHost, ) -> Result<(), ApiError> { let cdn_url = dotenvy::var("CDN_URL")?; @@ -174,7 +182,7 @@ pub async fn delete_old_images( let name = image_url.split(&cdn_url_start).nth(1); if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; + file_host.delete_file(icon_path, publicity).await?; } } @@ -182,7 +190,7 @@ pub async fn delete_old_images( let name = raw_image_url.split(&cdn_url_start).nth(1); if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; + file_host.delete_file(icon_path, publicity).await?; } } diff --git a/apps/labrinth/src/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs index 62941f97f..4f7b10342 100644 --- a/apps/labrinth/src/util/ratelimit.rs +++ b/apps/labrinth/src/util/ratelimit.rs @@ -56,18 +56,15 @@ impl AsyncRateLimiter { } pub async fn check_rate_limit(&self, key: &str) -> RateLimitDecision { - let mut conn = match self.redis_pool.connect().await { - Ok(conn) => conn, - Err(_) => { - // If Redis is unavailable, allow the request but with reduced limit - return RateLimitDecision { - allowed: true, - limit: self.params.burst_size, - remaining: 1, - reset_after_ms: 60_000, // 1 minute - retry_after_ms: None, - }; - } + let Ok(mut conn) = self.redis_pool.connect().await else { + // If Redis is unavailable, allow the request but with reduced limit + return RateLimitDecision { + allowed: true, + limit: self.params.burst_size, + remaining: 1, + reset_after_ms: 60_000, // 1 minute + retry_after_ms: None, + }; }; // Get current time in nanoseconds since UNIX epoch diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs index cad439c62..c96393721 100644 --- a/apps/labrinth/src/util/routes.rs +++ b/apps/labrinth/src/util/routes.rs @@ -1,11 +1,14 @@ use crate::routes::ApiError; use crate::routes::v3::project_creation::CreateError; +use crate::util::validate::validation_errors_to_string; use actix_multipart::Field; use actix_web::web::Payload; use bytes::BytesMut; use futures::StreamExt; +use serde::de::DeserializeOwned; +use validator::Validate; -pub async fn read_from_payload( +pub async fn read_limited_from_payload( payload: &mut Payload, cap: usize, err_msg: &'static str, @@ -25,6 +28,28 @@ pub async fn read_from_payload( Ok(bytes) } +pub async fn read_typed_from_payload( + payload: &mut Payload, +) -> Result +where + T: DeserializeOwned + Validate, +{ + let mut bytes = BytesMut::new(); + while let Some(item) = payload.next().await { + bytes.extend_from_slice(&item.map_err(|_| { + ApiError::InvalidInput( + "Unable to parse bytes in payload sent!".to_string(), + ) + })?); + } + + let parsed: T = serde_json::from_slice(&bytes)?; + parsed.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + Ok(parsed) +} + pub async fn read_from_field( field: &mut Field, cap: usize, @@ -32,11 +57,13 @@ pub async fn read_from_field( ) -> Result { let mut bytes = BytesMut::new(); while let Some(chunk) = field.next().await { - if bytes.len() >= cap { + let chunk = chunk?; + + if bytes.len().saturating_add(chunk.len()) > cap { return Err(CreateError::InvalidInput(String::from(err_msg))); - } else { - bytes.extend_from_slice(&chunk?); } + + bytes.extend_from_slice(&chunk); } Ok(bytes) } diff --git a/apps/labrinth/src/util/validate.rs b/apps/labrinth/src/util/validate.rs index ebb95accc..dfa4b7a71 100644 --- a/apps/labrinth/src/util/validate.rs +++ b/apps/labrinth/src/util/validate.rs @@ -1,4 +1,4 @@ -use std::sync::LazyLock; +use std::{fmt::Write, sync::LazyLock}; use itertools::Itertools; use regex::Regex; @@ -44,15 +44,17 @@ pub fn validation_errors_to_string( ValidationErrorsKind::Field(errors) => { if let Some(error) = errors.first() { if let Some(adder) = adder { - output.push_str(&format!( - "Field {} {} failed validation with error: {}", - field, adder, error.code - )); + write!( + &mut output, + "Field {field} {adder} failed validation with error: {}", + error.code + ).unwrap(); } else { - output.push_str(&format!( - "Field {} failed validation with error: {}", - field, error.code - )); + write!( + &mut output, + "Field {field} failed validation with error: {}", + error.code + ).unwrap(); } } diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index 7b0db26a3..88eaa1d6f 100644 --- a/apps/labrinth/src/util/webhook.rs +++ b/apps/labrinth/src/util/webhook.rs @@ -45,7 +45,6 @@ async fn get_webhook_metadata( project_id: ProjectId, pool: &PgPool, redis: &RedisPool, - emoji: bool, ) -> Result, ApiError> { let project = crate::database::models::project_item::DBProject::get_id( project_id.into(), @@ -159,56 +158,13 @@ async fn get_webhook_metadata( categories_formatted: project .categories .into_iter() - .map(|mut x| format!("{}{x}", x.remove(0).to_uppercase())) - .collect::>(), + .map(format_category_or_loader) + .collect(), loaders_formatted: project .inner .loaders .into_iter() - .map(|loader| { - let mut x = if &*loader == "datapack" { - "Data Pack".to_string() - } else if &*loader == "mrpack" { - "Modpack".to_string() - } else { - loader.clone() - }; - - if emoji { - let emoji_id: i64 = match &*loader { - "bukkit" => 1049793345481883689, - "bungeecord" => 1049793347067314220, - "canvas" => 1107352170656968795, - "datapack" => 1057895494652788866, - "fabric" => 1049793348719890532, - "folia" => 1107348745571537018, - "forge" => 1049793350498275358, - "iris" => 1107352171743281173, - "liteloader" => 1049793351630733333, - "minecraft" => 1049793352964526100, - "modloader" => 1049793353962762382, - "neoforge" => 1140437823783190679, - "optifine" => 1107352174415052901, - "paper" => 1049793355598540810, - "purpur" => 1140436034505674762, - "quilt" => 1049793857681887342, - "rift" => 1049793359373414502, - "spigot" => 1049793413886779413, - "sponge" => 1049793416969605231, - "vanilla" => 1107350794178678855, - "velocity" => 1049793419108700170, - "waterfall" => 1049793420937412638, - _ => 1049805243866681424, - }; - - format!( - "<:{loader}:{emoji_id}> {}{x}", - x.remove(0).to_uppercase() - ) - } else { - format!("{}{x}", x.remove(0).to_uppercase()) - } - }) + .map(format_category_or_loader) .collect(), versions_formatted: formatted_game_versions, gallery_image: project @@ -229,7 +185,7 @@ pub async fn send_slack_webhook( webhook_url: String, message: Option, ) -> Result<(), ApiError> { - let metadata = get_webhook_metadata(project_id, pool, redis, false).await?; + let metadata = get_webhook_metadata(project_id, pool, redis).await?; if let Some(metadata) = metadata { let mut blocks = vec![]; @@ -400,7 +356,7 @@ pub async fn send_discord_webhook( webhook_url: String, message: Option, ) -> Result<(), ApiError> { - let metadata = get_webhook_metadata(project_id, pool, redis, true).await?; + let metadata = get_webhook_metadata(project_id, pool, redis).await?; if let Some(project) = metadata { let mut fields = vec![]; @@ -619,3 +575,35 @@ fn get_gv_range( output } + +// Converted from knossos +// See: packages/utils/utils.ts +// https://github.com/modrinth/code/blob/47af459f24e541a844b42b1c8427af6a7b86381e/packages/utils/utils.ts#L147-L196 +fn format_category_or_loader(mut x: String) -> String { + match &*x { + "modloader" => "Risugami's ModLoader".to_string(), + "bungeecord" => "BungeeCord".to_string(), + "liteloader" => "LiteLoader".to_string(), + "neoforge" => "NeoForge".to_string(), + "game-mechanics" => "Game Mechanics".to_string(), + "worldgen" => "World Generation".to_string(), + "core-shaders" => "Core Shaders".to_string(), + "gui" => "GUI".to_string(), + "8x-" => "8x or lower".to_string(), + "512x+" => "512x or higher".to_string(), + "kitchen-sink" => "Kitchen Sink".to_string(), + "path-tracing" => "Path Tracing".to_string(), + "pbr" => "PBR".to_string(), + "datapack" => "Data Pack".to_string(), + "colored-lighting" => "Colored Lighting".to_string(), + "optifine" => "OptiFine".to_string(), + "bta-babric" => "BTA (Babric)".to_string(), + "legacy-fabric" => "Legacy Fabric".to_string(), + "java-agent" => "Java Agent".to_string(), + "nilloader" => "NilLoader".to_string(), + "mrpack" => "Modpack".to_string(), + "minecraft" => "Resource Pack".to_string(), + "vanilla" => "Vanilla Shader".to_string(), + _ => format!("{}{x}", x.remove(0).to_uppercase()), + } +} diff --git a/apps/labrinth/src/validate/datapack.rs b/apps/labrinth/src/validate/datapack.rs index 18cdd7e76..f152486d4 100644 --- a/apps/labrinth/src/validate/datapack.rs +++ b/apps/labrinth/src/validate/datapack.rs @@ -1,8 +1,8 @@ use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, + ValidationError, ValidationResult, }; -use std::io::Cursor; -use zip::ZipArchive; +use chrono::DateTime; pub struct DataPackValidator; @@ -16,19 +16,29 @@ impl super::Validator for DataPackValidator { } fn get_supported_game_versions(&self) -> SupportedGameVersions { - SupportedGameVersions::All + // Time since release of 17w43a, 2017-10-25, which introduced datapacks + SupportedGameVersions::PastDate( + DateTime::from_timestamp(1508889600, 0).unwrap(), + ) } - fn validate( + fn validate_maybe_protected_zip( &self, - archive: &mut ZipArchive>, + file: &mut MaybeProtectedZipFile, ) -> Result { - if archive.by_name("pack.mcmeta").is_err() { - return Ok(ValidationResult::Warning( + if match file { + MaybeProtectedZipFile::Unprotected(archive) => { + archive.by_name("pack.mcmeta").is_ok() + } + MaybeProtectedZipFile::MaybeProtected { data, .. } => { + PLAUSIBLE_PACK_REGEX.is_match(data) + } + } { + Ok(ValidationResult::Pass) + } else { + Ok(ValidationResult::Warning( "No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!", - )); + )) } - - Ok(ValidationResult::Pass) } } diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index ec2db81c8..6304b61dd 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -17,10 +17,14 @@ use crate::validate::rift::RiftValidator; use crate::validate::shader::{ CanvasShaderValidator, CoreShaderValidator, ShaderValidator, }; +use bytes::Bytes; use chrono::{DateTime, Utc}; -use std::io::Cursor; +use std::io::{self, Cursor}; +use std::mem; +use std::sync::LazyLock; use thiserror::Error; use zip::ZipArchive; +use zip::result::ZipError; mod datapack; mod fabric; @@ -77,18 +81,46 @@ pub enum SupportedGameVersions { All, PastDate(DateTime), Range(DateTime, DateTime), - #[allow(dead_code)] Custom(Vec), } +pub enum MaybeProtectedZipFile { + Unprotected(ZipArchive>), + MaybeProtected { read_error: ZipError, data: Bytes }, +} + pub trait Validator: Sync { fn get_file_extensions(&self) -> &[&str]; fn get_supported_loaders(&self) -> &[&str]; fn get_supported_game_versions(&self) -> SupportedGameVersions; + fn validate( &self, archive: &mut ZipArchive>, - ) -> Result; + ) -> Result { + // By default, any non-protected ZIP archive is valid + let _ = archive; + Ok(ValidationResult::Pass) + } + + fn validate_maybe_protected_zip( + &self, + file: &mut MaybeProtectedZipFile, + ) -> Result { + // By default, validate that the ZIP file is not protected, and if so, + // delegate to the inner validate method with a known good archive + match file { + MaybeProtectedZipFile::Unprotected(archive) => { + self.validate(archive) + } + MaybeProtectedZipFile::MaybeProtected { read_error, .. } => { + Err(ValidationError::Zip(mem::replace( + read_error, + ZipError::Io(io::Error::other("ZIP archive reading error")), + ))) + } + } + } } static ALWAYS_ALLOWED_EXT: &[&str] = &["zip", "txt"]; @@ -114,6 +146,29 @@ static VALIDATORS: &[&dyn Validator] = &[ &NeoForgeValidator, ]; +/// A regex that matches a potentially protected ZIP archive containing +/// a vanilla Minecraft pack, with a requisite `pack.mcmeta` file. +/// +/// Please note that this regex avoids false negatives at the cost of false +/// positives being possible, i.e. it may match files that are not actually +/// Minecraft packs, but it will not miss packs that the game can load. +static PLAUSIBLE_PACK_REGEX: LazyLock = + LazyLock::new(|| { + regex::bytes::RegexBuilder::new(concat!( + r"\x50\x4b\x01\x02", // CEN signature + r".{24}", // CEN fields + r"[\x0B\x0C]\x00", // CEN file name length + r".{16}", // More CEN fields + r"pack\.mcmeta/?", // CEN file name + r".*", // Rest of CEN entries and records + r"\x50\x4b\x05\x06", // EOCD signature + )) + .unicode(false) + .dot_matches_new_line(true) + .build() + .unwrap() + }); + /// The return value is whether this file should be marked as primary or not, based on the analysis of the file #[allow(clippy::too_many_arguments)] pub async fn validate_file( @@ -145,7 +200,7 @@ pub async fn validate_file( } async fn validate_minecraft_file( - data: bytes::Bytes, + data: Bytes, file_extension: String, loaders: Vec, game_versions: Vec, @@ -153,13 +208,18 @@ async fn validate_minecraft_file( file_type: Option, ) -> Result { actix_web::web::block(move || { - let reader = Cursor::new(data); - let mut zip = ZipArchive::new(reader)?; + let mut zip = match ZipArchive::new(Cursor::new(Bytes::clone(&data))) { + Ok(zip) => MaybeProtectedZipFile::Unprotected(zip), + Err(read_error) => MaybeProtectedZipFile::MaybeProtected { + read_error, + data, + }, + }; if let Some(file_type) = file_type { match file_type { FileType::RequiredResourcePack | FileType::OptionalResourcePack => { - return PackValidator.validate(&mut zip); + return PackValidator.validate_maybe_protected_zip(&mut zip); } FileType::Unknown => {} } @@ -178,7 +238,7 @@ async fn validate_minecraft_file( ) { if validator.get_file_extensions().contains(&&*file_extension) { - let result = validator.validate(&mut zip)?; + let result = validator.validate_maybe_protected_zip(&mut zip)?; match result { ValidationResult::PassWithPackDataAndFiles { .. } => { saved_result = Some(result); @@ -232,8 +292,7 @@ fn game_version_supported( all_game_versions .iter() .find(|y| y.version == x.version) - .map(|x| x.created > date) - .unwrap_or(false) + .is_some_and(|x| x.created > date) }) } SupportedGameVersions::Range(before, after) => { @@ -241,8 +300,7 @@ fn game_version_supported( all_game_versions .iter() .find(|y| y.version == x.version) - .map(|x| x.created > before && x.created < after) - .unwrap_or(false) + .is_some_and(|x| x.created > before && x.created < after) }) } SupportedGameVersions::Custom(versions) => { diff --git a/apps/labrinth/src/validate/modpack.rs b/apps/labrinth/src/validate/modpack.rs index e05e159ef..8e66005c3 100644 --- a/apps/labrinth/src/validate/modpack.rs +++ b/apps/labrinth/src/validate/modpack.rs @@ -28,14 +28,11 @@ impl super::Validator for ModpackValidator { archive: &mut ZipArchive>, ) -> Result { let pack: PackFormat = { - let mut file = - if let Ok(file) = archive.by_name("modrinth.index.json") { - file - } else { - return Ok(ValidationResult::Warning( - "Pack manifest is missing.", - )); - }; + let Ok(mut file) = archive.by_name("modrinth.index.json") else { + return Ok(ValidationResult::Warning( + "Pack manifest is missing.", + )); + }; let mut contents = String::new(); file.read_to_string(&mut contents)?; @@ -109,7 +106,7 @@ impl super::Validator for ModpackValidator { || x.starts_with("overrides/shaderpacks") || x.starts_with("client-overrides/shaderpacks")) }) - .flat_map(|x| x.rsplit('/').next().map(|x| x.to_string())) + .filter_map(|x| x.rsplit('/').next().map(|x| x.to_string())) .collect::>(), }) } diff --git a/apps/labrinth/src/validate/resourcepack.rs b/apps/labrinth/src/validate/resourcepack.rs index 687c5b4e8..1d9d52c35 100644 --- a/apps/labrinth/src/validate/resourcepack.rs +++ b/apps/labrinth/src/validate/resourcepack.rs @@ -1,5 +1,6 @@ use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, + ValidationError, ValidationResult, }; use chrono::DateTime; use std::io::Cursor; @@ -23,17 +24,24 @@ impl super::Validator for PackValidator { ) } - fn validate( + fn validate_maybe_protected_zip( &self, - archive: &mut ZipArchive>, + file: &mut MaybeProtectedZipFile, ) -> Result { - if archive.by_name("pack.mcmeta").is_err() { - return Ok(ValidationResult::Warning( - "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", - )); + if match file { + MaybeProtectedZipFile::Unprotected(archive) => { + archive.by_name("pack.mcmeta").is_ok() + } + MaybeProtectedZipFile::MaybeProtected { data, .. } => { + PLAUSIBLE_PACK_REGEX.is_match(data) + } + } { + Ok(ValidationResult::Pass) + } else { + Ok(ValidationResult::Warning( + "No pack.mcmeta present for resourcepack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + )) } - - Ok(ValidationResult::Pass) } } diff --git a/apps/labrinth/src/validate/shader.rs b/apps/labrinth/src/validate/shader.rs index 6a83a8195..2ba7d7222 100644 --- a/apps/labrinth/src/validate/shader.rs +++ b/apps/labrinth/src/validate/shader.rs @@ -1,7 +1,8 @@ use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, + ValidationError, ValidationResult, }; -use std::io::Cursor; +use std::{io::Cursor, sync::LazyLock}; use zip::ZipArchive; pub struct ShaderValidator; @@ -83,25 +84,42 @@ impl super::Validator for CoreShaderValidator { SupportedGameVersions::All } - fn validate( + fn validate_maybe_protected_zip( &self, - archive: &mut ZipArchive>, + file: &mut MaybeProtectedZipFile, ) -> Result { - if archive.by_name("pack.mcmeta").is_err() { - return Ok(ValidationResult::Warning( - "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", - )); - }; + static VANILLA_SHADER_CEN_ENTRY_REGEX: LazyLock = + LazyLock::new(|| { + regex::bytes::RegexBuilder::new(concat!( + r"\x50\x4b\x01\x02", // CEN signature + r".{24}", // CEN fields + r".{2}", // CEN file name length + r".{16}", // More CEN fields + r"assets/minecraft/shaders/", // CEN file name + )) + .unicode(false) + .dot_matches_new_line(true) + .build() + .unwrap() + }); - if !archive - .file_names() - .any(|x| x.starts_with("assets/minecraft/shaders/")) - { - return Ok(ValidationResult::Warning( - "No shaders folder present for vanilla shaders.", - )); + if match file { + MaybeProtectedZipFile::Unprotected(archive) => { + archive.by_name("pack.mcmeta").is_ok() + && archive + .file_names() + .any(|x| x.starts_with("assets/minecraft/shaders/")) + } + MaybeProtectedZipFile::MaybeProtected { data, .. } => { + PLAUSIBLE_PACK_REGEX.is_match(data) + && VANILLA_SHADER_CEN_ENTRY_REGEX.is_match(data) + } + } { + Ok(ValidationResult::Pass) + } else { + Ok(ValidationResult::Warning( + "No pack.mcmeta or vanilla shaders folder present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + )) } - - Ok(ValidationResult::Pass) } } diff --git a/apps/labrinth/tests/analytics.rs b/apps/labrinth/tests/analytics.rs index b15dd4d73..217f0e7b1 100644 --- a/apps/labrinth/tests/analytics.rs +++ b/apps/labrinth/tests/analytics.rs @@ -12,7 +12,7 @@ use labrinth::models::teams::ProjectPermissions; use labrinth::queue::payouts; use rust_decimal::{Decimal, prelude::ToPrimitive}; -mod common; +pub mod common; #[actix_rt::test] pub async fn analytics_revenue() { @@ -50,7 +50,7 @@ pub async fn analytics_revenue() { ]; let project_id = parse_base62(&alpha_project_id).unwrap() as i64; - for (money, time) in money_time_pairs.iter() { + for (money, time) in &money_time_pairs { insert_user_ids.push(USER_USER_ID_PARSED); insert_project_ids.push(project_id); insert_payouts.push(Decimal::from_f64_retain(*money).unwrap()); @@ -87,7 +87,7 @@ pub async fn analytics_revenue() { ) .await; assert_eq!(analytics.len(), 1); // 1 project - let project_analytics = analytics.get(&alpha_project_id).unwrap(); + let project_analytics = &analytics[&alpha_project_id]; assert_eq!(project_analytics.len(), 8); // 1 days cut off, and 2 points take place on the same day. note that the day exactly 14 days ago is included // sorted_by_key, values in the order of smallest to largest key let (sorted_keys, sorted_by_key): (Vec, Vec) = @@ -117,7 +117,7 @@ pub async fn analytics_revenue() { USER_USER_PAT, ) .await; - let project_analytics = analytics.get(&alpha_project_id).unwrap(); + let project_analytics = &analytics[&alpha_project_id]; assert_eq!(project_analytics.len(), 9); // and 2 points take place on the same day let (sorted_keys, sorted_by_key): (Vec, Vec) = project_analytics diff --git a/apps/labrinth/tests/common/api_common/models.rs b/apps/labrinth/tests/common/api_common/models.rs index b33324104..454983c2b 100644 --- a/apps/labrinth/tests/common/api_common/models.rs +++ b/apps/labrinth/tests/common/api_common/models.rs @@ -31,13 +31,12 @@ use serde::Deserialize; // as the environment generator for both uses common fields. #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonProject { // For example, for CommonProject, we do not include: // - game_versions (v2 only) // - loader_fields (v3 only) // - etc. - // For any tests that require those fields, we make a separate test with separate API functions tht do not use Common models. + // For any tests that require those fields, we make a separate test with separate API functions that do not use Common models. pub id: ProjectId, pub slug: Option, pub organization: Option, @@ -62,7 +61,6 @@ pub struct CommonProject { pub monetization_status: MonetizationStatus, } #[derive(Deserialize, Clone)] -#[allow(dead_code)] pub struct CommonVersion { pub id: VersionId, pub loaders: Vec, @@ -82,7 +80,6 @@ pub struct CommonVersion { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonLoaderData { pub icon: String, pub name: String, @@ -90,7 +87,6 @@ pub struct CommonLoaderData { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonCategoryData { pub icon: String, pub name: String, @@ -100,7 +96,6 @@ pub struct CommonCategoryData { /// A member of a team #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonTeamMember { pub team_id: TeamId, pub user: User, @@ -114,7 +109,6 @@ pub struct CommonTeamMember { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonNotification { pub id: NotificationId, pub user_id: UserId, @@ -127,7 +121,6 @@ pub struct CommonNotification { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonNotificationAction { pub action_route: (String, String), } @@ -153,7 +146,6 @@ impl CommonItemType { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonReport { pub id: ReportId, pub report_type: String, @@ -175,7 +167,6 @@ pub enum LegacyItemType { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonThread { pub id: ThreadId, #[serde(rename = "type")] @@ -187,7 +178,6 @@ pub struct CommonThread { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonThreadMessage { pub id: ThreadMessageId, pub author_id: Option, @@ -196,7 +186,6 @@ pub struct CommonThreadMessage { } #[derive(Deserialize)] -#[allow(dead_code)] pub enum CommonMessageBody { Text { body: String, @@ -216,7 +205,6 @@ pub enum CommonMessageBody { } #[derive(Deserialize)] -#[allow(dead_code)] pub enum CommonThreadType { Report, Project, @@ -224,7 +212,6 @@ pub enum CommonThreadType { } #[derive(Deserialize)] -#[allow(dead_code)] pub struct CommonUser { pub id: UserId, pub username: String, diff --git a/apps/labrinth/tests/common/api_common/request_data.rs b/apps/labrinth/tests/common/api_common/request_data.rs index eb3a78136..3bfc886aa 100644 --- a/apps/labrinth/tests/common/api_common/request_data.rs +++ b/apps/labrinth/tests/common/api_common/request_data.rs @@ -5,21 +5,18 @@ use labrinth::util::actix::MultipartSegment; use crate::common::dummy_data::TestFile; -#[allow(dead_code)] pub struct ProjectCreationRequestData { pub slug: String, pub jar: Option, pub segment_data: Vec, } -#[allow(dead_code)] pub struct VersionCreationRequestData { pub version: String, pub jar: Option, pub segment_data: Vec, } -#[allow(dead_code)] pub struct ImageData { pub filename: String, pub extension: String, diff --git a/apps/labrinth/tests/common/api_v2/mod.rs b/apps/labrinth/tests/common/api_v2/mod.rs index c1fbb2cb3..20d0e6e3a 100644 --- a/apps/labrinth/tests/common/api_v2/mod.rs +++ b/apps/labrinth/tests/common/api_v2/mod.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use super::{ api_common::{Api, ApiBuildable}, environment::LocalService, diff --git a/apps/labrinth/tests/common/api_v2/project.rs b/apps/labrinth/tests/common/api_v2/project.rs index 90f917bd3..d27eb9adc 100644 --- a/apps/labrinth/tests/common/api_v2/project.rs +++ b/apps/labrinth/tests/common/api_v2/project.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, fmt::Write}; use crate::{ assert_status, @@ -490,13 +490,13 @@ impl ApiProject for ApiV2 { featured = featured ); if let Some(title) = title { - url.push_str(&format!("&title={title}")); + write!(&mut url, "&title={title}").unwrap(); } if let Some(description) = description { - url.push_str(&format!("&description={description}")); + write!(&mut url, "&description={description}").unwrap(); } if let Some(ordering) = ordering { - url.push_str(&format!("&ordering={ordering}")); + write!(&mut url, "&ordering={ordering}").unwrap(); } let req = test::TestRequest::post() @@ -521,11 +521,12 @@ impl ApiProject for ApiV2 { ); for (key, value) in patch { - url.push_str(&format!( + write!( + &mut url, "&{key}={value}", - key = key, value = urlencoding::encode(&value) - )); + ) + .unwrap(); } let req = test::TestRequest::patch() diff --git a/apps/labrinth/tests/common/api_v2/request_data.rs b/apps/labrinth/tests/common/api_v2/request_data.rs index 0862aaf84..11841ead9 100644 --- a/apps/labrinth/tests/common/api_v2/request_data.rs +++ b/apps/labrinth/tests/common/api_v2/request_data.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] use serde_json::json; use crate::common::{ @@ -90,7 +89,7 @@ pub fn get_public_project_creation_data_json( { "title": format!("Test Project {slug}"), "slug": slug, - "project_type": version_jar.as_ref().map(|f| f.project_type()).unwrap_or("mod".to_string()), + "project_type": version_jar.as_ref().map_or("mod".to_string(), |f| f.project_type()), "description": "A dummy project for testing with.", "body": "This project is approved, and versions are listed.", "client_side": "required", diff --git a/apps/labrinth/tests/common/api_v2/version.rs b/apps/labrinth/tests/common/api_v2/version.rs index eeb8337cd..92b73d44e 100644 --- a/apps/labrinth/tests/common/api_v2/version.rs +++ b/apps/labrinth/tests/common/api_v2/version.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::fmt::Write; use super::{ ApiV2, @@ -383,32 +384,36 @@ impl ApiVersion for ApiV2 { ) -> ServiceResponse { let mut query_string = String::new(); if let Some(game_versions) = game_versions { - query_string.push_str(&format!( + write!( + &mut query_string, "&game_versions={}", urlencoding::encode( &serde_json::to_string(&game_versions).unwrap() ) - )); + ) + .unwrap(); } if let Some(loaders) = loaders { - query_string.push_str(&format!( + write!( + &mut query_string, "&loaders={}", urlencoding::encode(&serde_json::to_string(&loaders).unwrap()) - )); + ) + .unwrap(); } if let Some(featured) = featured { - query_string.push_str(&format!("&featured={featured}")); + write!(&mut query_string, "&featured={featured}").unwrap(); } if let Some(version_type) = version_type { - query_string.push_str(&format!("&version_type={version_type}")); + write!(&mut query_string, "&version_type={version_type}").unwrap(); } if let Some(limit) = limit { let limit = limit.to_string(); - query_string.push_str(&format!("&limit={limit}")); + write!(&mut query_string, "&limit={limit}").unwrap(); } if let Some(offset) = offset { let offset = offset.to_string(); - query_string.push_str(&format!("&offset={offset}")); + write!(&mut query_string, "&offset={offset}").unwrap(); } let req = test::TestRequest::get() diff --git a/apps/labrinth/tests/common/api_v3/mod.rs b/apps/labrinth/tests/common/api_v3/mod.rs index 2b2ceb5d9..d44d4c19b 100644 --- a/apps/labrinth/tests/common/api_v3/mod.rs +++ b/apps/labrinth/tests/common/api_v3/mod.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use super::{ api_common::{Api, ApiBuildable}, environment::LocalService, diff --git a/apps/labrinth/tests/common/api_v3/project.rs b/apps/labrinth/tests/common/api_v3/project.rs index 59ad6f8a9..0513206f8 100644 --- a/apps/labrinth/tests/common/api_v3/project.rs +++ b/apps/labrinth/tests/common/api_v3/project.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, fmt::Write}; use actix_http::StatusCode; use actix_web::{ @@ -363,13 +363,13 @@ impl ApiProject for ApiV3 { featured = featured ); if let Some(title) = title { - url.push_str(&format!("&title={title}")); + write!(&mut url, "&title={title}").unwrap(); } if let Some(description) = description { - url.push_str(&format!("&description={description}")); + write!(&mut url, "&description={description}").unwrap(); } if let Some(ordering) = ordering { - url.push_str(&format!("&ordering={ordering}")); + write!(&mut url, "&ordering={ordering}").unwrap(); } let req = test::TestRequest::post() @@ -394,11 +394,12 @@ impl ApiProject for ApiV3 { ); for (key, value) in patch { - url.push_str(&format!( + write!( + &mut url, "&{key}={value}", - key = key, value = urlencoding::encode(&value) - )); + ) + .unwrap(); } let req = test::TestRequest::patch() @@ -593,17 +594,17 @@ impl ApiV3 { let start_date = start_date.to_rfc3339(); // let start_date = serde_json::to_string(&start_date).unwrap(); let start_date = urlencoding::encode(&start_date); - extra_args.push_str(&format!("&start_date={start_date}")); + write!(&mut extra_args, "&start_date={start_date}").unwrap(); } if let Some(end_date) = end_date { let end_date = end_date.to_rfc3339(); // let end_date = serde_json::to_string(&end_date).unwrap(); let end_date = urlencoding::encode(&end_date); - extra_args.push_str(&format!("&end_date={end_date}")); + write!(&mut extra_args, "&end_date={end_date}").unwrap(); } if let Some(resolution_minutes) = resolution_minutes { - extra_args - .push_str(&format!("&resolution_minutes={resolution_minutes}")); + write!(&mut extra_args, "&resolution_minutes={resolution_minutes}") + .unwrap(); } let req = test::TestRequest::get() diff --git a/apps/labrinth/tests/common/api_v3/request_data.rs b/apps/labrinth/tests/common/api_v3/request_data.rs index 3e2586f45..c9774dd0f 100644 --- a/apps/labrinth/tests/common/api_v3/request_data.rs +++ b/apps/labrinth/tests/common/api_v3/request_data.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] use serde_json::json; use crate::common::{ @@ -74,10 +73,7 @@ pub fn get_public_version_creation_data_json( // Loader fields "game_versions": ["1.20.1"], - "singleplayer": true, - "client_and_server": true, - "client_only": true, - "server_only": false, + "environment": "client_only_server_optional", }); if is_modpack { j["mrpack_loaders"] = json!(["fabric"]); diff --git a/apps/labrinth/tests/common/api_v3/version.rs b/apps/labrinth/tests/common/api_v3/version.rs index 7a6f83231..54739cc50 100644 --- a/apps/labrinth/tests/common/api_v3/version.rs +++ b/apps/labrinth/tests/common/api_v3/version.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::fmt::Write; use super::{ ApiV3, @@ -416,32 +417,36 @@ impl ApiVersion for ApiV3 { ) -> ServiceResponse { let mut query_string = String::new(); if let Some(game_versions) = game_versions { - query_string.push_str(&format!( + write!( + &mut query_string, "&game_versions={}", urlencoding::encode( &serde_json::to_string(&game_versions).unwrap() ) - )); + ) + .unwrap(); } if let Some(loaders) = loaders { - query_string.push_str(&format!( + write!( + &mut query_string, "&loaders={}", urlencoding::encode(&serde_json::to_string(&loaders).unwrap()) - )); + ) + .unwrap(); } if let Some(featured) = featured { - query_string.push_str(&format!("&featured={featured}")); + write!(&mut query_string, "&featured={featured}").unwrap(); } if let Some(version_type) = version_type { - query_string.push_str(&format!("&version_type={version_type}")); + write!(&mut query_string, "&version_type={version_type}").unwrap(); } if let Some(limit) = limit { let limit = limit.to_string(); - query_string.push_str(&format!("&limit={limit}")); + write!(&mut query_string, "&limit={limit}").unwrap(); } if let Some(offset) = offset { let offset = offset.to_string(); - query_string.push_str(&format!("&offset={offset}")); + write!(&mut query_string, "&offset={offset}").unwrap(); } let req = test::TestRequest::get() diff --git a/apps/labrinth/tests/common/asserts.rs b/apps/labrinth/tests/common/asserts.rs index f4b7330e7..b28490551 100644 --- a/apps/labrinth/tests/common/asserts.rs +++ b/apps/labrinth/tests/common/asserts.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use crate::common::get_json_val_str; use itertools::Itertools; use labrinth::models::v3::projects::Version; diff --git a/apps/labrinth/tests/common/database.rs b/apps/labrinth/tests/common/database.rs index 33ed2fc28..6297838f2 100644 --- a/apps/labrinth/tests/common/database.rs +++ b/apps/labrinth/tests/common/database.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use labrinth::{database::redis::RedisPool, search}; use sqlx::{PgPool, postgres::PgPoolOptions}; use std::time::Duration; diff --git a/apps/labrinth/tests/common/dummy_data.rs b/apps/labrinth/tests/common/dummy_data.rs index c18f2402c..639a86e83 100644 --- a/apps/labrinth/tests/common/dummy_data.rs +++ b/apps/labrinth/tests/common/dummy_data.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] use std::io::{Cursor, Write}; use crate::{ @@ -28,7 +27,6 @@ use super::{database::USER_USER_ID, get_json_val_str}; pub const DUMMY_DATA_UPDATE: i64 = 7; -#[allow(dead_code)] pub const DUMMY_CATEGORIES: &[&str] = &[ "combat", "decoration", @@ -41,7 +39,6 @@ pub const DUMMY_CATEGORIES: &[&str] = &[ pub const DUMMY_OAUTH_CLIENT_ALPHA_SECRET: &str = "abcdefghijklmnopqrstuvwxyz"; -#[allow(dead_code)] #[derive(Clone)] pub enum TestFile { DummyProjectAlpha, @@ -173,7 +170,6 @@ impl TestFile { } #[derive(Clone)] -#[allow(dead_code)] pub enum DummyImage { SmallIcon, // 200x200 } diff --git a/apps/labrinth/tests/common/environment.rs b/apps/labrinth/tests/common/environment.rs index 8031e816a..ec4079919 100644 --- a/apps/labrinth/tests/common/environment.rs +++ b/apps/labrinth/tests/common/environment.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use super::{ api_common::{Api, ApiBuildable, generic::GenericApi}, api_v2::ApiV2, diff --git a/apps/labrinth/tests/common/pats.rs b/apps/labrinth/tests/common/pats.rs index 1e304fb66..75691817a 100644 --- a/apps/labrinth/tests/common/pats.rs +++ b/apps/labrinth/tests/common/pats.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use chrono::Utc; use labrinth::{ database::{self, models::generate_pat_id}, diff --git a/apps/labrinth/tests/common/permissions.rs b/apps/labrinth/tests/common/permissions.rs index fedaa72ce..e4ce62e1e 100644 --- a/apps/labrinth/tests/common/permissions.rs +++ b/apps/labrinth/tests/common/permissions.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] use actix_http::StatusCode; use actix_web::{dev::ServiceResponse, test}; use futures::Future; diff --git a/apps/labrinth/tests/common/scopes.rs b/apps/labrinth/tests/common/scopes.rs index 637914b81..5d74db600 100644 --- a/apps/labrinth/tests/common/scopes.rs +++ b/apps/labrinth/tests/common/scopes.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] use actix_web::{dev::ServiceResponse, test}; use futures::Future; use labrinth::models::pats::Scopes; diff --git a/apps/labrinth/tests/common/search.rs b/apps/labrinth/tests/common/search.rs index daf7a9eb5..c6c0035ed 100644 --- a/apps/labrinth/tests/common/search.rs +++ b/apps/labrinth/tests/common/search.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::{collections::HashMap, sync::Arc}; use actix_http::StatusCode; @@ -65,7 +63,7 @@ pub async fn setup_search_projects( let id = 0; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[4..6] }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, ])) .unwrap(); @@ -80,7 +78,7 @@ pub async fn setup_search_projects( let id = 1; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" }, ])) .unwrap(); project_creation_futures.push(create_async_future( @@ -94,7 +92,7 @@ pub async fn setup_search_projects( let id = 2; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" }, { "op": "add", "path": "/name", "value": "Mysterious Project" }, ])) .unwrap(); @@ -109,7 +107,7 @@ pub async fn setup_search_projects( let id = 3; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.4"] }, { "op": "add", "path": "/name", "value": "Mysterious Project" }, { "op": "add", "path": "/license_id", "value": "LicenseRef-All-Rights-Reserved" }, @@ -126,7 +124,7 @@ pub async fn setup_search_projects( let id = 4; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, ])) .unwrap(); @@ -141,7 +139,7 @@ pub async fn setup_search_projects( let id = 5; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, ])) @@ -157,8 +155,7 @@ pub async fn setup_search_projects( let id = 6; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server_prefers_both" }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, ])) .unwrap(); @@ -175,8 +172,7 @@ pub async fn setup_search_projects( let id = 7; let modify_json = serde_json::from_value(json!([ { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, - { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, - { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server_prefers_both" }, { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, { "op": "add", "path": "/initial_versions/0/loaders", "value": ["forge"] }, { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.2"] }, diff --git a/apps/labrinth/tests/error.rs b/apps/labrinth/tests/error.rs index 585f37bac..5f53a78df 100644 --- a/apps/labrinth/tests/error.rs +++ b/apps/labrinth/tests/error.rs @@ -7,7 +7,7 @@ use common::api_v3::ApiV3; use common::database::USER_USER_PAT; use common::environment::{TestEnvironment, with_test_environment}; -mod common; +pub mod common; #[actix_rt::test] pub async fn error_404_body() { diff --git a/apps/labrinth/tests/files/dummy_data.sql b/apps/labrinth/tests/files/dummy_data.sql index f3fb1e47d..9866a9d45 100644 --- a/apps/labrinth/tests/files/dummy_data.sql +++ b/apps/labrinth/tests/files/dummy_data.sql @@ -67,8 +67,8 @@ VALUES (2, 'Ordering_Negative1', '{"type":"release","major":false}', -1); INSERT INTO loader_field_enum_values(enum_id, value, metadata, ordering) VALUES (2, 'Ordering_Positive100', '{"type":"release","major":false}', 100); -INSERT INTO loader_fields_loaders(loader_id, loader_field_id) -SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field IN ('game_versions','singleplayer', 'client_and_server', 'client_only', 'server_only') ON CONFLICT DO NOTHING; +INSERT INTO loader_fields_loaders(loader_id, loader_field_id) +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field IN ('game_versions','environment') ON CONFLICT DO NOTHING; INSERT INTO categories (id, category, project_type) VALUES (51, 'combat', 1), @@ -108,6 +108,6 @@ VALUES ( INSERT INTO oauth_client_redirect_uris (id, client_id, uri) VALUES (1, 1, 'https://modrinth.com/oauth_callback'); -- Create dummy data table to mark that this file has been run -CREATE TABLE dummy_data ( +CREATE TABLE dummy_data ( update_id bigint PRIMARY KEY ); diff --git a/apps/labrinth/tests/games.rs b/apps/labrinth/tests/games.rs index 5b423ef46..449f0035d 100644 --- a/apps/labrinth/tests/games.rs +++ b/apps/labrinth/tests/games.rs @@ -5,7 +5,7 @@ use common::{ environment::{TestEnvironment, with_test_environment}, }; -mod common; +pub mod common; #[actix_rt::test] async fn get_games() { diff --git a/apps/labrinth/tests/loader_fields.rs b/apps/labrinth/tests/loader_fields.rs index 7caf8beb4..53a86bfac 100644 --- a/apps/labrinth/tests/loader_fields.rs +++ b/apps/labrinth/tests/loader_fields.rs @@ -17,8 +17,7 @@ use crate::common::dummy_data::{ DummyProjectAlpha, DummyProjectBeta, TestFile, }; -// importing common module. -mod common; +pub mod common; #[actix_rt::test] @@ -115,7 +114,7 @@ async fn creating_loader_fields() { Some( serde_json::from_value(json!([{ "op": "remove", - "path": "/singleplayer" + "path": "/environment" }])) .unwrap(), ), @@ -242,7 +241,7 @@ async fn creating_loader_fields() { USER_USER_PAT, ) .await; - assert_eq!(v.fields.get("test_fabric_optional").unwrap(), &json!(555)); + assert_eq!(&v.fields["test_fabric_optional"], &json!(555)); // - Patch let resp = api .edit_version( @@ -257,7 +256,7 @@ async fn creating_loader_fields() { let v = api .get_version_deserialized(alpha_version_id, USER_USER_PAT) .await; - assert_eq!(v.fields.get("test_fabric_optional").unwrap(), &json!(555)); + assert_eq!(&v.fields["test_fabric_optional"], &json!(555)); // Simply setting them as expected works // - Create @@ -274,32 +273,26 @@ async fn creating_loader_fields() { "value": ["1.20.1", "1.20.2"] }, { "op": "add", - "path": "/singleplayer", - "value": false - }, { - "op": "add", - "path": "/server_only", - "value": true + "path": "/environment", + "value": "client_or_server_prefers_both" }])) .unwrap(), ), USER_USER_PAT, ) .await; + assert_eq!(&v.fields["game_versions"], &json!(["1.20.1", "1.20.2"])); assert_eq!( - v.fields.get("game_versions").unwrap(), - &json!(["1.20.1", "1.20.2"]) + &v.fields["environment"], + &json!("client_or_server_prefers_both") ); - assert_eq!(v.fields.get("singleplayer").unwrap(), &json!(false)); - assert_eq!(v.fields.get("server_only").unwrap(), &json!(true)); // - Patch let resp = api .edit_version( alpha_version_id, json!({ "game_versions": ["1.20.1", "1.20.2"], - "singleplayer": false, - "server_only": true + "environment": "client_or_server_prefers_both" }), USER_USER_PAT, ) @@ -308,10 +301,7 @@ async fn creating_loader_fields() { let v = api .get_version_deserialized(alpha_version_id, USER_USER_PAT) .await; - assert_eq!( - v.fields.get("game_versions").unwrap(), - &json!(["1.20.1", "1.20.2"]) - ); + assert_eq!(&v.fields["game_versions"], &json!(["1.20.1", "1.20.2"])); // Now that we've created a version, we need to make sure that the Project's loader fields are updated (aggregate) // First, add a new version @@ -327,8 +317,8 @@ async fn creating_loader_fields() { "value": ["1.20.5"] }, { "op": "add", - "path": "/singleplayer", - "value": false + "path": "/environment", + "value": "client_or_server" }])) .unwrap(), ), @@ -361,22 +351,15 @@ async fn creating_loader_fields() { ) .await; assert_eq!( - project.fields.get("game_versions").unwrap(), + &project.fields["game_versions"], &[json!("1.20.1"), json!("1.20.2"), json!("1.20.5")] ); assert!( - project - .fields - .get("singleplayer") - .unwrap() - .contains(&json!(false)) + project.fields["environment"].contains(&json!("client_or_server")) ); assert!( - project - .fields - .get("singleplayer") - .unwrap() - .contains(&json!(true)) + project.fields["environment"] + .contains(&json!("client_or_server_prefers_both")) ); }) .await @@ -440,10 +423,7 @@ async fn get_available_loader_fields() { fabric_loader_fields, [ "game_versions", - "singleplayer", - "client_and_server", - "client_only", - "server_only", + "environment", "test_fabric_optional" // exists for testing ] .iter() @@ -463,10 +443,7 @@ async fn get_available_loader_fields() { mrpack_loader_fields, [ "game_versions", - "singleplayer", - "client_and_server", - "client_only", - "server_only", + "environment", // mrpack has all the general fields as well as this "mrpack_loaders" ] @@ -533,7 +510,7 @@ async fn test_multi_get_redis_cache() { assert_eq!(projects.len(), 10); // Ensure all 5 modpacks have 'mrpack_loaders', and all 5 mods do not - for project in projects.iter() { + for project in &projects { if modpacks.contains(project.slug.as_ref().unwrap()) { assert!(project.fields.contains_key("mrpack_loaders")); } else if mods.contains(project.slug.as_ref().unwrap()) { @@ -566,7 +543,7 @@ async fn test_multi_get_redis_cache() { assert_eq!(versions.len(), 10); // Ensure all 5 versions from modpacks have 'mrpack_loaders', and all 5 versions from mods do not - for version in versions.iter() { + for version in &versions { if version_ids_modpacks.contains(&version.id) { assert!(version.fields.contains_key("mrpack_loaders")); } else if version_ids_mods.contains(&version.id) { diff --git a/apps/labrinth/tests/notifications.rs b/apps/labrinth/tests/notifications.rs index d63fc819a..d4d6ce659 100644 --- a/apps/labrinth/tests/notifications.rs +++ b/apps/labrinth/tests/notifications.rs @@ -5,7 +5,7 @@ use common::{ use crate::common::api_common::ApiTeams; -mod common; +pub mod common; #[actix_rt::test] pub async fn get_user_notifications_after_team_invitation_returns_notification() diff --git a/apps/labrinth/tests/oauth.rs b/apps/labrinth/tests/oauth.rs index d10bfe353..ee4d53745 100644 --- a/apps/labrinth/tests/oauth.rs +++ b/apps/labrinth/tests/oauth.rs @@ -16,7 +16,7 @@ use common::{ }; use labrinth::auth::oauth::TokenResponse; -mod common; +pub mod common; #[actix_rt::test] async fn oauth_flow_happy_path() { diff --git a/apps/labrinth/tests/oauth_clients.rs b/apps/labrinth/tests/oauth_clients.rs index 3ababa4ad..8dd8604c5 100644 --- a/apps/labrinth/tests/oauth_clients.rs +++ b/apps/labrinth/tests/oauth_clients.rs @@ -17,7 +17,7 @@ use labrinth::{ use common::database::USER_USER_ID_PARSED; -mod common; +pub mod common; #[actix_rt::test] async fn can_create_edit_get_oauth_client() { diff --git a/apps/labrinth/tests/organizations.rs b/apps/labrinth/tests/organizations.rs index 2e5f259fe..193305eb7 100644 --- a/apps/labrinth/tests/organizations.rs +++ b/apps/labrinth/tests/organizations.rs @@ -22,7 +22,7 @@ use common::{ use labrinth::models::teams::{OrganizationPermissions, ProjectPermissions}; use serde_json::json; -mod common; +pub mod common; #[actix_rt::test] async fn create_organization() { @@ -583,9 +583,7 @@ async fn add_remove_organization_project_ownership_to_user() { .await; assert_eq!(members.len(), 1); assert_eq!(members[0].user.id.to_string(), FRIEND_USER_ID); - let user_member = - members.iter().filter(|m| m.is_owner).collect::>(); - assert_eq!(user_member.len(), 0); + assert_eq!(members.iter().filter(|m| m.is_owner).count(), 0); // Beta project should have: // - No members @@ -836,9 +834,7 @@ async fn delete_organization_means_all_projects_to_org_owner() { .api .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) .await; - let user_member = - members.iter().filter(|m| m.is_owner).collect::>(); - assert_eq!(user_member.len(), 0); + assert_eq!(members.iter().filter(|m| m.is_owner).count(), 0); // Transfer ownership of zeta organization to FRIEND let resp = test_env @@ -856,9 +852,7 @@ async fn delete_organization_means_all_projects_to_org_owner() { .api .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) .await; - let user_member = - members.iter().filter(|m| m.is_owner).collect::>(); - assert_eq!(user_member.len(), 0); + assert_eq!(members.iter().filter(|m| m.is_owner).count(), 0); // Delete organization let resp = test_env diff --git a/apps/labrinth/tests/pats.rs b/apps/labrinth/tests/pats.rs index 70afaf26b..1da071282 100644 --- a/apps/labrinth/tests/pats.rs +++ b/apps/labrinth/tests/pats.rs @@ -8,7 +8,7 @@ use serde_json::json; use crate::common::api_common::AppendsOptionalPat; -mod common; +pub mod common; // Full pat test: // - create a PAT and ensure it can be used for the scope diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs index 1c202935b..977e1b09c 100644 --- a/apps/labrinth/tests/project.rs +++ b/apps/labrinth/tests/project.rs @@ -27,7 +27,7 @@ use labrinth::util::actix::{MultipartSegment, MultipartSegmentData}; use serde_json::json; use sha1::Digest; -mod common; +pub mod common; #[actix_rt::test] async fn test_get_project() { diff --git a/apps/labrinth/tests/scopes.rs b/apps/labrinth/tests/scopes.rs index c71a74e37..3e253116c 100644 --- a/apps/labrinth/tests/scopes.rs +++ b/apps/labrinth/tests/scopes.rs @@ -29,7 +29,7 @@ use serde_json::json; // - test the function with the PAT with the given scopes // - test the function with the PAT with all other scopes -mod common; +pub mod common; // Test for users, emails, and payout scopes (not user auth scope or notifs) #[actix_rt::test] diff --git a/apps/labrinth/tests/search.rs b/apps/labrinth/tests/search.rs index e8562f5c7..f45c4baf4 100644 --- a/apps/labrinth/tests/search.rs +++ b/apps/labrinth/tests/search.rs @@ -14,7 +14,7 @@ use serde_json::json; use crate::common::api_common::Api; use crate::common::api_common::ApiProject; -mod common; +pub mod common; // TODO: Revisit this wit h the new modify_json in the version maker // That change here should be able to simplify it vastly @@ -52,8 +52,11 @@ async fn search_projects() { vec![1, 2, 3, 4], ), (json!([["project_types:modpack"]]), vec![4]), - (json!([["client_only:true"]]), vec![0, 2, 3, 7, 9]), - (json!([["server_only:true"]]), vec![0, 2, 3, 6, 7]), + (json!([["environment:server_only"]]), vec![0, 2, 3]), + ( + json!([["environment:client_or_server_prefers_both"]]), + vec![6, 7], + ), (json!([["open_source:true"]]), vec![0, 1, 2, 4, 5, 6, 7, 9]), (json!([["license:MIT"]]), vec![1, 2, 4, 9]), (json!([[r#"name:'Mysterious Project'"#]]), vec![2, 3]), @@ -151,22 +154,7 @@ async fn index_swaps() { test_env.api.remove_project("alpha", USER_USER_PAT).await; assert_status!(&resp, StatusCode::NO_CONTENT); - // Deletions should not be indexed immediately - let projects = test_env - .api - .search_deserialized( - None, - Some(json!([["categories:fabric"]])), - USER_USER_PAT, - ) - .await; - assert_eq!(projects.total_hits, 1); - assert!(projects.hits[0].slug.as_ref().unwrap().contains("alpha")); - - // But when we reindex, it should be gone - let resp = test_env.api.reset_search_index().await; - assert_status!(&resp, StatusCode::NO_CONTENT); - + // We should wait for deletions to be indexed let projects = test_env .api .search_deserialized( @@ -177,7 +165,7 @@ async fn index_swaps() { .await; assert_eq!(projects.total_hits, 0); - // Reindex again, should still be gone + // When we reindex, it should be still gone let resp = test_env.api.reset_search_index().await; assert_status!(&resp, StatusCode::NO_CONTENT); diff --git a/apps/labrinth/tests/tags.rs b/apps/labrinth/tests/tags.rs index 8f4295dda..4cd93d60a 100644 --- a/apps/labrinth/tests/tags.rs +++ b/apps/labrinth/tests/tags.rs @@ -9,7 +9,7 @@ use common::{ use crate::common::api_common::ApiTags; -mod common; +pub mod common; #[actix_rt::test] async fn get_tags() { diff --git a/apps/labrinth/tests/teams.rs b/apps/labrinth/tests/teams.rs index 4645eec92..13c41f563 100644 --- a/apps/labrinth/tests/teams.rs +++ b/apps/labrinth/tests/teams.rs @@ -10,7 +10,7 @@ use labrinth::models::teams::{OrganizationPermissions, ProjectPermissions}; use rust_decimal::Decimal; use serde_json::json; -mod common; +pub mod common; #[actix_rt::test] async fn test_get_team() { diff --git a/apps/labrinth/tests/user.rs b/apps/labrinth/tests/user.rs index d01ffb821..340b50031 100644 --- a/apps/labrinth/tests/user.rs +++ b/apps/labrinth/tests/user.rs @@ -5,7 +5,7 @@ use common::{ environment::with_test_environment_all, }; -mod common; +pub mod common; // user GET (permissions, different users) // users GET diff --git a/apps/labrinth/tests/v2/version.rs b/apps/labrinth/tests/v2/version.rs index dd9757ff6..f0d993e45 100644 --- a/apps/labrinth/tests/v2/version.rs +++ b/apps/labrinth/tests/v2/version.rs @@ -219,7 +219,7 @@ async fn version_updates() { // Add 3 new versions, 1 before, and 2 after, with differing game_version/version_types/loaders let mut update_ids = vec![]; - for (version_number, patch_value) in [ + for (version_number, patch_value) in &[ ( "0.9.9", json!({ @@ -241,9 +241,7 @@ async fn version_updates() { "version_type": "beta" }), ), - ] - .iter() - { + ] { let version = api .add_public_version_deserialized_common( *alpha_project_id_parsed, diff --git a/apps/labrinth/tests/v2_tests.rs b/apps/labrinth/tests/v2_tests.rs index 808bcb1bd..69ea83705 100644 --- a/apps/labrinth/tests/v2_tests.rs +++ b/apps/labrinth/tests/v2_tests.rs @@ -1,5 +1,4 @@ -// importing common module. -mod common; +pub mod common; // Not all tests expect exactly the same functionality in v2 and v3. // For example, though we expect the /GET version to return the corresponding project, diff --git a/apps/labrinth/tests/version.rs b/apps/labrinth/tests/version.rs index bd86f8984..d6239bb14 100644 --- a/apps/labrinth/tests/version.rs +++ b/apps/labrinth/tests/version.rs @@ -22,8 +22,7 @@ use labrinth::models::projects::{ use labrinth::routes::v3::version_file::FileUpdateData; use serde_json::json; -// importing common module. -mod common; +pub mod common; #[actix_rt::test] async fn test_get_version() { @@ -164,7 +163,7 @@ async fn version_updates() { // Add 3 new versions, 1 before, and 2 after, with differing game_version/version_types/loaders let mut update_ids = vec![]; - for (version_number, patch_value) in [ + for (version_number, patch_value) in &[ ( "0.9.9", json!({ @@ -186,9 +185,7 @@ async fn version_updates() { "version_type": "beta" }), ), - ] - .iter() - { + ] { let version = api .add_public_version_deserialized( *alpha_project_id_parsed, diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 000000000..434acf91a --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +allow-dbg-in-tests = true +msrv = "1.88.0" diff --git a/docker-compose.yml b/docker-compose.yml index 4b4d073cd..21e8c266c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,10 +6,14 @@ services: ports: - '5432:5432' environment: - POSTGRES_DB: postgres POSTGRES_USER: labrinth POSTGRES_PASSWORD: labrinth POSTGRES_HOST_AUTH_METHOD: trust + healthcheck: + test: ['CMD', 'pg_isready'] + interval: 3s + timeout: 5s + retries: 3 meilisearch: image: getmeili/meilisearch:v1.12.0 restart: on-failure @@ -20,6 +24,11 @@ services: environment: MEILI_MASTER_KEY: modrinth MEILI_HTTP_PAYLOAD_SIZE_LIMIT: 107374182400 + healthcheck: + test: ['CMD', 'curl', '--fail', 'http://localhost:7700/health'] + interval: 3s + timeout: 5s + retries: 3 redis: image: redis:alpine restart: on-failure @@ -27,6 +36,11 @@ services: - '6379:6379' volumes: - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'PING'] + interval: 3s + timeout: 5s + retries: 3 clickhouse: image: clickhouse/clickhouse-server ports: @@ -34,6 +48,11 @@ services: environment: CLICKHOUSE_USER: default CLICKHOUSE_PASSWORD: default + healthcheck: + test: ['CMD', 'clickhouse-client', '--query', 'SELECT 1'] + interval: 3s + timeout: 5s + retries: 3 volumes: meilisearch-data: db-data: diff --git a/package.json b/package.json index 575ac166a..f5ac243e3 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "lint": "turbo run lint --continue", "test": "turbo run test --continue", "fix": "turbo run fix --continue", + "ci": "turbo run lint test --continue", "intl:extract": "pnpm ui:intl:extract && pnpm web:intl:extract && pnpm app:intl:extract" }, "devDependencies": { + "if-ci": "^3.0.0", "prettier": "^3.3.2", - "turbo": "^2.2.3", + "turbo": "^2.5.4", "vue": "^3.5.13" }, "packageManager": "pnpm@9.15.0", diff --git a/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json new file mode 100644 index 000000000..26c250c78 --- /dev/null +++ b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22" +} diff --git a/packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json b/packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json similarity index 88% rename from packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json rename to packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json index 0e8fd8613..2fce764bc 100644 --- a/packages/app-lib/.sqlx/query-759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d.json +++ b/packages/app-lib/.sqlx/query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27\n ", + "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ", "describe": { "columns": [], "parameters": { - "Right": 27 + "Right": 28 }, "nullable": [] }, - "hash": "759e4ffe30ebc4f8602256cb419ef15732d84bcebb9ca15225dbabdc0f46ba2d" + "hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c" } diff --git a/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json new file mode 100644 index 000000000..cf3645df1 --- /dev/null +++ b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944" +} diff --git a/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json new file mode 100644 index 000000000..f34447870 --- /dev/null +++ b/packages/app-lib/.sqlx/query-3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + "describe": { + "columns": [ + { + "name": "texture", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "3f3d3c2d77c1bcaf4044b612c3822546583aa19ea7088682d718c64ed5d5f1c5" +} diff --git a/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json b/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json new file mode 100644 index 000000000..4b7932bbe --- /dev/null +++ b/packages/app-lib/.sqlx/query-4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9" +} diff --git a/packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json b/packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json similarity index 80% rename from packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json rename to packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json index 72b34a957..5dc714e29 100644 --- a/packages/app-lib/.sqlx/query-d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9.json +++ b/packages/app-lib/.sqlx/query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ", + "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ", "describe": { "columns": [ { @@ -29,113 +29,118 @@ "type_info": "Integer" }, { - "name": "advanced_rendering", + "name": "hide_nametag_skins_page", "ordinal": 5, "type_info": "Integer" }, { - "name": "native_decorations", + "name": "advanced_rendering", "ordinal": 6, "type_info": "Integer" }, { - "name": "discord_rpc", + "name": "native_decorations", "ordinal": 7, "type_info": "Integer" }, { - "name": "developer_mode", + "name": "discord_rpc", "ordinal": 8, "type_info": "Integer" }, { - "name": "telemetry", + "name": "developer_mode", "ordinal": 9, "type_info": "Integer" }, { - "name": "personalized_ads", + "name": "telemetry", "ordinal": 10, "type_info": "Integer" }, { - "name": "onboarded", + "name": "personalized_ads", "ordinal": 11, "type_info": "Integer" }, { - "name": "extra_launch_args", + "name": "onboarded", "ordinal": 12, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "custom_env_vars", + "name": "extra_launch_args", "ordinal": 13, "type_info": "Text" }, { - "name": "mc_memory_max", + "name": "custom_env_vars", "ordinal": 14, - "type_info": "Integer" + "type_info": "Text" }, { - "name": "mc_force_fullscreen", + "name": "mc_memory_max", "ordinal": 15, "type_info": "Integer" }, { - "name": "mc_game_resolution_x", + "name": "mc_force_fullscreen", "ordinal": 16, "type_info": "Integer" }, { - "name": "mc_game_resolution_y", + "name": "mc_game_resolution_x", "ordinal": 17, "type_info": "Integer" }, { - "name": "hide_on_process_start", + "name": "mc_game_resolution_y", "ordinal": 18, "type_info": "Integer" }, { - "name": "hook_pre_launch", + "name": "hide_on_process_start", "ordinal": 19, - "type_info": "Text" + "type_info": "Integer" }, { - "name": "hook_wrapper", + "name": "hook_pre_launch", "ordinal": 20, "type_info": "Text" }, { - "name": "hook_post_exit", + "name": "hook_wrapper", "ordinal": 21, "type_info": "Text" }, { - "name": "custom_dir", + "name": "hook_post_exit", "ordinal": 22, "type_info": "Text" }, { - "name": "prev_custom_dir", + "name": "custom_dir", "ordinal": 23, "type_info": "Text" }, { - "name": "migrated", + "name": "prev_custom_dir", "ordinal": 24, + "type_info": "Text" + }, + { + "name": "migrated", + "ordinal": 25, "type_info": "Integer" }, { "name": "feature_flags", - "ordinal": 25, + "ordinal": 26, "type_info": "Text" }, { "name": "toggle_sidebar", - "ordinal": 26, + "ordinal": 27, "type_info": "Integer" } ], @@ -155,6 +160,7 @@ false, false, false, + false, null, null, false, @@ -172,5 +178,5 @@ false ] }, - "hash": "d90a2f2f823fc546661a94af07249758c5ca82db396268bca5087bac88f733d9" + "hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca" } diff --git a/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json new file mode 100644 index 000000000..ee92d633c --- /dev/null +++ b/packages/app-lib/.sqlx/query-545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "545b01d8cc1e79ff5d4136887fbb712aba58908a66dd7bbd64c293b9ee7a1523" +} diff --git a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json index 22e39e75b..9742cb7b4 100644 --- a/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json +++ b/packages/app-lib/.sqlx/query-6e3fa492c085ebb8e7280dd4d55cdcf73da199ea6ac05ee3ee798ece80d877cf.json @@ -41,7 +41,7 @@ { "name": "display_claims!: serde_json::Value", "ordinal": 7, - "type_info": "Text" + "type_info": "Null" } ], "parameters": { diff --git a/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json new file mode 100644 index 000000000..2c946cb4e --- /dev/null +++ b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + "describe": { + "columns": [ + { + "name": "id: Hyphenated", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246" +} diff --git a/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json new file mode 100644 index 000000000..4d0c3892f --- /dev/null +++ b/packages/app-lib/.sqlx/query-aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "texture_key", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "variant: MinecraftSkinVariant", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "cape_id: Hyphenated", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac" +} diff --git a/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json b/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json new file mode 100644 index 000000000..a09ac2ff7 --- /dev/null +++ b/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681" +} diff --git a/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json b/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json new file mode 100644 index 000000000..ad8564624 --- /dev/null +++ b/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20" +} diff --git a/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json b/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json new file mode 100644 index 000000000..ee41aad88 --- /dev/null +++ b/packages/app-lib/.sqlx/query-fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "fd494269d944b179ade61876669d1821b46d60f5cb79685c489aaebf13b35d24" +} diff --git a/packages/app-lib/.taurignore b/packages/app-lib/.taurignore new file mode 100644 index 000000000..9b48651bb --- /dev/null +++ b/packages/app-lib/.taurignore @@ -0,0 +1,3 @@ +# State files generated by Gradle on build. If not ignored for Tauri, +# cargo tauri dev gets softlocked due to these files changing for every build +/java/.gradle diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 275a31c06..85200eb25 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -1,14 +1,15 @@ [package] name = "theseus" -version = "0.9.5" +version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging authors = ["Jai A "] -edition = "2024" +edition.workspace = true [dependencies] -bytes.workspace = true +bytes = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true serde_ini.workspace = true +serde_with.workspace = true sha1_smol.workspace = true sha2.workspace = true url = { workspace = true, features = ["serde"] } @@ -20,6 +21,12 @@ tempfile.workspace = true dashmap = { workspace = true, features = ["serde"] } quick-xml = { workspace = true, features = ["async-tokio"] } enumset.workspace = true +chardetng.workspace = true +encoding_rs.workspace = true +hashlink.workspace = true +png.workspace = true +bytemuck.workspace = true +rgb.workspace = true chrono = { workspace = true, features = ["serde"] } daedalus.workspace = true @@ -29,21 +36,23 @@ regex.workspace = true sysinfo = { workspace = true, features = ["system", "disk"] } thiserror.workspace = true either.workspace = true +data-url.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] } tracing-error.workspace = true paste.workspace = true +heck.workspace = true -tauri = { workspace = true, optional = true } +tauri = { workspace = true, optional = true, features = ["unstable"] } indicatif = { workspace = true, optional = true } async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] } futures = { workspace = true, features = ["async-await", "alloc"] } -reqwest = { workspace = true, features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls-webpki-roots", "charset", "http2", "macos-system-configuration"] } +reqwest = { workspace = true, features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls-webpki-roots", "charset", "http2", "macos-system-configuration", "multipart"] } tokio = { workspace = true, features = ["time", "io-util", "net", "sync", "fs", "macros", "process"] } -tokio-util = { workspace = true, features = ["compat"] } +tokio-util = { workspace = true, features = ["compat", "io", "io-util"] } async-recursion.workspace = true fs4 = { workspace = true, features = ["tokio"] } async-walkdir.workspace = true @@ -62,7 +71,7 @@ p256 = { workspace = true, features = ["ecdsa"] } rand.workspace = true base64.workspace = true -sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "macros", "migrate", "json"] } +sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "macros", "migrate", "json", "uuid"] } quartz_nbt = { workspace = true, features = ["serde"] } hickory-resolver.workspace = true @@ -72,6 +81,12 @@ ariadne.workspace = true [target.'cfg(windows)'.dependencies] winreg.workspace = true +[build-dependencies] +dunce.workspace = true + [features] tauri = ["dep:tauri"] cli = ["dep:indicatif"] + +[lints] +workspace = true diff --git a/packages/app-lib/build.rs b/packages/app-lib/build.rs new file mode 100644 index 000000000..251c4e848 --- /dev/null +++ b/packages/app-lib/build.rs @@ -0,0 +1,44 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::{Command, exit}; +use std::{env, fs}; + +fn main() { + println!("cargo::rerun-if-changed=java/gradle"); + println!("cargo::rerun-if-changed=java/src"); + println!("cargo::rerun-if-changed=java/build.gradle.kts"); + println!("cargo::rerun-if-changed=java/settings.gradle.kts"); + println!("cargo::rerun-if-changed=java/gradle.properties"); + + let out_dir = + dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap())) + .unwrap(); + + println!( + "cargo::rustc-env=JAVA_JARS_DIR={}", + out_dir.join("java/libs").display() + ); + + let gradle_path = fs::canonicalize( + #[cfg(target_os = "windows")] + "java\\gradlew.bat", + #[cfg(not(target_os = "windows"))] + "java/gradlew", + ) + .unwrap(); + + let mut build_dir_str = OsString::from("-Dorg.gradle.project.buildDir="); + build_dir_str.push(out_dir.join("java")); + let exit_status = Command::new(gradle_path) + .arg(build_dir_str) + .arg("build") + .arg("--no-daemon") + .arg("--console=rich") + .current_dir(dunce::canonicalize("java").unwrap()) + .status() + .expect("Failed to wait on Gradle build"); + if !exit_status.success() { + println!("cargo::error=Gradle build failed with {exit_status}"); + exit(exit_status.code().unwrap_or(1)); + } +} diff --git a/packages/app-lib/java/.gitattributes b/packages/app-lib/java/.gitattributes new file mode 100644 index 000000000..f91f64602 --- /dev/null +++ b/packages/app-lib/java/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/packages/app-lib/java/.gitignore b/packages/app-lib/java/.gitignore new file mode 100644 index 000000000..1b6985c00 --- /dev/null +++ b/packages/app-lib/java/.gitignore @@ -0,0 +1,5 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/packages/app-lib/java/build.gradle.kts b/packages/app-lib/java/build.gradle.kts new file mode 100644 index 000000000..b7f9ede04 --- /dev/null +++ b/packages/app-lib/java/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + java + id("com.diffplug.spotless") version "7.0.4" +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(11) + } +} + +tasks.withType().configureEach { + options.release = 8 + options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) +} + +spotless { + java { + palantirJavaFormat() + removeUnusedImports() + } +} + +tasks.jar { + archiveFileName = "theseus.jar" +} + +tasks.named("test") { + useJUnitPlatform() +} + +tasks.withType().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} diff --git a/packages/app-lib/java/gradle.properties b/packages/app-lib/java/gradle.properties new file mode 100644 index 000000000..377538c99 --- /dev/null +++ b/packages/app-lib/java/gradle.properties @@ -0,0 +1,5 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true + diff --git a/packages/app-lib/java/gradle/libs.versions.toml b/packages/app-lib/java/gradle/libs.versions.toml new file mode 100644 index 000000000..cd6495556 --- /dev/null +++ b/packages/app-lib/java/gradle/libs.versions.toml @@ -0,0 +1,5 @@ +[versions] +junit-jupiter = "5.12.1" + +[libraries] +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } diff --git a/packages/app-lib/java/gradle/wrapper/gradle-wrapper.jar b/packages/app-lib/java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..1b33c55ba Binary files /dev/null and b/packages/app-lib/java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/packages/app-lib/java/gradle/wrapper/gradle-wrapper.properties b/packages/app-lib/java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ff23a68d7 --- /dev/null +++ b/packages/app-lib/java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/app-lib/java/gradlew b/packages/app-lib/java/gradlew new file mode 100755 index 000000000..23d15a936 --- /dev/null +++ b/packages/app-lib/java/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/app-lib/java/gradlew.bat b/packages/app-lib/java/gradlew.bat new file mode 100644 index 000000000..db3a6ac20 --- /dev/null +++ b/packages/app-lib/java/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/app-lib/java/settings.gradle.kts b/packages/app-lib/java/settings.gradle.kts new file mode 100644 index 000000000..01d2944d7 --- /dev/null +++ b/packages/app-lib/java/settings.gradle.kts @@ -0,0 +1,6 @@ +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} + +rootProject.name = "theseus" diff --git a/packages/app-lib/library/JavaInfo.java b/packages/app-lib/java/src/main/java/com/modrinth/theseus/JavaInfo.java similarity index 88% rename from packages/app-lib/library/JavaInfo.java rename to packages/app-lib/java/src/main/java/com/modrinth/theseus/JavaInfo.java index 542fc6078..ef182e9f9 100644 --- a/packages/app-lib/library/JavaInfo.java +++ b/packages/app-lib/java/src/main/java/com/modrinth/theseus/JavaInfo.java @@ -1,8 +1,7 @@ +package com.modrinth.theseus; + public final class JavaInfo { - private static final String[] CHECKED_PROPERTIES = new String[] { - "os.arch", - "java.version" - }; + private static final String[] CHECKED_PROPERTIES = new String[] {"os.arch", "java.version"}; public static void main(String[] args) { int returnCode = 0; @@ -19,4 +18,4 @@ public final class JavaInfo { System.exit(returnCode); } -} \ No newline at end of file +} diff --git a/packages/app-lib/java/src/main/java/com/modrinth/theseus/MinecraftLaunch.java b/packages/app-lib/java/src/main/java/com/modrinth/theseus/MinecraftLaunch.java new file mode 100644 index 000000000..9d61a0c0b --- /dev/null +++ b/packages/app-lib/java/src/main/java/com/modrinth/theseus/MinecraftLaunch.java @@ -0,0 +1,130 @@ +package com.modrinth.theseus; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; + +public final class MinecraftLaunch { + public static void main(String[] args) throws IOException, ReflectiveOperationException { + final String mainClass = args[0]; + final String[] gameArgs = Arrays.copyOfRange(args, 1, args.length); + + System.setProperty("modrinth.process.args", String.join("\u001f", gameArgs)); + parseInput(); + + relaunch(mainClass, gameArgs); + } + + private static void parseInput() throws IOException { + final ByteArrayOutputStream line = new ByteArrayOutputStream(); + while (true) { + final int b = System.in.read(); + if (b < 0) { + throw new IllegalStateException("Stdin terminated while parsing"); + } + if (b != '\n') { + line.write(b); + continue; + } + if (handleLine(line.toString("UTF-8"))) { + break; + } + line.reset(); + } + } + + private static boolean handleLine(String line) { + final String[] parts = line.split("\t", 2); + switch (parts[0]) { + case "property": { + final String[] keyValue = parts[1].split("\t", 2); + System.setProperty(keyValue[0], keyValue[1]); + return false; + } + case "launch": + return true; + } + + System.err.println("Unknown input line " + line); + return false; + } + + private static void relaunch(String mainClassName, String[] args) throws ReflectiveOperationException { + final int javaVersion = getJavaVersion(); + final Class mainClass = Class.forName(mainClassName); + + if (javaVersion >= 25) { + Method mainMethod; + try { + mainMethod = findMainMethodJep512(mainClass); + } catch (ReflectiveOperationException e) { + System.err.println( + "[MODRINTH] Unable to call JDK findMainMethod. Falling back to pre-Java 25 main method finding."); + // If the above fails due to JDK implementation details changing + try { + mainMethod = findSimpleMainMethod(mainClass); + } catch (ReflectiveOperationException innerE) { + e.addSuppressed(innerE); + throw e; + } + } + if (mainMethod == null) { + throw new IllegalArgumentException("Could not find main() method"); + } + + Object thisObject = null; + if (!Modifier.isStatic(mainMethod.getModifiers())) { + thisObject = forceAccessible(mainClass.getDeclaredConstructor()).newInstance(); + } + + final Object[] parameters = mainMethod.getParameterCount() > 0 ? new Object[] {args} : new Object[] {}; + + mainMethod.invoke(thisObject, parameters); + } else { + forceAccessible(findSimpleMainMethod(mainClass)).invoke(null, new Object[] {args}); + } + } + + private static int getJavaVersion() { + String javaVersion = System.getProperty("java.version"); + + final int dotIndex = javaVersion.indexOf('.'); + if (dotIndex != -1) { + javaVersion = javaVersion.substring(0, dotIndex); + } + + final int dashIndex = javaVersion.indexOf('-'); + if (dashIndex != -1) { + javaVersion = javaVersion.substring(0, dashIndex); + } + + return Integer.parseInt(javaVersion); + } + + private static Method findMainMethodJep512(Class mainClass) throws ReflectiveOperationException { + // BEWARE BELOW: This code may break if JDK internals to find the main method + // change. + final Class methodFinderClass = Class.forName("jdk.internal.misc.MethodFinder"); + final Method methodFinderMethod = methodFinderClass.getDeclaredMethod("findMainMethod", Class.class); + final Object result = methodFinderMethod.invoke(null, mainClass); + return (Method) result; + } + + private static Method findSimpleMainMethod(Class mainClass) throws NoSuchMethodException { + return mainClass.getMethod("main", String[].class); + } + + private static T forceAccessible(T object) throws ReflectiveOperationException { + try { + final Method setAccessible0 = AccessibleObject.class.getDeclaredMethod("setAccessible0", boolean.class); + setAccessible0.setAccessible(true); + setAccessible0.invoke(object, true); + } catch (NoSuchMethodException e) { + object.setAccessible(true); + } + return object; + } +} diff --git a/packages/app-lib/library/JavaInfo.class b/packages/app-lib/library/JavaInfo.class deleted file mode 100644 index 69ffc97b4..000000000 Binary files a/packages/app-lib/library/JavaInfo.class and /dev/null differ diff --git a/packages/app-lib/migrations/20250413162050_skin-selector.sql b/packages/app-lib/migrations/20250413162050_skin-selector.sql new file mode 100644 index 000000000..8053e5ade --- /dev/null +++ b/packages/app-lib/migrations/20250413162050_skin-selector.sql @@ -0,0 +1,80 @@ +CREATE TABLE default_minecraft_capes ( + minecraft_user_uuid TEXT NOT NULL, + id TEXT NOT NULL, + + PRIMARY KEY (minecraft_user_uuid, id) +); + +-- Emulate a ON UPDATE CASCADE foreign key constraint for the user UUID on the default_minecraft_capes table, +-- but allowing deletion of the user UUID in the minecraft_users table. This allows the application to temporarily +-- keep skin state around for logged-out users, allowing them to retain their skins under the right conditions +CREATE TRIGGER default_minecraft_capes_user_uuid_insert_check + BEFORE INSERT ON default_minecraft_capes FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot add a default cape for an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER default_minecraft_capes_user_uuid_update_check + BEFORE UPDATE ON default_minecraft_capes FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot change a default cape to refer to an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER default_minecraft_capes_user_uuid_update_cascade + AFTER UPDATE OF uuid ON minecraft_users FOR EACH ROW + BEGIN + UPDATE default_minecraft_capes SET minecraft_user_uuid = NEW.uuid WHERE minecraft_user_uuid = OLD.uuid; + END; + +CREATE TABLE custom_minecraft_skins ( + minecraft_user_uuid TEXT NOT NULL, + texture_key TEXT NOT NULL, + variant TEXT NOT NULL CHECK (variant IN ('CLASSIC', 'SLIM', 'UNKNOWN')), + cape_id TEXT, + + PRIMARY KEY (minecraft_user_uuid, texture_key, variant, cape_id), + FOREIGN KEY (texture_key) REFERENCES custom_minecraft_skin_textures(texture_key) + ON DELETE CASCADE ON UPDATE CASCADE +); + +-- Similar partial foreign key emulation as above +CREATE TRIGGER custom_minecraft_skins_user_uuid_insert_check + BEFORE INSERT ON custom_minecraft_skins FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot add a custom skin for an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER custom_minecraft_skins_user_uuid_update_check + BEFORE UPDATE ON custom_minecraft_skins FOR EACH ROW + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM minecraft_users WHERE uuid = NEW.minecraft_user_uuid + ) THEN RAISE(ABORT, 'Cannot change a custom skin to refer to an unknown Minecraft user UUID') END; + END; + +CREATE TRIGGER custom_minecraft_skins_user_uuid_update_cascade + AFTER UPDATE OF uuid ON minecraft_users FOR EACH ROW + BEGIN + UPDATE custom_minecraft_skins SET minecraft_user_uuid = NEW.uuid WHERE minecraft_user_uuid = OLD.uuid; + END; + +CREATE TABLE custom_minecraft_skin_textures ( + texture_key TEXT NOT NULL, + texture PNG BLOB NOT NULL, + + PRIMARY KEY (texture_key) +); + +CREATE TRIGGER custom_minecraft_skin_texture_delete_cleanup + AFTER DELETE ON custom_minecraft_skins FOR EACH ROW + BEGIN + DELETE FROM custom_minecraft_skin_textures WHERE texture_key NOT IN ( + SELECT texture_key FROM custom_minecraft_skins + ); + END; diff --git a/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql b/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql new file mode 100644 index 000000000..faba8e36f --- /dev/null +++ b/packages/app-lib/migrations/20250514181748_skin_nametag_setting.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN hide_nametag_skins_page INTEGER NOT NULL DEFAULT 0 CHECK (hide_nametag_skins_page IN (0, 1)); diff --git a/packages/app-lib/package.json b/packages/app-lib/package.json index 9c1f1e77c..d1213bbaa 100644 --- a/packages/app-lib/package.json +++ b/packages/app-lib/package.json @@ -2,8 +2,8 @@ "name": "@modrinth/app-lib", "scripts": { "build": "cargo build --release", - "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings", - "fix": "cargo fmt && cargo clippy --fix", - "test": "cargo test" + "lint": "cargo fmt --check && cargo clippy --all-targets", + "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", + "test": "cargo nextest run --all-targets --no-fail-fast" } } diff --git a/packages/app-lib/src/api/jre.rs b/packages/app-lib/src/api/jre.rs index ef193aac3..412d44720 100644 --- a/packages/app-lib/src/api/jre.rs +++ b/packages/app-lib/src/api/jre.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use sysinfo::{MemoryRefreshKind, RefreshKind}; use crate::util::io; -use crate::util::jre::extract_java_majorminor_version; +use crate::util::jre::extract_java_version; use crate::{ LoadingBarType, State, util::jre::{self}, @@ -38,9 +38,9 @@ pub async fn find_filtered_jres( Ok(if let Some(java_version) = java_version { jres.into_iter() .filter(|jre| { - let jre_version = extract_java_majorminor_version(&jre.version); + let jre_version = extract_java_version(&jre.version); if let Ok(jre_version) = jre_version { - jre_version.1 == java_version + jre_version == java_version } else { false } @@ -135,7 +135,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result { #[cfg(target_os = "macos")] { base_path = base_path - .join(format!("zulu-{}.jre", java_version)) + .join(format!("zulu-{java_version}.jre")) .join("Contents") .join("Home") .join("bin") @@ -157,8 +157,8 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result { } // Validates JRE at a given at a given path -pub async fn check_jre(path: PathBuf) -> crate::Result> { - Ok(jre::check_java_at_filepath(&path).await) +pub async fn check_jre(path: PathBuf) -> crate::Result { + jre::check_java_at_filepath(&path).await } // Test JRE at a given path @@ -167,11 +167,18 @@ pub async fn test_jre( major_version: u32, ) -> crate::Result { let jre = match jre::check_java_at_filepath(&path).await { - Some(jre) => jre, - None => return Ok(false), + Ok(jre) => jre, + Err(e) => { + tracing::warn!("Invalid Java at {}: {e}", path.display()); + return Ok(false); + } }; - let (major, _) = extract_java_majorminor_version(&jre.version)?; - Ok(major == major_version) + let version = extract_java_version(&jre.version)?; + tracing::info!( + "Expected Java version {major_version}, and found {version} at {}", + path.display() + ); + Ok(version == major_version) } // Gets maximum memory in KiB. diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs index 7d24418b5..265d9bcb4 100644 --- a/packages/app-lib/src/api/logs.rs +++ b/packages/app-lib/src/api/logs.rs @@ -39,21 +39,27 @@ pub struct LatestLogCursor { #[serde(transparent)] pub struct CensoredString(String); impl CensoredString { - pub fn censor(mut s: String, credentials_set: &Vec) -> Self { + pub fn censor(mut s: String, credentials_list: &[Credentials]) -> Self { let username = whoami::username(); s = s .replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/") .replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\"); - for credentials in credentials_set { + for credentials in credentials_list { + // Use the offline profile to guarantee that this function does not cause + // Mojang API request, and is never delayed by a network request. The offline + // profile is optimistically updated on upsert from time to time anyway s = s .replace(&credentials.access_token, "{MINECRAFT_ACCESS_TOKEN}") - .replace(&credentials.username, "{MINECRAFT_USERNAME}") .replace( - &credentials.id.as_simple().to_string(), + &credentials.offline_profile.name, + "{MINECRAFT_USERNAME}", + ) + .replace( + &credentials.offline_profile.id.as_simple().to_string(), "{MINECRAFT_UUID}", ) .replace( - &credentials.id.as_hyphenated().to_string(), + &credentials.offline_profile.id.as_hyphenated().to_string(), "{MINECRAFT_UUID}", ); } @@ -210,7 +216,7 @@ pub async fn get_output_by_filename( .await? .into_iter() .map(|x| x.1) - .collect(); + .collect::>(); // Load .gz file into String if let Some(ext) = path.extension() { @@ -350,7 +356,7 @@ pub async fn get_generic_live_log_cursor( .await? .into_iter() .map(|x| x.1) - .collect(); + .collect::>(); let output = CensoredString::censor(output, &credentials); Ok(LatestLogCursor { cursor, diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs index 4fa75a4c8..568a6aca1 100644 --- a/packages/app-lib/src/api/minecraft_auth.rs +++ b/packages/app-lib/src/api/minecraft_auth.rs @@ -23,8 +23,8 @@ pub async fn finish_login( #[tracing::instrument] pub async fn get_default_user() -> crate::Result> { let state = State::get().await?; - let users = Credentials::get_active(&state.pool).await?; - Ok(users.map(|x| x.id)) + let user = Credentials::get_active(&state.pool).await?; + Ok(user.map(|user| user.offline_profile.id)) } #[tracing::instrument] diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs new file mode 100644 index 000000000..2a869a22d --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -0,0 +1,530 @@ +//! Theseus skin management interface + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +pub use bytes::Bytes; +use futures::{StreamExt, TryStreamExt, stream}; +use serde::{Deserialize, Serialize}; +use url::Url; +use uuid::Uuid; + +pub use crate::state::MinecraftSkinVariant; +use crate::{ + ErrorKind, State, + state::{ + MinecraftCharacterExpressionState, MinecraftProfile, + minecraft_skins::{ + CustomMinecraftSkin, DefaultMinecraftCape, mojang_api, + }, + }, +}; + +use super::data::Credentials; + +mod assets { + mod default { + mod default_skins; + pub use default_skins::DEFAULT_SKINS; + } + pub use default::DEFAULT_SKINS; +} + +mod png_util; + +#[derive(Deserialize, Serialize, Debug)] +pub struct Cape { + /// An identifier for this cape, potentially unique to the owning player. + pub id: Uuid, + /// The name of the cape. + pub name: Arc, + /// The URL of the cape PNG texture. + pub texture: Arc, + /// Whether the cape is the default one, used when the currently selected cape does not + /// override it. + pub is_default: bool, + /// Whether the cape is currently equipped in the Minecraft profile of its corresponding + /// player. + pub is_equipped: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Skin { + /// An opaque identifier for the skin texture, which can be used to identify it. + pub texture_key: Arc, + /// The name of the skin, if available. + pub name: Option>, + /// The variant of the skin model. + pub variant: MinecraftSkinVariant, + /// The UUID of the cape that this skin uses, if any. + /// + /// If `None`, the skin does not have an explicit cape set, and the default cape for + /// this player, if any, should be used. + pub cape_id: Option, + /// The URL of the skin PNG texture. Can also be a data URL. + pub texture: Arc, + /// The source of the skin, which represents how the app knows about it. + pub source: SkinSource, + /// Whether the skin is currently equipped in the Minecraft profile of its corresponding + /// player. + pub is_equipped: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum SkinSource { + /// A default Minecraft skin, which may be assigned to players at random by default. + Default, + /// A skin that is not the default, but is not a custom skin managed by our app either. + CustomExternal, + /// A custom skin we have set up in our app. + Custom, +} + +/// Represents either a URL or a blob for a Minecraft skin PNG texture. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(untagged)] +pub enum UrlOrBlob { + Url(Url), + Blob(Bytes), +} + +/// Retrieves the available capes for the currently selected Minecraft profile. At most one cape +/// can be equipped at a time. Also, at most one cape can be set as the default cape. +#[tracing::instrument] +pub async fn get_available_capes() -> crate::Result> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + Ok(profile + .capes + .iter() + .map(|cape| Cape { + id: cape.id, + name: Arc::clone(&cape.name), + texture: Arc::clone(&cape.url), + is_default: default_cape_id + .is_some_and(|default_cape_id| default_cape_id == cape.id), + is_equipped: cape.state + == MinecraftCharacterExpressionState::Active, + }) + .collect()) +} + +/// Retrieves the available skins for the currently selected Minecraft profile. At the moment, +/// this includes custom skins stored in the app database, default Mojang skins, and the currently +/// equipped skin, if different from the previous skins. Exactly one of the returned skins is +/// marked as equipped. +#[tracing::instrument] +pub async fn get_available_skins() -> crate::Result> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + let current_skin = profile.current_skin()?; + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id); + + // Keep track of whether we have found the currently equipped skin, to potentially avoid marking + // several skins as equipped, and know if the equipped skin was found (see below) + let found_equipped_skin = Arc::new(AtomicBool::new(false)); + + let custom_skins = CustomMinecraftSkin::get_all(profile.id, &state.pool) + .await? + .then(|custom_skin| { + let found_equipped_skin = Arc::clone(&found_equipped_skin); + let state = Arc::clone(&state); + async move { + // Several custom skins may reuse the same texture for different cape or skin model + // variations, so check all attributes for correctness + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && custom_skin.texture_key == *current_skin.texture_key() + && custom_skin.variant == current_skin.variant + && custom_skin.cape_id + == if custom_skin.cape_id.is_some() { + current_cape_id + } else { + default_cape_id + }; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + name: None, + variant: custom_skin.variant, + cape_id: custom_skin.cape_id, + texture: png_util::blob_to_data_url( + custom_skin.texture_blob(&state.pool).await?, + ) + .or_else(|| { + // Fall back to a placeholder texture if the DB somehow contains corrupt data + png_util::blob_to_data_url(include_bytes!( + "minecraft_skins/assets/default/MissingNo.png" + )) + }) + .unwrap(), + source: SkinSource::Custom, + is_equipped, + texture_key: custom_skin.texture_key.into(), + }) + } + }); + + let default_skins = + stream::iter(assets::DEFAULT_SKINS.iter().map(|default_skin| { + let is_equipped = !found_equipped_skin.load(Ordering::Acquire) + && default_skin.texture_key == current_skin.texture_key() + && default_skin.variant == current_skin.variant; + + found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel); + + Ok::<_, crate::Error>(Skin { + texture_key: Arc::clone(&default_skin.texture_key), + name: default_skin.name.as_ref().cloned(), + variant: default_skin.variant, + cape_id: None, + texture: Arc::clone(&default_skin.texture), + source: SkinSource::Default, + is_equipped, + }) + })); + + let mut available_skins = custom_skins + .chain(default_skins) + .try_collect::>() + .await?; + + // If the currently equipped skin does not match any of the skins we know about, + // add it to the list of available skins as a custom external skin, set by an + // external service (e.g., the Minecraft launcher or website). This way we guarantee + // that the currently equipped skin is always returned as available + if !found_equipped_skin.load(Ordering::Acquire) { + available_skins.push(Skin { + texture_key: current_skin.texture_key(), + name: current_skin.name.as_deref().map(Arc::from), + variant: current_skin.variant, + cape_id: current_cape_id, + texture: Arc::clone(¤t_skin.url), + source: SkinSource::CustomExternal, + is_equipped: true, + }); + } + + Ok(available_skins) +} + +/// Adds a custom skin to the app database and equips it for the currently selected +/// Minecraft profile. +#[tracing::instrument(skip(texture_blob))] +pub async fn add_and_equip_custom_skin( + texture_blob: Bytes, + variant: MinecraftSkinVariant, + cape_override: Option, +) -> crate::Result<()> { + let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?; + if skin_width != 64 || ![32, 64].contains(&skin_height) { + return Err(ErrorKind::InvalidSkinTexture)?; + } + + let cape_override = cape_override.map(|cape| cape.id); + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + // We have to equip the skin first, as it's the Mojang API backend who knows + // how to compute the texture key we require, which we can then read from the + // updated player profile + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]), + variant, + ) + .await?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + sync_cape(&state, &selected_credentials, &profile, cape_override).await?; + + CustomMinecraftSkin::add( + profile.id, + &profile.current_skin()?.texture_key(), + &texture_blob, + variant, + cape_override, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Sets the default cape for the currently selected Minecraft profile. If `None`, +/// the default cape will be removed. +/// +/// This cape will be used by any custom skin that does not have a cape override +/// set. If the currently equipped skin does not have a cape override set, the equipped +/// cape will also be changed to the new default cape. When neither the equipped skin +/// defines a cape override nor the default cape is set, the player will have no +/// cape equipped. +#[tracing::instrument] +pub async fn set_default_cape(cape: Option) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + let current_skin = get_available_skins() + .await? + .into_iter() + .find(|skin| skin.is_equipped) + .unwrap(); + + if let Some(cape) = cape { + // Synchronize the equipped cape with the new default cape, if the current skin uses + // the default cape + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::equip( + &selected_credentials, + cape.id, + ) + .await?; + } + + DefaultMinecraftCape::set(profile.id, cape.id, &state.pool).await?; + } else { + if current_skin.cape_id.is_none() { + mojang_api::MinecraftCapeOperation::unequip_any( + &selected_credentials, + ) + .await?; + } + + DefaultMinecraftCape::remove(profile.id, &state.pool).await?; + } + + Ok(()) +} + +/// Equips the given skin for the currently selected Minecraft profile. If the skin is already +/// equipped, it will be re-equipped. +/// +/// This function does not check that the passed skin, if custom, exists in the app database, +/// giving the caller complete freedom to equip any skin at any time. +#[tracing::instrument] +pub async fn equip_skin(skin: Skin) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::equip( + &selected_credentials, + png_util::url_to_data_stream(&skin.texture).await?, + skin.variant, + ) + .await?; + + sync_cape(&state, &selected_credentials, &profile, skin.cape_id).await?; + + Ok(()) +} + +/// Removes a custom skin from the app database. +/// +/// The player will continue to be equipped with the same skin and cape as before, even if +/// the currently selected skin is the one being removed. This gives frontend code more options +/// to decide between unequipping strategies: falling back to other custom skin, to a default +/// skin, letting the user choose another skin, etc. +#[tracing::instrument] +pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + CustomMinecraftSkin { + texture_key: skin.texture_key.to_string(), + variant: skin.variant, + cape_id: skin.cape_id, + } + .remove( + selected_credentials.maybe_online_profile().await.id, + &state.pool, + ) + .await?; + + Ok(()) +} + +/// Unequips the currently equipped skin for the currently selected Minecraft profile, resetting +/// it to one of the default skins. The cape will be set to the default cape, or unequipped if +/// no default cape is set. +#[tracing::instrument] +pub async fn unequip_skin() -> crate::Result<()> { + let state = State::get().await?; + + let selected_credentials = Credentials::get_default_credential(&state.pool) + .await? + .ok_or(ErrorKind::NoCredentialsError)?; + + let profile = + selected_credentials.online_profile().await.ok_or_else(|| { + ErrorKind::OnlineMinecraftProfileUnavailable { + user_name: selected_credentials.offline_profile.name.clone(), + } + })?; + + mojang_api::MinecraftSkinOperation::unequip_any(&selected_credentials) + .await?; + + sync_cape(&state, &selected_credentials, &profile, None).await?; + + Ok(()) +} + +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// The normalized, processed is returned texture as a byte array in PNG format. +#[tracing::instrument] +pub async fn normalize_skin_texture( + texture: &UrlOrBlob, +) -> crate::Result { + png_util::normalize_skin_texture(texture).await +} + +/// Reads and validates a skin texture file from the given path. +/// Returns the file content as bytes if it's a valid skin texture (PNG with 64x64 or 64x32 dimensions). +#[tracing::instrument] +pub async fn get_dragged_skin_data( + path: &std::path::Path, +) -> crate::Result { + if let Some(extension) = path.extension() { + if extension.to_string_lossy().to_lowercase() != "png" { + return Err(ErrorKind::InvalidSkinTexture.into()); + } + } else { + return Err(ErrorKind::InvalidSkinTexture.into()); + } + + tracing::debug!("Reading file: {:?}", path); + + if !path.exists() { + tracing::error!("File does not exist: {:?}", path); + return Err(ErrorKind::InvalidSkinTexture.into()); + } + + let data = match tokio::fs::read(path).await { + Ok(data) => { + tracing::debug!( + "File read successfully, size: {} bytes", + data.len() + ); + data + } + Err(err) => { + tracing::error!("Failed to read file: {}", err); + return Err(err.into()); + } + }; + + let url_or_blob = UrlOrBlob::Blob(data.clone().into()); + + match normalize_skin_texture(&url_or_blob).await { + Ok(_) => Ok(data.into()), + Err(err) => { + tracing::error!("Failed to normalize skin texture: {}", err); + Err(ErrorKind::InvalidSkinTexture.into()) + } + } +} + +/// Synchronizes the equipped cape with the selected cape if necessary, taking into +/// account the currently equipped cape, the default cape for the player, and if a +/// cape override is provided. +async fn sync_cape( + state: &State, + selected_credentials: &Credentials, + profile: &MinecraftProfile, + cape_override: Option, +) -> crate::Result<()> { + let current_cape_id = profile.current_cape().map(|cape| cape.id); + let target_cape_id = match cape_override { + Some(cape_id) => Some(cape_id), + None => DefaultMinecraftCape::get(profile.id, &state.pool) + .await? + .map(|cape| cape.id), + }; + + if current_cape_id != target_cape_id { + match target_cape_id { + Some(cape_id) => { + mojang_api::MinecraftCapeOperation::equip( + selected_credentials, + cape_id, + ) + .await? + } + None => { + mojang_api::MinecraftCapeOperation::unequip_any( + selected_credentials, + ) + .await? + } + } + } + + Ok(()) +} diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png b/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png new file mode 100644 index 000000000..54d69181d Binary files /dev/null and b/packages/app-lib/src/api/minecraft_skins/assets/default/MissingNo.png differ diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs new file mode 100644 index 000000000..6c26f07d8 --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs @@ -0,0 +1,529 @@ +use std::sync::{Arc, LazyLock}; + +use url::Url; + +use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant}; + +use super::super::super::Skin; + +/// A list of default Minecraft skins to make available to the user, created by Mojang. +pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| { + // + // The skins below are available in the vanilla Minecraft launcher, and were found + // by reverse engineering the behavior of the Minecraft launcher. The textures are + // publicly available at `https://textures.minecraft.net/texture/`. + // + vec![Skin { + texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJklEQVR4Xu2aP2sUURTF/RRir2BAsQyC4GJjaSwEg1iJqRSDgiBooSIWoggiYm0l2KiNiKCNhaWFlYVWVhYWfoAxZ8xZfnvyJtmdkN2dMBcO7++8fefc+2Zn3ps9e7awX48G1e/ng0rpjztHRlJD7a5zXqmQ43XOTMQQOSPrd7UAf18u10jCTRHiuhyvc2ZCTR5mPUVwW47XOaP3GQXON4ljMXK8zll61eRc/vn5RfXt3eOhEFwau0IAk7bXGe6CBDAkhGExcrzOmUgy7Bn+jgDh99fXI3B9jjd3lqFd8jKJu815Lgf3Yx3Hz7LyOZ+pmwmWSCbZJMd6lykCyymAr835TN048SSek1bq0E7i7M/ooLAeg7+X85m6pdecFkVYW9t/vn8arvOSCKxznoQpylzcJD0pkh/x8hpR2f4LB2rygvIyi1DyMoUkYfdT/VwIkF7mHd2EN4OFoggUIAlTpLkQwJMcPuxsRv7Dm/9YL/MvzySV2tMkTO/z93I+Uzd6ww8x+b++QYh18nlDJHGP6ehi3m1zIUDa1VdXKmJxcbHGwsJCjeyf9vDjg+r++3s1OYPEc/xEjpem8Q06I/u1tpyQiVuI7J9mAQR5ebi01kXwuOePHRxBWwFEXlGb/VpbTrBtBNy+e27kncAilMhThBwvLQXYkQjgpNpEgMjz5YjLIcffrgDbjgB5ywPefHujunjicHXt1GJ16+zxOm84MhzeBidE7/MmqQmqjktrK2gupfETTX2SZ6OJnH7M5C+fPFKTF5SnCIK95bJ+zNd7Qk+fXRr5m1S+JICuKdWRVArtPOvTIUqTZ6PJ2yIq2PMmr1R1FCD7UQBPXqlFSPIkndeZ5NMvT4ap8yyX6vLa5NloJkKiJG8xSiJZAJKxByxCkncbyyScqa5vg+TZaAxzhr8FYIQICn+WSSYnrzTJUoCSF+llgQ9khO8vCbcnz0ZbXl6uDIa4iauOfYSVlZUayjOMk0AdBWsp12aKUurPcfIJlH99FIL3GyF5NprJmJAJlgiXQELpzSREAQy2u8z6JJ4ilMhPJECJ4Orq6oY6izIYDKqlpaUaypMQiSgvUZKQ692HQrluqgIkoQzxbFfqyFB+39G9Qxw6s7Dhbm/CJqY+vIZiZKq2JJ0CcCm0EoChTpIlwu7DsgmYsAgmSYtjgdg/2wnVJ+kSeUbCxAKYLEl5CSTxFEiQp3xnF/KBJFP3cfgT9jzbk7jJ580vRUiejabne5EysVzzJi34fYBlT5Tk9M9x/fTREaiO4vCaFIJI8ikC01YR0FtvvfW20+bdIG6Jc5uMcFuO0XnL8wLXc7doVxKX2fu7luBW5tDP+nFMewqJfPdPqE+OM1NrS7633nrrrbfeeuuWeU9QaHO4qmcGb4Z0coODAgiTHq/7XUEC6FB14i2uWVseeU8aAX5n6GwEpACTRoAE8BLoRATw5NjwuSLPHQ2JUtoI1QapX5ezLTFXb5VJ0ALwcDUPWRkh3CmmALl7zH5Kcx4zMxL3wWqeMpfg43gR4nkiy1nHNOcxM+NROb8dYPjnNwXsk+/y48K/n/VG7iHkXgI5bMtSgCRrTztCEnm+n6c9JahPzmNmxuMzLoNxvi/QqZMJlU53mqA+OY+ZWekoLc8V85id/enVccgbOY+ZWRJUeZLvCzovQBLiAWuJsFJHhvK55pNoE3IeMzOGOkmWCJeQAowjwlzdA0yWpDb7viBB4rwZJmmSn6t/gc2+LxAcCULp+wKu/63I70QE/AMDdqWZ7rX6YgAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("1abc803022d8300ab7578b189294cce39622d9a404cdc00d3feacfdf45be6981"), + name: Some(Arc::from("Alex")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFQElEQVR4Xu2av2scRxzF9VeY9DZYEKPyEBh8pHFpuQhEhFTBqhIiEjAY4iI2JkVIMBgRUrsypHHShGCIGxcuXbhyYVepVKTwH7DR2+hzvHs7e9KelNWt2AeP+bGzc/PefGfvdubW1o7A3z9Nq/1fppXSt/c25lKo69SRVypmf4MDQqDEwaw/1wa8f7JdMwW3RQh12d/ggKC2GfZ6N4Fr2d/g4LPvUUC+zRzMyP4Gh5xVxFF+9+Jx9fqPhzMjfGmcCwMQzax7uIsyAMoIiBnZ3+AgkR72Hv5EgLj/6ukcqc/+Vg4Z2qVZduFcI+/LgXZe5/1nWfkcT+9AYElkik1xXk/ZTfByGsC9OZ7e4QNP4TlopYR2Cvf2Hh1uLH345+V4ekfOGmnRhIO1/c+b57N1XjLB68i7YDdlJR6SDMrFz83ygVDh4ueXavGi8gImlGbZjXTBtFP9ShiQs+xPdAQvIka5CW5ACnaTVsIABjn7sbNI/LPf/uNh2b/yEKmUmXbBPvv+eTme3uGzwY+Y/F5vGHEoPh+ILpw+iS7Pc20lDEh8/etXlXMymdRcX1+vme0TP/71Q/X9nw9qcdCFZ//J7C+h/p05Kdm+M3JACMeIbJ/AAFGzPFtahybQ72dXL8/xpAZIvKI323dGDnDZCPju/qdz7wSYUBLvJmR/iTYDTjUCfFDLRIDE+8uRL4fs/7QMWDoCNFt09u3vd6pbH12pvrkxqe5+cq3OQyKD8IY5IGbfH5IamOp8aR1FjaXUf4mL2qXeBiROH4b4L69v1OJF5d0EkdmirA/hfgaz9/MXc1+TypcM0D2lOhdUMpqyXytNjNLU24BmW0JFZh7xSlXnBmQ7N4DBK8WEFO+i8z4E7r18NEvJe7mt3k2inHobQIgLdfGYUTIJA1wMzmNCiueal11wprr/JEy9DXiYe/hjgEeIqPD3sovJwStNsW5AaQZzlv0HWZJnTNLbpN4Gtre3K+ghjnDVeRtxZ2enpvIeximgjoKD1NdkmlJq7/3411wyjfBnDky9DSAGQQgsCS7RBeVspiA3APp1yl6fopMYUBJ/LANKAnd3dxt1mDKdTqutra2ayrsgF6K8TElB1NPGjaLupAZ4PvU2kIIyxPO6UiJD+Q82L8z44cfrjac9ghGmNn6Pm5GprqXgpK93GZHXU28DHuousiSYNl5GAIIlMEViDgZ5+7zuVH0KahPvkdDJAMS6KJZACk+DRM0UT3Yxf4hkShvC38nM+/UU7eLz4VcyIfU2oN/3EoWwXPOIFnkf8DIDdXH65rh9c3OOqnNz/J40wpnCSyZ42jkCRowYMeL/BrtBviXu22ROrmUfg0eeF1Dvu0XnUrjA7J9bgUeB0M/640B7CiXme3+SdtnfmWBZ8SNGjBgxYsSIYYE9QXGZw1X9ZvANkcFtcLgBYtfjdd4VMEAHq522uM4aeeTdNQJ4ZxhsBKQBXSNABvgSWPkI8JNjyLminztCmVLaCNUGKa/Lea2NK/F2mQIxwA9X85DVI8R3itOA3EEubbPneHqHC+dgNU+ZS+Q4XkL8PBFS5/VeJs3x9A4/Kvf/Dnj4538KvE2+x58mcw+htJeQejojDUixzDQRksyz/TzpWUS1zfH0Dj8+82VwnP8X6NQJIW0nO4uotjme3lE6SstzxTxm9/Y+m13EwxxP70iBKnf5f8EyBnik5Hh6RwryA9aSYKVEhvK55lPsUczx9A4PdRdZElxiGtDFhJV4BiDWRS36f0HShfvDMMUmaZ/j6R2L/l8gEgli6f8FCPE0xbbxNCLgXy/vfcHbfYbuAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6ac6ca262d67bcfb3dbc924ba8215a18195497c780058a5749de674217721892"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEuklEQVR4Xu2aMYsUQRSEhYsMTARBRBRUxMsEUUMTDw1ExESDCzQRo9NQ5EBNNDMxMjAy0L/gbzH2B2giorBSA3XUfnbP7eytM7N3W1BMT/fb2VfVr2dmZ/bQoV3w5urxybtrpyYfNs40W+2rLapd4tend3bI4y0dLDwNsCkUTvH7wgCL94znfppA4fvGAM54F/H7woDdKoCCSR5v6ZCiswrcpmCSx1s62ACbkMshK+Dbi4dT3FcG5JWAy6AkPk3g8UYHn8gs7OXFo1OzrH0bIEpYjjM2qyP7tDXdry3z6R2ZbG5phoR/f7fVUG2Oe5/Hyr5RGuAEc5Y84x6T6F+fXk9RfRmjz1iUDUiDWA2OYz69w8lIgMV5rTthijdTTH6+JDQrIw1hPr3DiViAy9slr3Hh3NrajkC1BY3lktDWBpRmPs3wPvPpHZmMSz3b319tNeTsZ7+NcluC0wRXQ5rgfebTO7I0nZhPdlz7vrxNGRGxrgYdIy+HFG1DRlEBKd6Jib6+0wSK9/V+FtqINIT5DI7DG3cnyUuXrzQ8f2G9IeMJC9P2x9uNhr8/39vhkdubreTxiJ/vbzXH+fp8faoCGTc3aICF2wjGE7mUvFWV2AyJ5HeYsxhgI2WAhC/8lptJda0Anui8lBZpwJ8vD8ddAUrS5wefR/7HElhIBTABkoZkn9pKpEQaoFnzzCUlRqJEV4naHveYDfS+xhxv+ngidVZx7P6jSRspmPtOKBMTJdblafEWkEKdcI7lOi/F5Ji/j8ejzioo+MSDJ1NUnwXbkCQFpQFJinR8TSDbpX3PehrgGOqsomTA6cfPpgyw2KwK71NQlnaJjKcoGsBxHocGeJw6q6ABWQUlwSQF1QzIONKJp+C2+GQakKTOKiioxHkMsBgK4T7HSB+vK6mzCgoqsc0AihCVQE0QY9VXqhbH+kRaY15pktRZhYVxy74a0/WSaAqzQY6nCWmSYiSGvz/4Q4y/WToZkNd2ijN5/U/qju/jjbP/bMXt7e2G3s+YUjypMRpAM5JzGeA7PHPjxs3J5uZmsxU5TuaM5qyKNonrsxRLerw08yXxNIE6q8jb3DRhFvH6LA1w4rl8ZALjKLjGkgFq59rntrMBeyHFixaf54+2SmhjzjorgMLnqoBFQ+uWff8DfX1PZ/SVWF/f0xl9JTaKB6olHDgDfE1PLiK53Z4a8zvFUTxk9SMx9s+DNkFtY4NiNC88hoLfLbD/wOD6ySMTkf0HBouuAP5U9i9G/nLMPm15nF6xSANWWGGFFVZYJvCZoR+c+CkS44l8QDqahxxdQAPy8ZnIeCKv937z2/kx15CgAV0rQML9LvFAVkAugaWoAArmO4USM5ZPgUsvVbg/+K1ugoL46oxkvIT5CbAMSNEpmIYwj8EgURakdun/BTQgKdElAyg6+0ZngMXYAP6/gMx4GlASTTNGbUBWAYWXSANmJfMYDCUDupBvgmalv1/VYFNcJbUqyrHUsCfs1QC+55uVzGMwpAE0wu3ckjaANz219uhuiiTCl7TafUCOk5zZNtHZxzwGA1+Zd/1/AQ3YzQT3M4/BQEE2oSSevwtsQL7mznf8JQMcxzwGA/8v0JU0oCSaXGQF/AXuGcKOL5bNbAAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("4c05ab9e07b3505dc3ec11370c3bdce5570ad2fb2b562e9b9dd9cf271f81aa44"), + name: Some(Arc::from("Ari")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEyElEQVR4Xu2aP4sUQRTEhYsMTARBRBRURDNB1NDEQwMRMdHgAk3ESA1FDtREM5OLDIwM9Cv4WYz9AJqIKKzUYB21v+mZ3bnb65u724Kip7vfzr6qefNnp/fQoRl4d+34ZOP6qcnH1TNNq762RW2X+O353U1yf3sOFp4G2BQKp/h9YYDF+4hnP02g8H1jAI/4EPH7woBZFUDBJPe355Ciswq8TcEk97fnYANsQp4OWQHfXz2a4r4yIO8EPA1K4tME7m908IXMwl5fOjp1lNW3AaKE5TxjszpyTK3pcbXMpzoy2WxphoT/2HjaUNucd5/7yrFRGuAE8yj5iHtOon9/fjtFjWWMPmNRNiANYjU4jvlUh5ORAIvzue6EKd5MMfn5ktCsjDSE+VSHE7EAl7dLXvPCuZWVTYHaFjSXp4RaG1A68mmG+8ynOjIZl3pu/3jztCGPfo7bKG9LcJrgakgT3Gc+1ZGl6cR8seO579vblBER62rQPvJ2SNE2ZBQVkOKdmOj7O02geN/v56GNSEOYz67j8Oq9SfLylasNz1+42JDxhIWp/fl+teGfL/c3eeTOWi+5P+LXh9ub+/r28mKrEhk/GDTAwm0E44k8ldyqSmyGRPI7zHkMSDNtgIQv7NGbSQ2tAF7ofCot0oC/Xx+NuwKUnK8Pvo7s5CmwrQpgAiQNyTFtK4kSaYCOmsg4CZEo0VWibc97LkvfY5r3Z0zv03HU28KxB48nfaRg9p1MJiVKrMvS4p1UCqWwvGjaAMZ4zPP+ztI+qbcFCj7x8NkUNWbBNiRJQWlAkiIdz4SzX5rjmI96GpAx1NtCyYDTT15MGWCxWRXuU1CWdomMpyAawHnGlgzIeeptgQZkFZQEkxTUZUDGkU46BffFk2kASb0tUFCJWzHAYiiEfc6R3t9WSb0tUFCJfQZQhKgv7hLEWI2VqsWxvpD2Me82JPW2YGFsOdbFdLskmsJskONpQpqkGIko/QbhAxB/t3ibelvIezvFmbz/J/XE9+nm2VYrrq+vN3Q/Y0rxpOZKBtCM5GAD/IRnrt68NVlbW2takfNkHtE8qqJN4nlZiiU9T0F94mnCXAbkY26aMI94fZYGOPE8fWQC4yi4i10GqJ/nPttBBmyHFC9afF4/+iqhjzzqJROypVHUu+PQecuxnULN75obNZOq+V1zo2ZSo3ipShw4A3xPTy4isXneGvN7xV1/0epXYhzfCmaJmTW/KxjNgsduwWsLHD8wuHHyyETk+IHBoiuAP5XzVyN/PeaY47i/KlikAUssscQSS+wl8J2hX5z4LRLjCb4g5UsOxo8ONCBfn4mMJ3i/96qvTWD86EADhlaAROda4oGrAJ4Co68ACuaaQokZy7fAfYsqpXHmUx0UxKUzkvHNUf7/BlgGUDBNYJ/5VIdEWZC2S/8voAFJiS4Z0GUC55hPdaQoG8D/F5AZTwO6BJcMGaUBWQUUXiINGErmUx0lA4aQK0FDqSqwGa6OvirK6lFLPYOxXQO4xjeUzKc60gAa4e1sSRtgQbnux3XA7I/mwUgifEvreg7IeZJHdJZgjjOf6uCS+dD/F9AACuzra5v5VAcF2YSSeP4usAG5xM31fRqQ44plPtXB/wsMJQ3oElziIirgH4/zgLkTkjiIAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("fece7017b1bb13926d1158864b283b8b930271f80a90482f174cca6a17e88236"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFK0lEQVR4Xu2awYscRRjFcw9JhLiHoMIGzCVLIIRlEQJGDDkIghA9RBBBBdkl4EEFPYm5CCrkorlo9CBBCJGQu168GTx49OYf4D+QY8tr+A1vXqom3e3uzHTSDx71dVV1zfe++qpmumsOHXoErl35svnq3W9bfvHW9Zmtel1/9875hczxRgcX7UGgROgHl87M8bEJgIv3bIAl8R6EHG90SMEeBNmPfQBy3WN/+tpnT0YAEC3BiGYDVL3KFA7VluONDoi+cfXH2exLGPUqS1mgOrXleKMDYnMvoG7RV+EoMsBFlda713kWeFBK91EPsx47/Vk6So5LnK9xtX2zd3Ouz/sX9mZ2isrgsFRK/dOfpUNO5Gy7GNpv7b7U3Pnw1ZlAXXufFOdLhHtKmZL+LB04p/L3z99o6Y7KRnwSEZTc73WeNbIZGzv9WTp8diRKO3g6Luxtbc+EyxZo1/3aH3Sv2kl7SgJJHVyLbwkcUamdWwIkRs6qFHPmIe26/+ZHt9s6dn/VaZ9gbPYBD8RaBMCdUfnT7sutGJaFBP68+0or7pOLmy1lq079yACV//7z+kygxGvpqFSdbPUjELSnP0sHsylnRM2gxOC47Jx5yBJwkTD7+md4n/Rn5Xjh5LkGvnhqp3n7xLnm8sZWS9nZP3Hvrz8b8fb9P5rvf/u1pWzqNe7557dn44tel+MlfGwCu6/PHC5elPD3nttpKTv7J3BQG+KbO5stZRMExpdgJ/U5XkJjEFTE6zOy32C4+KEZ8PXduw8FQHX7kQHMvsY6kAxw8SKzD7N/Qg5KLEFAvKi2HD+Z4yV8Se1LBijqZ585/ZAji0i6iqztXPuHjx1vnnr6REvZuRfkPQTI7ezDGJB6+pAZKlNnFRK0tXFqTlgKLvFRAdCYzgxACsB5t70tA+D9s5+YOqvIGVU29AlEBgBHyAJmPx3OAGSdszTzGYy8J3VWIaGaIQQTBAKRgkVvT/H6cKWwnGIJyGYTrIktiXAuEsw14+k6dVZRm+1SnYuHpQC4Y7X1nP1LAr1vtpcCOTgAtT2gFITskwFwJ3zWkxmEvJdrvkH6MnVW4Wsekrq1AHg2yNkkM0MAfBYR56UHANHefwhTZxUuFvvvH7abBw/uzQUCuyuPHNtoH440juxs934EgaARELXle8auTJ1VpEOiHM+6Gj07fOP03xbYpYzyDdipOrWlsK5MnVWkoL7M3/SiBMt5Nkrs7Kf6zaPPzoLAbwZsteUTpf/8TXp76qwiBfUls6fSvx6pW9Qu5nOBZ4DaUjji/fyB0oOQOqtQumvNp7Ck+qgv61qU7c674C7XIpmR/cikFJ9BKAWg1xKYMGHChAkTJkyYsKbg+Z7H3Np1MscZLfw9QR/mOBPGinz4cpsHOfGXj8/OPbDlOBMmTFgO/MWHOOR0mZekg97wrBr59mfI/wsIgE59taGN6g2Pv+4amgG8Ih91BsC+/y8gACqVAbzry35rA1/vKd5ZOoFSvQt2Upellsda/dJbJNyF1s4fPeVLglM4+0P6sTKkaBeZs579agEQEUydH5n1Ovs7aKQghOcBCSc+3l4KADOcJU+A1KUfK0MGAMGlOhcPfZY91WszT7DSj5UhhZZEy85DUNryOb4r+Xyu/f0ApdPbS+MMhq9x6OeGTk99goBjfcnnky1D6VoGAbES5ueIXf9fwPd8X6YfK0MKgl3/Y+Ci8gB0EdOPlSEF9WUGoEsgRpEBXSkxfsyNnaJdvPqkHyvD//1/QQZgkfiDyID/AMdpmCl88QvjAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("daf3d88ccb38f11f74814e92053d92f7728ddb1a7955652a60e30cb27ae6659f"), + name: Some(Arc::from("Efe")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFPElEQVR4Xu2awatVVRTGnUcZ1BtIBk/ISY9A5PEQBA2jQSAE5sAgggriPYIGGuRIbBKo0MScqDUQEUQR5zVxZjRo2Mw/wH/A4YnvwO/yve/sc9895+npXD0LPvY6a6+z7/rWXnvfe86+e/bsID+duVRd/vrXGj9/8ctMl13X1786Ohc53tKJk/Yk0EL0+48/2IaXJgFO3qsBlMh7EnK8pZMk7EmQ/tInINc9+vlPL7waCYC0CEOaDVB2tUkcqC/HWzqB9LXvfp/NvohhV1uqAtnUl+MtnUA29wJs874Kl6ICnFRpvbvNq8CTUroPO0g7esYzuJQCFzlf4+q7unVzm8+3x7dmepLK5LBUSv4Zz+CiIHK2nQz9tzc/rO6dPTkjqGv3SXK+RLinVCkZz+BCcGofXTxdwwOVDvkEJGi5321eNdIZGz3jGVx8dkRKO3gGLtlaW58Rly6hX/drf9C96qfsaUkkNjCKbwkCUaudWwRERsGqFXLmAf26/+a5u7WN3V827ROMzT7giRhFAjwYtbc2T9RkWBYieGfzk5rcjx+t1pAum/yoALVPn3w2IyjyWjpqZZMuPxJBf8YzuDCbCkbQDIoMgUvPmQcsAScJ0tc/w30ynv9djhw4XIFjBzeqL/cdrk6trNWQnv4pD//5uxLu/vW4uvHnHzWkY9e4R99bn40vuC3HS8mxSS4/vtK/szh5QcS/eXejhvT0TyFAbYifb6zWkE4SGF+EHdhzvBSNIeK0kOez0r+zOPm+FXDlwYNGAmR7HhXA7JPo514BTl5g9kH6pygokSUJkBfUl+MncryUXFK7rgBl/dA77zcCmQfKVSCQXPuv7X2revPtfTWkZ+B5DwlyPX18T6Hf/bD7ZyXfhojQ2srBbcSScAk7JUBjOjIBSYCydt37nBh621juk3wbkjOqauiSiEwAAVMFzH4G3Ba0607Erz0p2d85ASKqGYIwSSARSVjw/iSvD1YJ68NZAtLZBNvIJslEG2G/LrXJtyFts12yOXlQSoAH1rae079E0H2zv5RIWu9Pvg2hAiDtxEtJSJ9MgAfhs57IJOS9XPMN0hfJtyG+5gGl25YArwYFm2BmFIATpB+d1hMAafffDZJvQ5ws+r+/rVfPnj3clgj0RfH63pX64UjjSM9+91OgnjQSor58z9gVybchGZCgwNPWBq8O3zj9twV6qaJ8A3bIpr4k1BXJtyFJqCvyN70gwgqejRI9/WRffWP/LAn8ZkBXXz5ROpIscJ/k25Ak1BXMnlr/esQ2r1/I5wKvAPUlaSfvZxDZLpwAlbvWfBJLyEe+rGtBugfvhBe5FqiM9KOSkngpCd52roBJJplkkkkmmWSSSUYqelLUEyKPtzzn52NvvgdY+HF47OLvCfogx5tk2SQfvrhG10Pa/R8O1ZDuD21CjjfJJJO8WPEXH0Kf02VekkrPlxzpPzrJtz99/l/gCdCJrzYzkpD+oxN/3dW3AnhFvtQVALr+v4AE8EOGc//RVoCv9yTvKJ1AyZ6EAbZsgZaJ2oxncJlH3Im2nT9mybe1EPZTpFEkIEk7yZz19GtLgABhbByZeQKEjGdwSUIQzwMSTny8v5QACGabCaDNeAaXTACESzYnD3yWvdRz5vN6NHtAEi2Rlp6HoPTlc3xXKBGul67dln3Jp7P4Ggd+bujw0icJHmQfUC19kXw6C2RFzM8RF/1/AT90+iLjGVySEFj0PwZJKA9Ad0LGM7gkoa7IBHRJwqgrYFGIhB9x5/l+G/DPeAaX3f6/IBOwCHlPQsbTVf4Dv55a+XG6bwMAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("226c617fde5b1ba569aa08bd2cb6fd84c93337532a872b3eb7bf66bdd5b395f8"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("e5cdc3243b2153ab28a159861be643a4fc1e3c17d291cdd3e57a7f370ad676f3"), + name: Some(Arc::from("Kai")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("7cb3ba52ddd5cc82c0b050c3f920f87da36add80165846f479079663805433db"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("dc0fcfaf2aa040a83dc0de4e56058d1bbb2ea40157501f3e7d15dc245e493095"), + name: Some(Arc::from("Makena")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6c160fbd16adbc4bff2409e70180d911002aebcfa811eb6ec3d1040761aea6dd"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEsklEQVR4Xu2aTWoUQRzFszGEgJqETFDUEDWJCiIGVCQuXCgo2bkRv3YKHkAXuvUGbrxC8Ag5hGcaeQNv+Oc3Vd1d7aR7xuTBo6qrqmvqvfro6q5ZWKjBvUsXho83V4cKn+8MRnGFTndeLmR9c4eUqBim0v5bA2LPx1D8sb99jE5nfXOHlAE04r83IAqXaBpxKgyQ8LcPto6FNuDni70JA5R2Kgw4/PBsJDaa4Gvlsb65Aw3glJDIKrK+mYOEpFb1VNwGsHyqLMO4fsTpw/Z0DjYyXt9cWx7F1ZNHnw8mQuWpTFUdDJnP9nQO9lCMex5L7J/v70dhpOd/7FHGaQBDtqdzRMESKepawiz09+v9EXPXKqt7fD9NcOgRNZMGuKctxtcSKry5e20sWHHBJqhsNM3XFM5wZgxQY+Jw13Uc6nXUfb4nTgsKToVsT+dwY+Kzm7TQL09ujRiFk67HAmVwDJnO9nQON4o7OW5oUr3ufN4nUmguZHt6x+Dl3nDz3dMRFb8/WBreXl0chyxPcN7HuPJU787XV0kqj/URv26sDM3YGSzXGhIdGcUrZHkiGqB4HPo2IGWC01kfQQMkXiOO5VqDBrQZAaanQ0yz0BxZH0EDZnIEcEH09TQMOLwzmO4IYAOaMA7bT2vnJph6miiN5ZpQgslvG4uVVBnqzILiKLCO8YfdaMUXl5bHI2Bl/fJEPu/JkeJP3IDc4hTp6VFlgESLgyvX58cAik+ZwDUiLkputOJRvEYD880qEywm0vfkqPwiAy4+3J4QlROrsqQFRCHRAJEGpO6hEb6OZtG4FJ1PnVlQUCljo6IBEu1pIDI/xZRoCmxK6syCgkpJA9wLNsDTICfQPNq7Oi7j+NwZEBlHgKdAjhIsquHxuhMD+LwuJcW40XFR9dOC5ZoYwN9rSurMIt7EN76q116Tzosa5h4hEq/Q0yPFaByv2Za6NjmfOrM42F0fkh8fbY3I9BS5R2DP5x6nTUnhFh+32QyLDND+nrQ4pqfIBosUzGuS+45YnuJpQgxbjYB5g1+3FTLvVEDCZ+ZQpQ/YAI0C5p0aSLxMYHoviF973DssY3joRtZ9PGUdgn9nd+P8Mbo+lu8MTQ2IaV7QnMcTpVjWcL5EO03xqns6QRsDKNrDO1XWyBmQK98Z2hggKC1FlovodajnUNfwuvwSTKueBe6+ShnrqhNYl98LKKiUsa4m4pqUifCboanPXQ5TdDnWkwX35aVkfdMGX52bkvWc4QxnOEMn4LlC6eFqXMjafODQU6bXLTEPU6L4Jsfr0QCd+pZ+4pLw3I6zE9CArkeA3yVmxoC2I0CbmjYjQOI7fU/gxqgJvYtUnJ/ITe7wTOf79/lNoK73p74+UBwF1pHC2xgQ43UCvUgyvTUoiO8KzBc9PfowQKjLL0KV+JQJXCMovM4ArQ/RAGKqvdsEOvaiqJxYHqyKFC7yxYasMqC4d9lbpaSgUlJ8U1JHa1BQKSmolBTWlG4/p4jIbwikyowN4AJVSgoqJYU1pdvPdE6XHMcG/Ct4PN0V2Y7eEBvFE9x4kltC3k/OlAH8v0Dp/wskJh5zxzP+FF2O7egN/L+AWPL/AhpQJf4kRsBfkTxADPB27yIAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("90e75cd429ba6331cd210b9bd19399527ee3bab467b5a9f61cb8a27b177f6789"), + name: Some(Arc::from("Noor")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEu0lEQVR4Xu2awaoURxiF3XgRwUTFkYhG1KhRCCEXjASzyEIhwZ0bMepOIQ+QLHTrG7jJK0gewYfIM42chjOc+3XVdNc4t2fGew8cqrrq75o6p6uqq7vnxIkB/PjNV/Nfrp6bK310a9bllbrcdbWU7e0cSqIyLZV9sQbklc9UfPfg5gG6nO3tHEoG0Igv3oAULtE04kgYIOF/3rt2ILUB73/f7xmgsiNhwIeXDzuxaYKPVcf2dg40gFNCIpeR7W0dJKS0qpfyNoDxpVimuX7k9GF/Jgc7mcffnT/d5XUlP/71uJeqTjHL2mDKevZncvAKZd7zWGL/f/uiS5Oe/3lFmacBTNmfyZGCJVLUsYRZ6H9PH3SsHStW5/h8muDUI2orDfCVthgfS6jw7IdvF4KVF2yCYtM0H1M4060xQJ3J4a7jHOpD1Hk+J6cFBZdS9mdyuDN57yYt9O9fv++Ywkm3Y4EyOFOWsz+Tw53iTo4bmtJVdz3PEym0lrI/G8fsj/351ee/dVT+p9mp+Z1ze4uU8QTnfeZVp3Zv/fOkSNWxPeLD3dn83xtnF+RoZHwzJDqZ4pUynkgDlM+hbwNKJric7RElAyTcI4/xzaABq4wA053KMgutke0RJQO2bgRwQfTxugxIEz57BLADY5jD9vX5kz2W7iYqY9wYWjD55uLeUjqOenugOAocYv6oO6383qnTixFw9sKlXj3PqZHCD92A2uKU9PRYZoBEi7PL13fHAIovmcA1Ihckd1r5FK/RwHpzmQkpJOnzanTbowz4+uebPVE1sYolLSCFpAEiDSidQyN8nGaRFE4DROrtgYJamT+WBki0p4HI+hJLoimuldTbAwW1kgboR9MAT4OaQPPj/pVFjPM7Z0AyR4CnQI0SLKrDeTyJAbxft5Ji3OlcVH23YNwYA/h7raTeHjKYT3zcZpZIx0UNc48QiVfq6VFiGsdj9mVMvzKGent4fPvCnHx1/1pHlpfIPQKvfO12OpYUneJzq810tAHa35MWx/IS2WGRgnlMct+R8RReMiHT5hGwi/Ajt1LWHQlI+NZ8WNkEbIBGAeuODCReJrB8cuTbHl8Zxhgetsmhl6dsw/Bv3b545gDdJuMnwVgDssyLmev4RSljE46RaJcpP3TeoWIVAyjaQ7sUm6gZsOycQ8cqBggqK5FxxMaGeg1DHR+qb8Va2uLuq5XZ1pDAofqNgIJamW2NETcmJuEnw6RedTktMWPYXg/cl7eS7a0bfHRuJds7xjGOcYxDBb8rtH5czQWsW9XxkoPxhO4yG90O82NKih/zeZ0G+KuvTWA8IeG1HeckoAFTjwA/S2yNAauOAG9qWkeAxE/6jMCN0Rh6F6k8X5Gb3N2RjuP7gLFXf23rBMVR4BApfBUD3JeW9wFeLFneDAriswLrRU+PTRogjI1bimXiSyZwjaBwM9cB0uuE4tiftVzVFuizF0XVxPLDqkjhFj+GJQOaryqvVispqJUU30rqaQYFtZKCWklBreT0EPN9QIkZ01vEWklBraSgzyWnyRA5IJrhndqmyP5MjuwMv97mV9wW8vwat8IA/l+g9f8FEpGfuPl9v0bHsz+Tg/8XEFv+X0ADxohPE9ifVnwCzC7McvDpKlsAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("d5c4ee5ce20aed9e33e866c66caa37178606234b3721084bf01d13320fb2eb3f"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEwElEQVR4Xu1av2sUURgMqCABQQVBBK0SCdooMQQD5jSFkNgpKdIEwSZoZ6GYJohNUmlhqrSxsUlhYZM/If/TmdncrLOz3+7drcneXtyB4f36bvNm3vd2l32ZmOiD+3eudMHZqWtJyTrbn189LqVfb+xAwZ17N1LxqLsBH17MZniuDFDBXn5b6+TEkxjz640dXLSmP8r/wgDd+74VILJoC5wLA3TFNRsgHqRQv/mh7+v64vkwwO8BSoiE4KLSrzd2UNF+QwQpdnn6aobs9+s1DpraFDZ183Im5ZkFSjVBf6PX0WtEcSh9PrUjmiBIIVpX8V7yd5Foxvn10efzqR0qwifMvr03y91fn9a7v7+8S8qf79eSvkfT13OxINvs0zpNYJ/Pp3ZwQjpRXUkIhWAIp3gSY5Go6JqsM55tn0/t0ImxxCMM4iACQoGXT3ZS0agDaCMGffhNdC0aGhnUCAN8hShIyRV/+/RBQs0AJUX5yuv9QQ1B2+dTOzgRlFx5PsO/v15MX2jwaPu4NJdw6fZkJoaPPGaCXtMN8LbPp3bwrU3FuwkoIRomsKRoGqS/5fX0zfDurckM2e/zGTkWHu50lfPz8wlnZmYSenwOR0fp9lBj0Ycxv77TL5fDwUE34f5+Znt6WGX4hCicRnh8DsciVbyacNoGqNEeVhk+oSoZcNYGTB4epgY0MgPOdAscC9ct8M8Z8Gxurwvij6N8vvAjJftYkjp+cXc3Q0/PVHxv4kXxYKafQpW9uLS9tZWlxg4KChzUAB0HMdlL29sJU0E9oUxP74/iMyK9j/2RARsbJ6QBHB8UZQI9O3wchJALm5sJUdcURel9Hu8muFkZ8WJGMlZkADgoygQWGaB1iiH7CfL4UJi0c2mupHg1gRwULpDtqE/HSBeUWXEzQFc/MiC39/f/3uh4s9N6RI67zkK4Ab7CkSHa9pRODegJcQM83jMkFd9r66OON1Rtq+hKj8XIAN8GZeMURXoK66p6bBTvv3VhKj4ygKXrLASFFQnsZwAmqWKSDNAVZTb0BHl8aIDED2oAykoZ4OmtwpUeR1KQiqNwF8cYj8sYIOIR4yvrBqgRlQxYWVnpgn5zczLOqaIiM1Sgx6YGiGgVD6rgoizQLTC0AXi97XQ6GVGrq6s5oYjxWNR1i6hhmjk0gsI8RrePxiATIwM0K9wAlq6zEHzHpzgXSOFRHOpqAKnbiCVX1OP9XsMY9pcJ1/3vRrjOFi1atGjRosX4AR9Ai5h5fT44+ViajrVo0aJFixYtWtQN/5Q29OGqfELTDyEe1li4AUMfr4sBmZPlcYEbUCUD+JrLDBhrAypnwHFZ6airbujXX//I6WM6zpjcgaefEbDtbAoGEcls8DEwPOJWA9wEtpsCNwClG6Dj/QxID0vcCK83BS7QRfq49iUGlJ3xl5HQTOmZ498NnKdqoItnO+rTMdLP9/XG5wce2u/zGBmKBPtq67jGu1h99Hm7kY/FSGC/LaB0A4pEs5+lz2NkcIEoIwMic8DofM/Fq/DGZYCnv6+09kfbJNrnkXDv93mMDDxJpsAi+nE7WSY+Et44A3hcrqKG+f+CyAAV7Qaw7fMYGaL/G1CBFB7FoQ5BRfeByAiO+zyq4g8lK5z2I+oYkQAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("31f477eb1a7beee631c2ca64d06f8f68fa93a3386d04452ab27f43acdf1b60cb"), + name: Some(Arc::from("Steve")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE5klEQVR4Xu1aMWsUQRgVVJCAoIIgglaJBG2UGIKCOU0hJHZKijRBsAnaWSjaiNjESgtTpT0bmxQWNvkJ+U9n3ube8ubNt7e5xOzdxX3wmNmZb/fmvflmdtm9M2dqcOfmxR44N325KFnn8ecXDwbSrzdxoODO7auleNTdgHfP5hKeKgNUsJff1zqZeBJ9fr2Jg4vW9Ef5Xxiga9+XAkRWLYFTYYDOuGYDxIMU6psf2r6tL54OA3wPUEIkBFeVfr2Jg4r2DRGk2OWZSwnZ7tcbO2hqU9j0tQtJyjMLlGqCnqPX0WtEcSh9PI0jGiBIIVpX8V7yvEg04/z6aPPxNA4V4QNm2/ar5d7vD+u9P1/eFOWvt2tF2/2ZK1ksyGO2aZ0msM3H0zg4IB2oziSEQjCEUzyJvkhUdE3WGc9jH0/j0IGxxC0M4iACQoHnj76WolEHcIwYtOGc6Fo0NDJoLAzwGaIgJWf89eO7BTUDlBTlM6/7gxqCYx9P4+BAUHLmeQ//8XKxfKDBre390nzBpRtTSQxvecwEvaYb4Mc+nsbBpzYV7yaghGiYwJKiaZCey+vpk+Gt61MJ2e7jGTke3vvaUy4sLBScnZ0t6PEZ9vbK5aHGog19fn2nXy7Dzk6vZLebLNHiN44LHxCF0wiPz7AvUsWrCSdhgO5H+B0PHxo+oKNkwEkbMLW7mxkwVhlwoktgX3S0BI6cAU/mt3sgfhzl04c/S7KNJan957a2EurMYFCl+P7Aq+LBpF2FiuCSbPv0KaWfUwcKPKwB2g9isOc3NwuWgvpCmZbeHsUnIr2N7S4ehOiNjQPSAI2rwyCBnh3eD0LI2Y8fC6Je/nC/9DaPdxPcrEwQ2T3ImNAAPacOgwRWGaB1iiHrBHl8IoyiVISnuJPi1QRlHVwgj6M27SNdUDLjZoDOfmRAtva7B5scyQ1Vy4h6juvN4Ab4DEeG6LGndGlAX4gb4PGeIaX4/rHe4nTH1zYVrfHF5luHyABfBoP6KYr0FNZZ9dgo3s+NRKkRkQFad70ZKKxKYJ0BGKSKKTJAZ5TZ0Bfk8aEBEh8ZoEaoASg9xvVm8PRW4UqPIylIxVG4i2OMxyUGiHjE6Ky6OBqgRniM682wsrLSA31zczLOqaIiM1Sgx5YGiGgVD6pYFxeZ4P2uNwMebzudTiJqdXU1E4oYj0Vdl4gapplDIyjMY3T5aAwyscoAzQw3QGNdbwY+41OcC6TwKA51NYDUZcSSM+rxvtcwhu0qxkWCfmv0GNfbokWLFi1atJgc4AVoFf3xmU+dSUyLFi1atGjRokVT8FdpQ39c1XeE+/S3QB4+dnADhv68bgbwTc+hX3GNGm7AUTOAj7qaARNpwLEyYL+uGTCWS0Df/vpLTu/TfsZkHzODbwS6JDKOGocRyWzwPjD5wqsGDDJB20YNNwClG6D9dQaUH0uqBLsho4YLdJHer22FAXXf9+uoWdI3xt8bVPGfGOjieRy1aR/p3/b9Q4d/8FCiz8fTOKoE+2xrv8arGL3f687vt8Kxui1GAuuWgNINGCRc+1j38TQOF4gyMiAyB4xS3YW7AUofT+Pw9PeZ1vZomURr3EUOygofT+Pgl2QKrKJ/bifrxFcJHxsD+LlcRQ3z/4LIABfsx9rm42kc0f8GVCCFR3GoQ0jVPuBZEfX5eIbFX3srPNN8aUvJAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b66bc80f002b10371e2fa23de6f230dd5e2f3affc2e15786f65bc9be4c6eb71a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFG0lEQVR4Xu2az4odRRjF8wYiuHIhOoIuRAYiMkNEREWYIRLjahJGmIUaYVAkszAQgwHJMBsRwexCiDAbxYVZj27i0ieY5/AFWk/DuZz8Ul33L/dPpg8cqrq+6rp1Tn3dfW/1PXduCNbW1pr19fVmc3Oz5fb2dluqTbGHn29UyfFWDhadwvOYgkmOt3KQYGeAV911kYJJjrdy6DNghAx4dPDOE8LdxvFWDqNkgMSmCXnM8VYOpSeA6Qw4vXNxINpU21NhQD4GZQAvB4stUTGOt3RIcbmyKlN8zYSM51i+T7heOpfzmTs8SU7OE0zhXQaUhHYZwPM4n7mjtMopykzhKc5kJrjNY/FzlsaAnffON+aVDzYGLE2cxmSfPDfHFC+/9XqRinE+c8d3V99uzH8f7LXUDUzHEqa68H/Xwc1NdUF19VFf1X1+jjmMnM/c8cPuhcbMx5eOVVpUF9Xn4NKbjz0Oc0zz9sdvPEa3cz5zR+kLDB9nFnvj/RdbZqbwsVca78G1MpfiMenJkvxSw5VPwSl6XHI+C8e7B382l249aqn63t5es7W11VJ19idu3jvfrH/6xxPZsfvtc41ir175pUqOR2icL398uR0vs5D9JgYNsHiT/QlNTAbwUvCkJfL5i3eLHMUAjSMjVUr4zDOJBoybAZpYyQCv3LQGzD0DJDrJ/kReAnkP8cox5UmOR8w8AzRZfbDKGt2HBmklPvrqmUHpDNAkvTqq58pl/2zPUmOo/snNF5oPv3h2UGa/Wow6O2FxF67/PRArYaKPFUuTMs7JizbGK0VxLN0vS4+9f/RaK9Bl9qvFqLMTNIAr7FhmwNWjf1p2ZYDaLZDi2J9muFxIBqgcZoCYcYkgT05OBnVNhuKypBk+z2NL2CSkzk6UDPAKD7sEbBCZ/dIIiaZZZI6tMShsVFJnJ2yAJ50Csy0vgezXdX5eJmrLrKBZN47uDz7T2eUMyzs9v1zVYtTZCa7euLQBeQ+RcApxvzzH/WWA6qVz8llf+jqeBmSMOjtBQeOyZIAEZAZYlDOEN1XXLdznqKRoGmATJjaAqZMDsd2xrJcMKKWySQN8fp6T/Sm6ZADnPbEB/ICuD0rSgExlZ0GmdM0AmqcY5zJsTmMbwB87/s6fvwHIjPNSyNTmypf6cvXz0lGMwi0+r32WYxmguzN5enraku2lODOAokttvAekaB5TPE3IcqIM6NGjR48ePXosLa4dvdQkD3/bGLAW4zgrC+32JLWn8M29V9qyFuM4Pbrw2Z2HDXn9p79asr0U53iTQPsBbJsbdm/92pAWyvZSnONNgu9//n0m46wszrwBX9++e7YN6PE0IXd3VM+dpVFeruaLk/39/ebw8LAl+y0taAC309ifyFdpOzs7rQnHx8dDz1sa0IAznwHeQDXZn1i5DPCmqDdAu5iboGkQ3/BmBtRinMfCwF3h3AX2Md8DZJzv+DMDajHOY2GgAVxhx7q2wWurXItxHgtDGsCXHF0GOK5jvtYelf782p5ALZYapgIN0DFfjdOAvAQobFT682t7ArVYapgKNsDXN69xMw3Ifn7FldTd39RTQMw29eE8JgZ/6/P3fokZl7BpKHF+rWWh+fwn3Yc6JgZ/6/P3fokZp6BxyVW2eD37Kd5t6kMdCwNfT+cLSrY7lvVMbaZ+yRzHOY+FgeJKpAlJC8oU58qTS5UB/LHD/w+UmPHS6tZMmPk9YFrwvwP8/0CJGZcoZwDF+5rn/WCWGfAfZVbEuZ74z00AAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("a3bd16079f764cd541e072e888fe43885e711f98658323db0f9a6045da91ee7a"), + name: Some(Arc::from("Sunny")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFJElEQVR4Xu2awYscRRjF8x+I4MmD6Ap6EFmIyC4RERVhl0g0p01YYQ9qhEWR7MFADAYky16CCOYWJMJeIjkk59VLPPoX7N/hP9D6Gt7w9jfV09O7Y+9Mth88qrq+6m/qffVV90zVnDvXgqWlpWp5eblaXV2tub6+Xpdqk+3JlysTSX8LB4tO4XlNwST9LRwk2BngWXddpGCS/hYOQwZMkQFPd94bE+42+ls4TJMBEptByGv6WziU3gCmM+DwzsWRaFNtz0QA8jWoAHA5WGyJstHf3CHF5cyqTPGTgpD29OXnhOulezme3uFBcnAeYApvCkBJaFMAeB/H0ztKs5yizBSe4kxmgtvsi58zNwHY+OB8ZV75aGXE0sAZmOyT96ZP8dN33ixSNo6nd/xw9d3K/OfBVk09wHQtYaoL/3UdPdxUF1RXH/VV3fenzzZyPL3j7uaFyszXl65VWlQT1Wfn0ttHXofp07x9+a0jdDvH0ztKX2D4OrPYGx++XDMzha+9kr8H18qci9ekB0vySw1nPgWn6K7keE4d7+/8UV269bSm6ltbW9Xa2lpN1dmfuHn/fLX8+eOx7Nj8/oVKttev/DaR9Ed8/dOrtS+XzEb27wwGwOJN9ic0KAWAS8EDlsgXL94rcpoAKIjy5dLiZ5ZRDEDXDNCgSgHwrJ00AL1ngEQn2Z/IJZDPEM8aU56kP2LmGaDB6oNVTqL7MECahU++eW5UOgM0QM+K6jlr2T/bs5QP1T+7+VL18VfPj0r2pZ1+qHcMFnfh+l8jsRIm+lq2DFLaOSDRgfEscVAsOavOItW3996oxblkX9rph3rHwABwhm3LDLi693fNpgxQuwVywOzPYLg8lQxQ2RYAMe0SQR4cHIzqGggHlSWD4fvsW6JOQuodQykAnuG2JeAAkdkvAyHRDBaZvuWDgrqSesfgAHjQKTDbcglkv6b7c5moLbOCwbqx9+voM51dzrB8wvPLlTnJTr1j4Ox1pQOQzxAJpxD3y3vcXwFQvXRPvuP5dZyvwFIf6h0DBXVlKQASkBlgUc4QPlRdt3Dfo5KCyMyCYwWAKZNO2G5b1ksBKKWyyQD4/rwn+1MQWRrfsQPAm5s+JMkAZCo7CzKlJwWAwZONY5lmXJ0CwB87/s6fvwHItHMpZGpz5kt9Ofu5dGSj6BSfa5/l1AHQ05k8PDysyfaSnRlA0aU2PgNSNK8pvBSELDtnwIABAwYMGDBgbnFt75Uqufv7yhG22elv4aCdnqT2FL67/1pN1dvs9DeA+OLOk4q8/vOfNdlestPfcaD9ALb1hs1bDyvSQtlestPfcfDjL49m4mdhceYD8O3te2c7AAOeJeTujuq5szTN4SoPTnZ3d6vt7e26FNl/7sAAcDuN/QkepW1sbNRUEPb391vvP3UwAGc+A7yBarI/sXAZ4E1Rb4A2MTdBM0A83WUG0M4DVo6nd3BXOHeBfc1zgLTzfJ8ZQHvapjr//7/BAHCGbWvaBucML3QG8JCjKQC265rH2V3Zth/QZqeezmAAdM2jcQYglwAFdWXbfkCbnXo6wwHw+uYaNzMA2c/HW0k/+fMNILJdfTmezuBvff7eLzHtEnYSSpiPtCwy3/tNdF/q6Qz+1ufv/RLTTkFdyRm2eL3zKdq0TX2pp3fwaDoPJ9luW9aZ1pn2TUsg6xxP76C4EhmEpIVkanPGmzgXGcAfO/z/QIlpz9TPGaZYcmbPgJOC/x3g/wdKTLvEOAMoXmvd6730TJhFBvwLF5puRHnAy2sAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEw0lEQVR4Xu2asYsVVxjFNdgasAmCRMiSRMXCBBZsXpRYSdhmsbOxtNFyOzGk0DKFCUIgTVKk0lIw5B+QNGkD+VvWkTNwlrO//e6+nd3ne2/MHDjMnTvfu++ec787M+/ed+rUHHx85nS38cm57sK5s53KPrr83def9bx348o+up7tjQ4pvDLCQtOErGN7owPF65hZkWIrsr3RIUVz9JkBFdne6JDzXUZwGlAwyfZGB4qnCRRMsr3RIef+1U/PH7gXWOgH/RTwqNsAZgDFpwlsb+1gIZzbeaPjNOC1jMljVcfPsj9LR4pj2UeN5sPbX3WP71zvqbLqKKwyJ9tKs/0Z9mfpyM4mLTqFk75uM9IICs1jxrE/S0d29Nn9rV7YUcRXJuhcbaRwiqcp7M/S4U7qhtUSL3x78aO9c5UFx9EEtVVNBU6XtTAgO2/RaYCzoqKupQH+bJpIM9KEtXhK+HHlRxcFJXdml3v6PGPNfARWbTKW/Vk6+OZGZmYkKXYeHcvPsD8rx+tfdrrkxsbGPjKe+P6Lze7PH58fEKw6XXO7T+/d3kfXsz3iv2tbnfj3l7e6J3dv9vzp4fbczx0ZNGBzc3NPvMqMJ9KAHH0a0CLbIyz+98uzPgsXnkm/PbrfJRdtwL+vf96jRWcd2yPeewZUBiQZT9iAvEeo/D4MWEgGqAMSquM/L5/0dGd8XsWYSkUJE925N3+8OHDHlwG+7nh9VtS1irr21+ff9JTgFO9pkG05TqTOJnL+UVwa0IrRl2Un3FGZIFqMO65O0gALE11n4RaVZce4HX6/4qiziUoYMyBNYFlflp2uRirFZHwVm1Q9jTBbBrh96myiJf4wE5I0IEczhWUGHDbCFEqRRyV1NpEGULyu8V6Q8Sp7NFIAhdAQm1AJzxiR95J8i6zo69TZBEVRcEu8aTFpQiU8syGnAGNTvMoUztdwCvc16myiJUzUnX+eARZDMht4vWIVn2Jdrl7HWU+dTfC5P5Q2JsmplFNqKCvRVRZkvUidTVDQUD64canJH3591ZP1Q0jRpt4AbYCpuqUbQHIEScbPYyW8Ep8miNTZRL7ni7PZrNve3u6Pouv5q9BMUSrzHpGkCT7n9TzPdNdR7/1Oe6c+j4N+G1hoim2dV6wE5ZxXfb5TMJ4mkTTAP34sWHU+WvygDJgwYcKECRMmrC20qvR2d7ckl9AcqyPbmTBhQg3+Zh9Ktjc65C+v1i+yvMY6tjc6cESHku1NmPA/h+8PJleQGE/kErkXN0a1wEEDhm6vayncJnjnVys9jFtbcFFzqAGjz4DKgCH/L0gDRpEBSvNc4OQiqN8bWougSvnc3Z23VeZz9mNlyPluAyiSMUnu6npLjMKzrOvsx8pAYSozA9IElisDPNIp3PuFPrIfKwNHNQ1omZCkAUelv59rAlwzMP2PlYWvF6QBfE2mCVW2cH8/t7pbVAz7sTJQVAo+TLxpQRTunR7S9ezHytASRrbiaEBug1XCvVXGfhwbTNeh5HN/KC0wDeB+YEXqODZyhKoRy5HLo8sUNJRM8dzjbxmw0DdDjuhQUtBQ0gCbUBmR/w2gjpXhpP8vyPTPe4F+B9CA3BZnP1aGk/6/QMK8v5/iMwMy9W0C+3FcvANG/3mGjdf2WwAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("f5dddb41dcafef616e959c2817808e0be741c89ffbfed39134a13e75b811863d"), + name: Some(Arc::from("Zuri")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE10lEQVR4Xu2asYsUSRjFvePSEzY5DkTB5TwVAxUWTMYVjeQwGcwuMdzkNtxMPC5YQwMVQTDRwEhDweP+ATExFe5v8fp4DW95++vqmS12bae1Hzyquvqb2nqvvurpqdpjx5bg+A/fNes/rTUn1n5sVHfp+m+XT7e8s3l+H93O/kaHFF4ywkLThGxjf6MDxavMrEixJbK/0SFFc/aZASWyv9Eh17uM4DKgYJL9jQ4UTxMomGR/o0Ou/Qsnf+48Cyz0q/4W8KzbAGYAxacJ7G/lYCFc2/mg4zLgvYzJstTGz3I8gyPFse5Ss7l981Jz7/aVlqqrjcJK5mRfabY/w/EMjhxs0qJTOOn7NiONoNAsM47jGRw50Idbt1phBxFfMkHX6iOFUzxN4XgGhwepB1afeOH6qe/3rlUXHEcT1FdpKXC5rIQBOXiLTgOcFSXqXhrgz6aJNCNNWIlvCX9d+auLgpI7s3MtfZ2xZn4FlvpkLMczOPjmRmZmJCl2GR3Lz3A8Xxxvn+40yfX19X1kPPHnmY3m7wdPOoLVpnvu9/6dm/vodvZHvP/1RvPvxVstVddk7P5+reWj7fnSzy8FDdjY2NgTrzrjiTQgZ58G9JH9ESn+xbnZXjYeWUY9v7vVJI/agI9vH+/RorON/RE24LNlQMmAJOMJG5DPCNWPyoDSEjhUBmgAEqryw+vdlh6Mr0sxptJQwkQP7N3LV50nvgzwfcfrs6Lulah7//xytWUKt3gvg+zPsSb1dpDrj+LSgL4Y/ZEcgAcqE0SL8aA1QBqQwtxm4RbkuvtxnPviGPw56u2gJIwZkCawrj+Ug2aK5uBNx5dik2qnEck+A/w3VFJvB33iF5mQpAE5myksM2DRDFMoBdaSejtIAyhe9/gsyHjVPRMpgEJoiE0oCc8Ykc+SJN818qFrUm8HFEXBfeJNi0kTSsIzG3IJMDbFq07RfBtNIzLGJfV20CdM1JN/mQEWQzIbeL/EUnwKTeGlV3K2H8gAfu/X0sYkuZRySdWSgnmdWcC4QQz4Y/NsL/969qYl22tIwUm9/dkAU22DGkByBknGLyNFW3hJfJpgI6i3g3zPF2ezWTOfz9tSdDt/FZopSnU+I5I0wde8n9cWnVmgd36nvVOfpWIOZICFpti+6xJLgnLNqz3fKRhPk8iSAf7hY8Fqc2nxB86ACRMmTJgwYcLKQrtK/3361Etuo3knSvdUsr8JEybsB3+z15L9jQ75y6vvF1neYxv7Gx04o7VkfxMmfOPw88HkDhLjCW6Re3NjNBscNKD2eF1b4WmCT329I8T4lQM3NWsN4IHK6DKgZEDN/xdwCax8BijNc4OTm6B+b+jbBFXK+yDT6c8s4HGZqWuOZ3DkercBFMmYJE9zfSRmE1I4S8VxPIODwlRnBqQJrJcMyFnO2c9zQ5ccz+DgrKYBfSYkaUAtuR/A/YIk9wqOZL8gDeBrMk0oZQvP9nm+v4iK5XgGB0Wl4EXiTQuh8DwRYnuWHM/g6BNG9sXRAB6DUTzvczzVYLrWkt/7tbS4NCBZaktSTzVyhkozljOXpesUVMucYZHn+4sMUCz1VIMzWksKqiUNsAmLjMj/D6CewXHY/y/I9M9ngc/3Swbk0TjHMzgO+/8FEuSz/RTPDEjm/wdwPLX4H8bzDGXhwa6jAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + // + // The skins below come from free skin packs released by Mojang for Java Edition players. + // + // MINECON Earth 2017 skin pack + // - https://web.archive.org/web/20210416010507/https://community-content-assets-cms.minecraft.net/upload/094a8f1a146ccb6573e08e0e35f9ac90-MINECON_Earth_2017_Skins.zip + // - https://minecraft.wiki/w/MINECON_Earth_2017_Skin_Pack + Skin { + texture_key: Arc::from("6c25523e7dabfcaf0dbe32d90fd0c001d5d57ac66206a0595defe9be5947ff08"), + name: Some(Arc::from("Globe Alex")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAB3RJTUUH4QkXEAQ4gVqxOgAAABd0RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FNQQAAsY8L/GEFAAAABmJLR0QA/wD/AP+gvaeTAAAFdElEQVR4nO2af2iVVRjHz+5uV1Jua7OxNRRdWxMW6HIr+421IoOCSkXLZor/9ItFkSzyR0NHKViQEEEQQq1BBGp/RIPWP2GBZtYdkWXeErGwVpSozanbW887nrPzPve857zve86971vbFz7svefXPc/3Pue87909rLu7m6mYu3i9k336bhe4RrCsrnW1Et34QTgzPOwgfm327R90RIKOrW1Qv7bfAapfW+Tg9cyehe5roNgG+AVOy8MGjqRZQE3//V12pmWVe51ilwTtZkU7tm8vU9Xfec+DTtSxU7oGY7Ma2fD1zWykLsNuq76LAwJTsrPbfPuq6oLKL3hZ+cBHu5VGyaQ1AAJHPV7V6gICE8S6OIQmQOBRggdpDZBJNKLYeq6rS5refuVhpTXg5oo3WW/9ly6iHvlpwgBZqttIfz/R4GEPiLwP6HbJpX0bHWCgfw+n9o3jHLwz+FGsu4Dp7o+weffv5JOds+moM6/nGL/FIWgCIAZPgb4wBvaD54SKFas8zw8AlGG5X5DivT/IcwDtE9SAgiVwbuRiQZZ8OvQxh2r0/HIO9P37ivFb5djpC7zNnzW/eKDCzUxMbSiTIcti6Oe3LCi0r8eAaWfHJw23NwiAUp55X/b+XGLw54eqlW0rf63zvFYFqJNo0NeDPzBAJumdApaAuAyanj3spjGCj7wApLgItoEnQ0Bsi+nvBy4H2RoOm8Z0PwiyJ2A791kfDYBJy0wAXt62hEPrAOiDgcMYusCpAVGCjmqU1AAxC8RsEM3YtLmdg0HTwAEcT7bpUXAyYTY7m1nBcCJ+JlDQALHMs0xI8H53APEuQD/RsAbQIOk3Qxq8WMbEyeHkgxhBwaBUwfvdBnWB6YL2C1DWl9YVDNz4WNoReXXDMx50n0Zu9RxHBR2fohv/4nd7HeRs/yuh+1MifRcw0YqODiVBNXIsb2U+BQZEnVCpNW1ug5VxSp4BtmQrA9w1v/ztVs6Gz9Z60K1ZusZPdDUr0e0RFFjn4rqne8DQW88XAOWIbg9I5w+OsvzBQ9yQhracxyCoV6rZzgcRl1INbeVsWedCDt0DoF6FTpW3r3NJqoq6B0DgsFkBSTXBugEQaE1Pjm29Os136if3bWaZ9s5EmmDNgMaHO92/EHRZ/kP2+i1beB1cQxnUYbukKHXHwL+f2pYcJ/tErweoVyGKBje6/4RLkpW6akYZE6ltTHmQSWyvUvmiWS5UScoC4yWwdPrPLgv2drk8dGArBx5WALg+uesFl6N9O11Uavn2OGfNb33sxT8+kAJ1N36xowAoR3RKmxpw7U0LPK+bGubz65fYN+Nl2fmsavT7QOPN/uSw+zfILdaGUpl7n2IiTet6PdQsWVOA2D7wG11+KXu0fIQtPnXEAwQsAipV8CDjDAiqjtN/ebJjQjlJmVdH8oOh3uurzyfGvKb2PnXjsN+fTf8fYPv9TJkyYMqASW6A8XPAAz/uckRmXjmDqaDtdZjOTydjAyqqxjxU1meU0PY6ii7TFKL/8Tm3p90DtsPXuv8YURK/BFTalru1THadKNnOgLDt484AawbgdRTT/jcGhO0zaQ0IY0LiDfivE/sE4ib2CcRN7BOIG+sPQr0nexyRyy6c8qDrv/6GrLNxZQuHjmd7von8ddjaL78BZN0AG+cLbP32H0STPgOMNxF6XoCeLxDPHsjOF8h+21edBxCB9qbzT5saSM8PvMfeUdYX6DrTGZjJeAnQ8wJ0DxDPHkQ5X1BsJXIPKKWmDIh7AnHL2AB6XoCeLxDPHujOF8Qh6xlAzxfQ8wdBzxaUSrEvAdlv+6rzACLQ3lSxGxC3jA2gZwfo+QJ6/kAE2setRGYAnAeQAYLf/hEb+gfAQppb6p3a5gAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("66206c8f51d13d2d31c54696a58a3e8bcd1e5e7db9888d331d0753129324e4f1"), + name: Some(Arc::from("Party Alex")), + variant: MinecraftSkinVariant::Slim, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAB3RJTUUH4QoJEjAE4f2wWAAAABd0RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FNQQAAsY8L/GEFAAAABmJLR0QA/wD/AP+gvaeTAAAFFUlEQVR4nO1aXWhcRRS+XeMmClmbNVtTMdjUqlHrX5QSC0oMUnzxoVFBEAqSh4oRBEEEH0ooIvjggyDiSx4EkUgfLC1IEC0+iFKoQluFFNpGqLbotqtNGyVtynW/ac/s2ZN77+z9nb1NPviy586dmdxzZubMzJlxJicnnSBW3x92JX9/+34tH95xpyJPgwwi3VS/bRacFtBR6W5iZ7mk5bzDaIAwSi7W5hXzhJZ6QBAGnn1OsfTw6DIivd0R2wB5x6oBbH+Abax4A3jO8ybyOT8uc7EO4MA01/P0eO6mOz8UaO5ulVCeELasF22jpR7w6J5Oxb5X3nM6N9zlbHjtM/V8PcDTAFAUBKDo3JEfnLF3ruj3kJGGd+fOLDTlzx1o08K5NLvXXZj5QHHp6K/q2ZP1dzpf/VnWQ5sivjmS6badoCM/GoRCFy/+pxUDFz/9SKWBkCmd8nrVw5WFLHeLbWkAalHestwIXHkygJ/yZIB23i4vS7jv4yGXs7+/v4mmCu9+c8QF/Qyy6d3NgTTVzxtn/vPX9W9UA6SyEvzp8c16l8gJ3FguBtIEzEJgsacjkW/N9VL4hvKAc6U2p36jItcGSAQYdxjrNAYnzvTp8f/U93eotMqLWzUpH5WhMc+JMSlpcpTSSVJd0hETLx/8cNn/4P4hlA+4XLukDXLgy9u1XD2yztdwvIzEY4d+Ud2SODezP7ARsJgCaHmMaFJW0EOAO6DKQ39FrrBj09X1/SNf79cOywsv9A5oPn9PtzPSXdChNMAtn438DWGgDcBbNKjlvcA/trtyWhkBfOOWV50HD+zW77jSUsHyvWedoZPfaeI5CxR+3jjicB7a+LJzdHSXImSkndg+pCnzA2tqvbrCC9XGEOLDCUpT75BlgNqx4Oe0UNiy+xuHSFj87YQigeeRJHDlRrefbpJfGr5VvUfvkHm5DPCW73liNgEVg9G0moBCN5cqTRn+na8GVvDPqWtD51RX/c8lZ61Tqrd84z3vBX//OHg1/7W8jXJdOs/5w6VmuRF+SAWx1wFr+4tNBJaOlzzpld/EtOFpgGK9FxRFTwgCd2jrx45rJ7jtrVktU7rMjzQ8g+jyXM4CiawEuUPjM4jfmoLnp54BcMeXmRP0SuzpXa8YBtyZkbMD+JqCp/NewB0fl6WDTAWDg4N6qwsZHH5mTJGe5XtOuZ2V22lJ03ZYEstaLHs5Kc1rKUy/keMBkmHjAXmj9Q+wTWOGFd8DrnsDxI0H2FYgLlOJB3hh4qYZFwxVKAMkHg/4Yt2+ZUpC8Xa9UJVIPIADix0YAazu/ET9QvmFY384W//sMleQNZIeU+Q/tuzp06QYH9Jtj3nJVAwAR0rKk0xGsa1w6gYgI3jRtrKZGSBPtP4Btmn9A2yzMD097XLu++pb9+BsVRGyfC9pdQpLAtjTwxJ8jy/jAV55eHqeacwQdjMU934BnRHi/C+J838TjTHB8fHxNZwZdMpMYTTA1NSUy5nFRxGSOP83YfV+gNzrUxzAL5DJnzGGZHla9tLS16scl3lQk8Y/fuV9AHl5K1UfwHeDct9vigPww1Eu83JBdeB+QJaXLn2HAI/hAzxe0MplpijA3YDbxnamUrcffA3AWw/wa0F5dO51vB5E2+iYeGBbY2pbqjN6MCiXiD0LJHW/wBYSnQah0JO79jaxHZQMwopfB6RigLD3C2xitQekEQ+Icr/AGuLGA+LeL5Bn/Pz8X94JiHL+b+L/USxRwWwDOUgAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("6acf91326bd116ce889e461ddb57e92ace07a8367dbd2d191075078fccc3c727"), + name: Some(Arc::from("Cardboard Cosplayer")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAB3RJTUUH4QoJFhEoWLNhhAAAABd0RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FNQQAAsY8L/GEFAAAABmJLR0QA/wD/AP+gvaeTAAAEzUlEQVR4nO1aSWsUQRTumbgkZNNER4NoQtCb2yFicL+p4IIIxoOgglFBvCgoihsK+gfcTiESQUUQT+pBFJGoONGLFzdMcCExQ0CjIVGJJa/wNdVvqqump6qneyAffFDd9erN1OuqV6/qlcMYc1SsmNLAVOzY2KykTr+OnZcOMCQ8P+84wkSa6k86RYiBnj7OPz9/GesqSgMAqqZMtqKnKA1gq/OA2Btgxtq9nGEh9gYIG1oDTKyozqsuLLx51Ol87e62p1C3TNQ2LGBAWPJk5UIsgx27VnDSOtm7oHRUnculLFImo4sj7p7Z6eHjdNolrQuDPNAZX1rh/qFCl28d2uShaAB8h1/L7zkfYvtx9iaTHWRuni3I7zy5fJDxAnyFKBn0q5l+eUqPAe7vWcVJ/2RPz1u2fU6NWw9leAdOLpf2KoqxPiXMUXEfINsjiDI6HbK6rGWwt2/E89y2eq7z/txuZ+/WpU55qpITyvAO6/3aFgOS0AEkdkB8J6L5dDsnhSgLOsT2OkaNQJHgsxM7OG1ifMVEX6451pbAsl87UUbG4R/flHVZgUHljEYmsmXaQg91gUXVhlXMhEH1o9/JN/CSjoCy2pTLfHAhMSJlHDG2GYr6D9hE3fTS4I1gLabzPjW/2SVdt6msbB77bYxM/UMYPiXwCBgZ6NfK7GOl/GuIhHf5gKVGPXRmf/RSJ6+B0gDDOXSW/iBy06iXfnJRI3abIYqLmT+e57qSWZ7nXrq6ZILpj70TpFNJV6+Tp4i9AcJG7A0AewuRtuWlBgDnl4sDjAKwG7WJ5IPHD51Xnfc8HPyc5oTyu9cvXYIs8M7tGy6LHXwEfMp8d0mhqpMhUfVFSQq/5REpc2p4LiEbDXk5wZlTq11SqOpkHS5JTVBSZ5BCI3l+XatzePEWl+0tm53ra9dzQlmsA1nKqDDU/8OOIl2sHPQ8IGzCnQA8zwOa6ov9MqgCP9ExRFEboKxykrmSYpwCNq/IOJh4xPNzXQOUVyUsRZ269jqK5/jYafFcn+qnuT+tATBDAsK5WBT+lCxbgxTrZAalHQQ5qlOl3zaTMI+szKX/QH2oE3JwyKutK3k+rrymlhMhlgsOcWjlMgLwy/qlrGAk4fCXUTViohgB1hWiEf3ycVTeT47Of+oH/D5YYB8QhgFUycpcDYB1tENUf6wNIMvM0qHtZ4CuKycZNYBsdJkaIJRACHNvtQ3T3fKHF13KHB/l3+Rvqbx1wFUUyPWLZGyAk74Xr68gqUVlcQJd9mTOTRdfhEXrCmWBkDj0Meag7Qrt/UMzgJ8zE/2CjV2cNQPQWP/U4nns2polnFCm9bq9gc7rx41agaCbIZo73Ll/0ENde788I94D0LXHEYYxg07e+ipgerfAFOIZwaJtZxM6+aI+D5BB3Nekrx5lOvnY5wbhZDfI7TOIPQLB1InQ+wPi3QLqD3K9X2CS/xejT/H8AB101l3hKA0Az1mdOtDoYeWu5Uqa/n/rPiCuKTU/RO4D6O2xuiGS/8+Ee7ss8lWAprLEtBfQNP+vQ+QGiBpjBghDaZzvF2TB9DzAtD3G+Minx9d7SOspjZfBZU1Nifr6OR46Tg0nfQ+ylGBEm/cLCg0rU8DkfkHkMD0PMG0vmwLiVAh7CvwDe09qkP5ikt8AAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b9f7facdca2bf4772fa168e1c3cf7b020124eb1fc82118307d426da1b88c32c5"), + name: Some(Arc::from("Creeper Cosplayer")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b7393199a84eb9e932efa8dda6829423875eb65af76cb82912ade62f93996b9c"), + name: Some(Arc::from("Creeper Piñata Cosplayer")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAB3RJTUUH4QoKDhoN8MMxLgAAABd0RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FNQQAAsY8L/GEFAAAABmJLR0QA/wD/AP+gvaeTAAADtklEQVR4nO1aPWgUQRTeW4IX40+MUSNqGjVVNKiFFmKRxiZgkVqwCAjWahRsDqwsg0QrOwsrsRHBziKICBYSQTiLwAXJJcaf4E+iOU/fyDtm5/bm7czbuZ29+MHHN7c3M+x793Zm37sJSqVSoOOuvYfqHFLzU/xRulkHLtw/HlFk7/BVLan5wyAHWD/9uEm/HH4oyEUXewbHEEZWg2DLQFF8lrVrZuxva4U1v/cR0PtuvGG0jG/VtY0ZAYj/EZBSBHjvADASjAXICk4B53DhvQPASOC/cA+alA3uPs8l7OXLExcb+3q725lHwPdKRdu+FdQKwNuD+wp4Hdp4PckcunbmDgAMnpgQNwUK6N99tnFdBl5Xx6pjWs0Vdz1zB+ANtdJW/U3m0GrWa0DP9IvExPd3kzEUM48AQHF2ldTJpScF7A/tJGOSaOYOSHqjnLE6zdwBgPDVrFYRU9XROtBkLDm3mh/P3zhX15HKr6n8vHy+T0tqfvV+Fu9eFkx6f7msB1D4OV+2HtsRDth0YMh6bEc4ACPAyhHUM0+x79KzCPtPTWup9qfIvb+2rAHPj45FdObCtWD961ysqn0pdQ6uh9/e2RohVG1/11YFsQ9+hu/U/hRzEQFx2LN/ONg8ckYQP3uJNCIAf1lQIO7P8l4Nit+rY3TqfQTs2D4UURnH7r0RpMZQ6hKpPAKfV8oRBeDWdL17Z+RFRe1LqXOkvQjKj4JK0wUwF48AYGDuYESLr2viF1QVvlf7UuoabAfYGOSTE1KJgA/vK9o29b2uDYBXXCRurbi9ctFwAL5Hq0ohqRFcJ8iofVxIdG+JoC4KnyZH6jJN82vXVHMF7ny5zAbXjnQLpoFYB8Q9ux0LNSRMS1R5fwSEwTCRrbbbYFNS84cntz0SkWCr7YBpDcGkpiDWAFxQbNUlXBoPCH02vh0QEbC89DTgqGuYps9G6bR86NCGrhfBVpllUpKLIDgBDwzYqmuY1hBMagrhRjYeICLg1+jLgKOuYZo+G6XTrp8xLjEhs9VEa4DrMONgqme8wFESUHcDT9hq3tl0gVsP4BYpW43DcwDUeONcIO2QdQWTMwAm9YImB/haC+CcAdDB+4IIGm4SAXD4SXe4KgJOLQCU+8cFl9x6gYiAPNQEWoGbLndEOsxxgvcFEdcIfa8FUODWCnJREKHAcYL36TAFbh6Ti4IIBY4TQt9rARS4tYJcFER0gLQXjLHV0LdagPz/v3wuQAdOzSB8sHilAMbYahxszxogUv3/n8AfTy+dVTxL25AAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("7cbe449d9d37c111a07a902e322d3869d98790c48f1fa16a24bcbe2d8d73808b"), + name: Some(Arc::from("Sheep Cosplayer")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAB3RJTUUH4QoJCg0vNZzzbgAAABd0RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FNQQAAsY8L/GEFAAAABmJLR0QA/wD/AP+gvaeTAAAEO0lEQVR4nO1bT0hUQRh/biuiQkmKlGKmBxchspOXCOrWuQikIKs9tBhdukQQ7EZgUF3zIkJ1COpkhyQwKBWjPweP4QaWREF/hAxSF4PXflOzzPt23sx782f3va0f/Nh5M/O+9943M9/M982sk8vlHBF7Hk67IrZfuiKkTL6MXS8+uCLqPj/hGMCXa1kuTWFbQxPh4Pa2UhpIMdLg1vEYRLYRBVQKiz/XjMuMhQJSzU2B8lQQCwXwWt5Ub4iFAgDQ4ixNITYKsIWkCSHF6caEGGWMFepc5Ztl83wlCPP5jpl8Gelcv2dhhUtazrsXGOTZkVCA3wfQD/QDqwCeIlj5sVIA+1HAzdsjXKr0AJwfSSO4WVj3XNefGnOSw7dK15CGPBU0fl/2XEdSARjF1nZ+3TlXuoY05PmhvqFRKnO9pZv8Rk4BvJenrZ/sOELI5vGAexAPtCdETgHCl5+d+0MF0BYvQ7UNoMrsoDLd+c4C2D9eTB9yRdSNH8gok48/Puz7WYkHYOy9fJ5LG+hO7SRUReRsQKVRUwpQ6gl4DG1cP+6hzCbwxvHS8jtCKoNe2zSEfqyKDejZtdsZHxhytrS3EEIa8qIII+4wRuHGCWd06ljpenSql+Q5qdM2HqcFqzZg8uoDwijDSg/oh5b+WkxkBsj1BUinBmw8ShsJsJy9B/pLpOOWki2jcy7LuIM7BJJ9vYT/AmpqHaCC/wqo9gvIMHP3rIfvX+ZCUQYrs4BpUGMLRpm1Tb/yS87S3BvxzV19wuLI9wA/5CcemREk8+fvdwx6qBsPCOuvY+LzAbryYtcD6DkBoAnETgGmEUsFwNa4scMSMv/bZFxPNR7gtz/od24odDwAQsaUEJdnyZZR4OtKA58PYM8LhQZudVbbWJu4l/j1Gj9tizYv/cLdqj0AyqsWEbKBsKdCvv1YCVTPmALYYTNxOO3yyNYJCxsnxADWegB7nk9rjCqgbWtr8Mq6Kyl2nLKEMXg0v07IG6+qNoDKMzULxMIZgg3T1b/pV4U1T74uEgf373MzmTMeZrNZQpwPdTFFwk0sWDJP79UB048nykjLRJQqQOvtBFgtthTLyOLZ9KTL8nVrq/ups5MQ0rgcU3UHx9RusS4dcHHhQ4MQu8ZB3OOosyxDNx6ge74AW3Td+2X1Y7MSDAMaLwiy/qhJBYRBTSog1PSrGw/QPV8g+0+QjNhzxZ4j7+gty1jGA4JA5j1S+8AdAtVwYEyDHQI854guzmrCBshaWxQbqAkF6Pgb2gqIy/kCvxhBcnhhvugxzYcUx9Rvcso8Qrp/B3t31QQMDdr94Zd7ELvSL2Ua1JjR2IBf7ICtw8JqPKAS4MUGwsQMqm4E385eFPLJ+EkPcbk2dOMBsNrD/+Vxn98kxPl4lchbKbL3UxmiVaWuO5z4PDzqNA+lSwR83NggBLBlUBfTBozt/QfAb8Cc/0AUtn6wAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b182ad5783a343be3e202ac35902270a8d31042fdfd48b849fc99a55a1b60a91"), + name: Some(Arc::from("Cake Steve")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAB3RJTUUH4QoKDhAf+ZWo7AAAABd0RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FNQQAAsY8L/GEFAAAABmJLR0QA/wD/AP+gvaeTAAADsUlEQVR4nO2aT0gUURzHf24rskGbhyBRyz/gwQ4pJAmBlQdNOkRGeSqS/hBhUR2SyEOLBEGBt06dorpYVCerS4JQdopMKFIIBRNDIjXKXVZ4+Rt3Zt+8nXmz7nu7v1nWL3zY2ffe/Pi937z329mZH0QiEZBxaV89U8HLvheMRZm7osr2PQfQB2BVvxeZ9WmSUGEEYFXxb1OM1dSwkRCsHSeCoGo/AHmi4LlTAJWV0NLUAqON1drs5k0ADJWXGx8tZTXaTOZPAEZGAGZnAWZmAD5+AigN67GbFzmAS3qiCiAHxNyv9sKSunnVK6zK2u98NHGVo+549Wd4rn8C4Oh08oZHZZKyceRbYGVizgAWYklQpSWpgxP9OP7ni2E7w+9t3202eZl2E3ag/3ATo0RMSnPPX3E3Osmrhm3Yl0mis+wlwO9mHzy60MGG+7ttYJsT4jgv3OzwyJzmUc32bqxFx2GvYLR5Mkk+4iScyNbE0g5AJlc2FysgZwEws7HuvZ2rGyXlAIgN4c47jGeg75oNL4PtZQEmQ7Qv4mX/15OrzORzX+e6zxfJ+c/gnt79UtLVjy/TWvxJCUCmDuVaFbuqtNghvxHKVLpWgLHnQ5GXFq2j72xgm2zPinv8bO0mKV45QgT3Ob/vxRzw9mJrCthukvUcsGNzkY26LQEp4ngvsq2sbIHzvT2O+FGB6skVaBz6Y2Cq6EbMIHrlO9Q9Gwcc44Yo2UT9GATbCsAg4MRVFe66B68fd1nf8Rjb/ChtW6DnyAHreGnwOnScHITQ1IQBHmOb01hqBXUa4yf24O59a8nzx36aPEp5BeCE3CaFE0dk58kCkotgKQfg4HSbjbbgXoPBmw9tmO3ieORpw21HsK+5+DQcrz3hCPYdnWtPAdtNsh4AVLyh3mLrziop/Fg/KLBt/hZsn+222L18yKIZzgD2y8h35cV/gQ9v5h1xU/HYVwtP5erBg18hd4AacgeoIXeAGnIHqCF3gBpyB6ghd4AacgeoIXeAGnIHqCF3gBpyB6ghd4AacgeoIXeAGu0PRAbGShhPOL5ow+v8Y/ExdrluwUK0p9tfXz4R0vbmNw1pD4CO+gJd7/7TUcGvAOUkwtcKuNUXmDjVFzi925fVA/DgeN8lwXxTwQcgqGrAViMwOQ7RoWoIhSqspsblZN3B33+p9QRQpuqBmgp+BWwEgNoBam0EgNoBp3f7snoAHhyvKvIAUEs5AGK9ANYU8DUGfO2BH+sLfLkCZPUA63r3n4b+A54o2ypxtYV5AAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("c05e396bbf744082122f77b7277af390d11d2d4e93dd2f8c67942ca9626db24d"), + name: Some(Arc::from("Party Steve")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAB3RJTUUH4QoKDhIdJa2rQgAAABd0RVh0U29mdHdhcmUAR0xEUE5HIHZlciAzLjRxhaThAAAACHRwTkdHTEQzAAAAAEqAKR8AAAAEZ0FNQQAAsY8L/GEFAAAABmJLR0QA/wD/AP+gvaeTAAAEGklEQVR4nO2bX0gUQRzH10uvtCQVzQz7o1H2h+jsIZECMQiil7KHIurNh8oKhEqCiq5UKB9CiEIKCgwRfNLon2RBglTQSwWRBZWoGGZq3ClySFu/gVlm5mZn7txdZ1fvCx+dnf3tMPNzZmdn5qcWDAY1EZWFC3QRL0+UC5GVrxqfFoPWpftM8bq83wKLmrEDvob+IryuGTsgMQTmiBJDYKYPkkOgpbObiyckm+cx17ammELeZ9MyPPEd0FB72ujyPEBmabfLNzipayKuXzymlVx+pL3yFSBHAJAGQLLnZaiWsAfUVJRpSQv9WlfXEwQWvgZngI2XRTngYVsDAis7K8NIHz3cxk2TNuzzXlBUD/jc2RNllN5zW+s4NaKl/viCgDTkxfKs20U5ADegoXIfAqvm0k0ttKNKGz7SiIA05GGR9l5zgg/GMNtgnmp3baQQCZcHZctQLnZe3F58XCcJZCRRyObVZ90vdBGBQECIrPxvfUM6yYcLFWjfAX4DjnwHxKv1qzdxsUOZWZkGdijKAf5F+QZWFL5TRcG1CYcRKjXvV4NRY35naZ0BXG8u3EtRvOEQBW+cj4WmjDGJgTye7f3HHUK6371Hz/KAdwCvTHgGY/s7IBIJUddpadlRgFaeb9XGK4IISJvZ5qUvEeK0HBkCH3t7Efk5yxD42o2y3QFbiooM8JuazHOb5v1L0DUOGAqFKWZLrnHA2tw1FKTg/TE2OhYFiHUcMDk5YiDV0nM39NRgu0HarbcU5D2wZSGnHN6UBVMVec3a86YuNt/RaTCyuFBLyskzKNk2bQAi74EtC+lt0uP4rzTwa5j6q5nZm+U7rWSrBTQe3G2kq9ueo0bWl/M/o3lbYD+nxNtizSf3a/2C+yv+MzI6TuVNkxe5khUnHgJ7lvsQsFN7ryQFAWmcTw4BvDKEtOhLkgdrz35ZsrBflL9bqinwahDD2kuHQOmDs1pZ0wHKKd8ndAQpsAHbgrtnjDwyzSoyNUBhtwY/9Znem574E3tBMg/Fux/gNRIOSDgg4QD7Cy1/3aMDZtduwjEHwLSJG47Tqhs7qw6Y1z3ASyivgGqUV0A1yiugGuUVUI3yCqhGeQVUY+wJvmm6qgP9zbX6qqf1CEjjfHIF2d54RQdiX3O6Vz5eA3n7AdiObPiccIKsi8S7GLIaX8A7/ydjABwbAm6S3TEAIjniALtiDGZDruwBsyqrY4iNH+DFGIh2gdlDDfYgRXao4ul3ABtroELzfggkHKC6AqqVcIDqCvACI3ixADjGiDxBtiWQwuo0YjW+wPPTIBtfACJjDGTxBaqVrLoC/tY6Iw3/fAGd2qxjw79mkLEAfvghO/+XCXcFWXwA2W3IlZ3V+AL2bJ93/i/C8hCwuh/gRHyB6OwfK64YAIH+AUWxpRxku8ezAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + // Builders & Biomes: Farmer's Market skin pack + // - https://www.minecraft.net/content/dam/games/minecraft/software/farmers-market-skin-files.zip + // - https://minecraft.wiki/w/Builders_%26_Biomes_(skin_pack) + Skin { + texture_key: Arc::from("2007b66a99ae905c81f339e2a0a4bf4b99e9454a485d5164e3e1051c3036ad70"), + name: Some(Arc::from("Barn Builder")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAADsMAAA7DAcdvqGQAAANDSURBVHja7Zq9ihRBEMd7j8N8BRODFYUVUcRDcNFAMFoTA8HASL1IE4NNxMzQFxDEZzARcx/A/B7ATDC4yEAQpZ1/s6U1ZX/3zO7MXi/82Zn+mOn6TXX3TFer7x9u6RvqncZvNpspi3ShlE/NbbcqdfT2qr4weaKfv/cDUEoZ7RyAjy9P6/39S3pv76n+day0D4DPUFyHazQAPj+6aRo5mdzVX49OIIBQA2O7wNgB6MVikT0GSOMFhOEDWBsW9IAU4xmE3ekCuwogajrLnAKDGg2AvjQaAHhhqgA8+ZhFjr99Mu8SkH71Y9wAYDCEgcy0shkDKA3H+CGP0mS90XsAGYI3RYjOXek7C4AL3wyQLS93jBgVgJBGCeDh5bMtnT/8orlkg6fTaat8aP2AXoiuv/ht/ufzeUjq/sGBUxcfXNMQXV/Wh1Gre+e2D0C+EVYAFcBAAaBhJBjMz21p/NwGCEKeHCCRRo2XQp5rkHTVCQ2uVC4agM14HwRKs92Yl8UbIh37ALiMM/nPbhvFAuDlkjwgR7anzw3H/2q1+ldnbUxLlvS/17OU90Hg+TjOAsCfcFAWY+Q40rpeJABn2UT17gExXSa1i/G0ZkAzSjGa6kBBAIdvfuoSdQHAp9ePr7QMihXqQUEANH2gr5KWy6URT3NNW76n+p/7Z4gMcg2gNiV5QMS87JWv8VgXQLClpDvxAS1WSbNAqQeAMp4wp87PASFURp7zY1qOz1XvHuAa8SFaHfKVCam0fckAbB6Q2wW6ELWJ/3cKAG4iAUhRns3FupgyQx5gAyDbnQ3A9+HR3NDIlY+PocaIIjVGBkXtIOG+9JMfc1IVQAmAkEI334SKl8QqgAqgAqgAKoAKoAKoACqACqACqAAqgEahCgg8+gCkBlel5AqvLXhaAVQAJxjAnTOnugWQWkFuPijdX5AS8XFoGABy9xe44v+t/QP+/M0CkCpe+/eEtkKhr/Xx8AAU7S9I1GC6QJceEIr5iz0D/QCAoVI2AKX7C3Ji/mLPwOYAcBCk0uhyTsyf7xnoDUCsivcXZMT8+SA4GAC5HlAa/1dKjdsDSjVIACn7C0IK7Q3YOgBbnD5lf4GrW8QCwMdRSfv/AAtFWPNdllX9AAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("59f2872323bf515aa8d84c00931fbf8170b2cec5138961527c09ffcd06ca4ab2"), + name: Some(Arc::from("Bee-Friender")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFaUlEQVR4Xu2aX2wURRzHp82Vu0LLWWpr4YTGouGFVCQ0sSRNjNVoxBjFF7HxQfkTUB4QKSEQHwkmVeRF0lTFp2qMgkaNwWgNkQT6gKIEDSSgaetVaLm2V6h315Ke+e3mtzczNztze7vu7R47L7e38/f3me9v/uxMBVGE51fem5Ul+TMxKS3h/NRMhaqOUsYrG3fHA0gPrJQqYMMLN6QdODCeVEL2tQKmpm+WN4DspTapAsa+jksB3NM96m8FhEKh7NzFh4RGVq0+T+KHGgMAMgK+V0DZuwBOc0e3z5NXeytJf1/Y6NCaJ4a053Xf/kXOrtjIdHT78Aly7qn7tHe3vms24rq2ZQiWBS+9PkhWFAIADOHHAfB/DCoAE3O3hV6ypCpESj1NugYAjKUDQPE8AL7bDm6d1l4deH+x6bgncgE0FpUAhvsGQPKXFGncEydjb8dI9Nl3NMOTX76Re7e2moFRngA6vieZ+guMoeFEKxk7/TiJWgDgSxcABWDPX+s9pdnQtP0RQwmFAhD5jC/GABoAbwS4QiEAZAulks8CfOP47S+/31ft799saZDuHXbuqLK1dN6wtI4p//hPR0l72xajTFX7+MrzNio8gH+mkkyeM4mb0s2NVQCJ8Xmt/PqGSu1XtXTmAYymZpn2OQ7gjleA1wHYVsD1nmWMT4Ek44mUIaveU7XGM7jDB5sXGXKFCNg/8CFzW7z0DYfY1SDms5peOohwkcev/i112QoRAPRHgIHPWC7/zgkAPAgzUFYMx7RKAJuW6aPqkdf1FR1tIP383FviT19L74qatgt7ljbo3DXxN8R1TXcz5WA6/r0KAuSj8ygB7GlpzMbTc6blDmVycu6MRsjv/86RcGVOVWeSOXehCzEzCNJc/irG1LfqGfFnNUhnFqcCgfFDM2m5CwAAurBqkiUNy2u0V+Mjt/Lq4QGgcjAhLIxkBpk1vL9Hh9DVzcIp1FCzdJHOq9YBXEnrvX5/JH/QKgQANsaKUeH2QXJs43It6ysnRkjm7MN2bdfyFwXArgKgYqvGg8EA4YcbdWR4W41jEFwHAMZ/MzDByBkMAwNhX8GHxtd+M3qbBoBKOPTo2rw8uw8uKEgdhw/Mkv2DI+66ABr/dOcSrZFoIBg39t6DQgDwEuIgLSig9dMV2jOEkgCw4gKPtaYIGssbj9aCMWg8v3ukwdAAEJ4IAMSpVAC9D0GpgPX1tdossD6qrwNks8BAMp3Xg5vb2Cm0mFEcIEDAgY/+j4YUpHlBooIBFFvBF/tqDZ8HJVy4NKMV1fEiu7A5/bH8ENWs/o59vaTryW7Sf7JH+9UG2JM95PBLe4VZfh5nd+PKhVCxhpdLPk8fXLoBOQDgBmUv1xEowMu940bbAgW4QdnLdQQK8HLvuNG2QAFuUPZyHYECvNw7brQtUIAblL1ch+MK+GNvE/NFoquPPVFSHV83L4pkVy/O3Tu6OJ37kHr5149I5IFNjrbZ0cKgp3kAWz7UvxBhUN0vkAGAMlQnPVbV5jgAuzdMfK+A/xOAJxXAH6/jR1FobKy+Ou94nZYonD4f+1y/IoPhs+vTJBbJ3SOqW8Aez03O5g5r4VDXrkvYdgGr9wvKDgCeLnfv0HvK7H7Brnf10Zw+bof/eB7hWwXwx+uyuwYgbTo+M58lLQvZcz7fuYDqfkGKsF6mAnBkeILEwuK7RKIpruRjgAgAfb/AKgBeQfSACAD4+E9GJ22NY7YyQ4OcVkAAgLqvBAOmCEgz5SKeVIBTLsDPGDgGeB4Afb+g2DHAzHgeQskVoLpfgADMrtNZ3bzw6VWbK1X5tgdBBKCqyEr8j4N9ZNWal40ssA2G//hLl2V3GvwPdSK8bg7in/sAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("7cd85127cbc710a1c9a53c6bb3474f59995c222b9d8c57b293993cc2d8a225aa"), + name: Some(Arc::from("Bee-Friender (Alternate)")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFW0lEQVR4Xu2bW2gcVRjHv41Js2k2jbnaZk2raVGEENFaaAtCIYKgIrQqXvLUC8WoDwqN+tJnlQrti1qK1qcqpaUFFUFwQRBbwdZiSbWKtiZhe0k2l93msuvajHwzfLPnnJ05Z2dn7O5J57xkds6ZM+f7nf93LnO+RECRnll7tyErcmlyWlrDuZm5iOodlcxXNu62B5BNrJUq4MkXUtIOTEyklZC1VsBM5sbSBmBc3CBVwPiXSSmAu4au6K2A2tpaIz/8kKORdb3nIPlOZwhARkB7BSx5F6Bp7sOXF+GVgzVw5FC93aGxx0fM60e+vgynV2/jOnrT6Ak488S95r3Zb9bYeQO7c0B14c1qHyQjpQBAQ8RxAP2fkgrAVP5fRy9prauFSk+TtwwAGssmhFL1AMRuO/qe1ZPPv8Ubw5ZzcgEylpSAhmsDIP3zAnTuScL4+3H46HiNaevgs4v2veaHGzhOSxIAGS4qAkF4AaClC6ACCMDYH3OmDd33NdpKKBWA0yioxRjAAihXAbKFUsVnAbFx4vZX3O+r9vd7ezqke4fXBut8LZ3Xd7Rw9Q/2tcD+n67ZdV7IzHvaexQVFgFcnUlzDT41eUP6Aq8AJicWzfrbOqwBVrV0FgFkczmufYEDuO0VUO0AfCvg+r4uzqdQksnJBVtWB79rsq/RHT7e2WjLFTNw/8CmvzPWTOGW7llhzSCUVOWllQGAqr6zE9NSl404ASB/RBh0TQ0R72kP4MUua1Q98Ia1omMNZK+3vuv86WvVnc2eejQ1X1AX+2D7cn5FSeXE+6IiRAWcuZYC9hmlAvb0dBrJbN5VaSO5wk6uvzkKF+bzUF9TUNWptDeD8EW/fxHn3nf/086f1bCcW57KNSh/ZC4rdwEEwFbWAAZ0dMfMWxNjs0XvEQGQcqggrgxlBrk1/Mg+C8LAEA+nVEPdykX7//IO4M+s1evrosW7vlIAUGO8GFW/6Uc4vK3bfHTHiTHInd7o13bz+bIA+FUAvtir8WgwQvg21QKju2OBQbjlAND4rxJTnJzRMDQQ9xVi6nz1F7u3WQCkhN6Vhc9t9OzZk6tKUsf6rVdBtTKMOI0BflyAjH+qv9VsJBmIxo1/8KAjALyJeVgWFdB3dLV5jakiALy4wGN9C0DGisaTtWgMGS9un1kwLACC5wQA81QqwN7HpFTA5rYmcxbY3GzNw7JZIJHOFvXgzg38FFrOKI4QMNHAx/4mQ0rSvEMhNwCGYWyBSAQiBKDcF5x8u8n2eVTC+YvWUvjRl9q5Kr//TH6I6vb+H4ab4NPhFGzvbTf/YsLrY79Z44wquQIA2ILzo6e9s+plOuaHAHTstSDbHCogSJo61hUqQMdeC7LNoQKCpKljXaECdOy1INscKiBImjrWFSpAx14Lss2hAoKkiXX9+uZK7pxh4BB/oqSKL1jTGDV6VxROiYYzhQ+pezd2wa7EpUA7LdDKnADs+oQ/LFXFF8gAYP2qkx6vHRo4AL8RJtor4P8EUJUKEI/X6aMoNjbe1lB0vM5KFE+fDx+3QmQoHbuegXi0EEfUsow/npv+p3BYi4e6fl3Ctwt4jS9YcgDoZGlo0Oopt/iC1/dbozl73I6/6TxCWwWIR2uyWAOUNpufWzSgZ/kyvV1AFV+wIBw9qAAcGJ2CeL17MLY4zVV8DFAdrnoFICqIHRDReDH/8yvyICjVusD3IBi0AkIATLzS+dkczN7kp8nYHTXQFyv8W09VKoCNL/DjAgjAKVU9ADa+oFwAbsYTEIJQcQWo4gsIgFs4XTp/UzVOSfNVARCqyn0Pgn7jC9wAPPdAKxcPIMYIkGF+p8H/AOqQyO1eub4oAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("5e4e09eccbce11e701c51bb64b102d688a6ac4018c725dd2b780210aee101b31"), + name: Some(Arc::from("Buff Butcher")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAABMxJREFUeJztml9sFEUcx7939q6a1q3a657HtUQCwSiXVDAQSlpj0sQIPpAQaNBGIcQE478EGtIHlcaKD0TltLUxfdAgjcYUUp+M5aFBS2NMSmgvAYxaNLZ3SJcWuYPG9IrUB7p7O7Oz/27nbk/Zz9PM7Pz5zW9/Mzs7v58PJmzfuGLR6PmeDacM22/peshnNoab+N0WwG3KnHYwONnCQw7XuOMtwFOA2wK4jacAtwVwG5/Zd77QnPjxd1fPCUW1gLpICHWRUDGHNKUss1AcHdTeF1TSQsXdSF7LFmVcM3w/H81aWgKJ0U2oX/9D3gMd7qtH+/MJTfrh3cHSWwKJ0U1EfvWuALoGf9WUG7X5r6CxAPVE5DfOmpzaGlhtrOK2BRD/AvRE6fxg/yUAwNMty5jP5TInS6XYKBbA24StKsFtC9AMvvnlPmJJXD/5KvF85GLaUOANz368KF7ohPToQaJcLlsbW2koUO8bmw373/vut4R8W2JZfPJOm5IfPDNhS6GOf4f12Pj3MSL/W4HGEUURQvBW3u01Cghf6CTy1/Pumo3w/Utc+5MkCZls/meZgllAsXBqAb71O95fBIAn092GFb+rek1JR+aH8Wf5EwCAUE2YqLc8GkKqfxdeaFxOlB8bmUS05XNMpmY09QFgf7SZKD+SGrI0gbPj54n8usfWEHmzPcUfqglrJmFEZH4Y1ZUCYoFxROaHdetlnznKTJcatpfAQu0O4NrJXFqH02NTaFqa+OmxKdN+j6SGcP6znQCANXu+Usqb1tbZko+u32tSX1HAuVWHmBVmrkxrymZvZAwnL2Nl4mo+fesKANL8g9/sNmxTniI3wOAle/tBXpsga/KxiTdvJyYAIXwPQO32cllsKa+n8GJTUl8BqxsfTwqigG1v59bwQMdO1IduIjFTUrpWKKs4e9Cwwj/UhUnFFLnG5taRBycAuNi9vWQnTMO8E2yLfwkA+GDfc6BvjIQAqYAXm6KOhQhXVxH56dm0kv7w1GVHfZv9GzCvxCRJAgDN5FllQwntTk+bfOMrXQCAkZ7XmUI01wNV294DAKQHDhB9CsG7jORnHoPtnAyZdtrTud9yB/JurnwFdBBFkdkOAJrRi/TAAeaze8c7dPt08g8gUyYEbuGn5F94pPZ+9iBLbzw1PQsAmnpzjDZ21/9Hc3t1n7W2xyGKIiRJUpQYb2sFcPtN00pQv/3W9jj6t261JYuHh4eHh4eHh4eHh4eHh8f/H+7BCU7jC2j//x9f53ySHd19aGho4CpzyV/dqm945LtKnnBXAO/4AvWVF32vyIM73gIcr6d84guMoP37Zpj5/80omgXoxSCwvM/FxPUlQL9xu/EAZv5/M7gpQDbxUE3Y1EmipnyUdHzY9e87pSAWcG7VIQSSxy0FUfySvAoAWF37gGG9E2NXuchGw10BgeRxVFcKQKWAWYtKAHKKuCyZW0BrexwA8MXhffkLugR3BViNIaKRLaAxylaA2gJ4TFzG8WfwqcdXEkdX2n1uxrIHa4i8ngL0aOk5U3qfQXV8gRmy6efyhZBIH+4KyCz4DeMLaPzU+7Pj2+fhHue+BOzi92lFsKqETNZvOzqcxrEFyHED0XA1AP09QC8G4cZNMgKE9u8DMIwPcMq/veWc6whwjEkAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("d66ed86ce96a1b63c30f1baac762f638717930866474ac4fce697cdbd0bd6fbb"), + name: Some(Arc::from("Buff Butcher (Alternate)")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAABKJJREFUeJztm11oHFUUx/8bNwsSbLJ2Zjak1iCJNKQLSQpVUxMIRCoJiFrSUqXix0vEqA9ixH5gsSSpEKmYWjA+aDUIZZFVEJS0CBErCkVJIQ1+NIg2hWSyNVlKHlyV+BBmMvfOnXt3dr629v6e7pl75n6cOWfm7r1nYxAwuLt+jVf/eM293PvbMpmYqI8oqYh6AFET99rAbV2CJjJeewiWm94DpAGiHkDUSANEPYCoiYm+80Ezevb3SNcJoXpAU30KTfWpMLsUEl8qJELpqEm5xSxv3nQrfsr9G0q/IuIndu0tSjHZ14zlT2ZL7ui9X7J4VH1gvbychdHv6amRktv0A2YIJPuaSXl4GLveqLBd591zoxD789Ah4iVonYjxxFmTs3oD655iuX1kJNKXILGQtz15Sl79OQcAqNqmMOuNa15CJWzMECjGhau2KdzJu2mrXLC5X89zE0RIXJ98nqg/P5fnuuw9j72zps0eg978GrO+Ld3AHdD44R5u+/3DXxLj600X8OlbL5vy6alfXYWU55/DTjwRO0XIE2sDgfSjaRrURKHk+20GSM0eI+TrJTfNZtPXz/ranq7r8LKWCcwDwsKrB8R27n1zDQC68ie5ilPVL5jldOU0Zv5uBQAoKrm0vXOLgquZJ/H5ByeI6w89/RK27PsQf1zN2fQB4PjqN8T1g1WdRU3gx+lLhLyjdTshi94pFYqask2CR7pyGnVJBbu1eaQrpx31Pl5oZZbLDdchsFDzIOrwg1nm4WbiB6s6cen9/QCA7c+cMa93tm11NT5af1ygH6M/ezS5pUVCVtQUalcmHSefvnyE2+FM4xAhGyEAbISB1f3f7bnGbW/u+y8IueG+XkKO7TjADwFu6w6InvyNRFl9BYp98flJIAbY8/pGDGeP7keL8g8u5srK1iZxUczSiww1T35z6ZgGgLmTfWU7YRrmnuBdd9QCAH6bX7AbgFp0dLe4e0uzSG2uJuTFa3mzfObCIq3uCtFvA/aW2PwCAPvTZ1376uIVmw7t8h0DYwCA86deZA6iuwWo3jMKAMhnB4k2VcEqlzVGNytDpp+6WVsbISAKJU3TmPcBQDfGkc8OMut47fqxnxlXEwV8N7eC9oYabieXr+gAYNNjOajb+H97td+xrmNgDJqmQdd104jZo+sLJjVR4IZox8AYRs8+7GosEolEIpFIJBKJRCKRSP7/+J6e4jW/gD7//+vcxjZa//GP0N7e7uuYy37r1rrDo+u67+37bgC/8wusW170vqIf3PQe4DmeSskv4EGf74sQnf+LCM0DnHIQ6NPnsIk8BOgn7jYfQHT+L8I3Axgurqgp4SGJlcHkBCE31PY6aLI54ErbTiAeMNM4xE2isDKUuQAAOLJvJ1fvlVcP+zI2Gt8NULsyibqkAiQVYLk4IwAbhlA/+1aoKzprdIPvBnCTQ2TF8AA6xcXA6gF+TNzA82fwqa67iaWr25y9/kfuJ2QnAzghygESEcg7wJpfIMJwfRNaDhjfDbBUSHDzC0S48SA/jscD+dPUUiFR8uDC+g+TgWcPMPIGGreu/1BxeoJOOQii830A3PwAr/wHFAVwFsjUTFkAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b9e9d1b51b4be289b9525d4decd798cb7912e920bac8846a2df70e9ff4f0b1d8"), + name: Some(Arc::from("Homestead Healer")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAABQ5JREFUeJztm19oHEUcx7/XXh560Z6YtGfrNQ9J8R5aasxhoHkQ8iBVqQVBoUTtS44qtokh+BBta4Kx0YcYTdN7kRPB/uFAsViCpfShUrCF1CshGMrRViEc1NqesCG3eUg0PlxmszP7Z2Z39u42sB84svObndmZ3/zm99udmYTAYeWvmRV9WlVKiETrDen52VvYvKvNUD701J4Q7xm1JOxlZfOzt6i0mUL8BlcBqlLiylSlhHA8QcmWC3nJplUHrgL05g6YT4G+VJeh3OjQoAfNqzwbZCsw6/x6wpMpsJ6RngKWFTM+wa9UzALWy9QIkTivH1lyre/o2LFeqmD/yVPaNbmXLc9iJq/1e4KQExw71ou5B4+oH6sQO1SlBFUpYbmQ1679AlcBpKNNsUbqp88TIRKtx+ZdbUL+o5pYKsBslMjoi9y7XrB0gn2pLnyVOY/jp7/RZJ8e7QYASkZehEaHBqGi7PmXC3nt2iztJygFsCNJ5v7X1/7A1oFOEIPf+mUv/v78Kg6/0KxNB33YY0NgOJ4wvD36hVApf0P72utLdWkdMiP/+00AQGL385S8/+Qp2wjAysiXo6qUUJ/YW9MoEHYSr9mOy+AXKzBoX7kUob7/3zz3LpU/eXbMdsTaX/tuJZvsAQA0JJcAAMVcHQDgYG4CJM+K5uMKt359+tWNE1T+iR9uOrIoofUA0un9b/Wv8O4lfHb5SeDyWvrDff84aVfVkP4adMKVo/9V83FCCFmAk5G3omHvuC8/kELsnDejmKtDOrOII6lN2rwmtA8fNtwfjbXh2UdrCyKjQ4Pa8hjr/DoH7uDu9EXT5+5sPcDtgPKAXoaLxuhluKkLh2x9gqM1wXRmER8nxYqQcGr25rjjpS+0652tB7BlWxwP7xeoe7Zsiztpmmtse0O8NytjrcCM3u5Zqg79yLOdY0fNSlYJuE4wnVlEOrMo9RDyJmj2IUTMPxpro376PBI6rf6y8PKptvFuaHrMaAVec3f6osEq9FPiYG7C5i/tA4z5h2yfHTYzcx4iZfT36D+G2OVyMv9ZH0DyrEaRdPLSK98y8rL1kHLNF+zbaRoFiNcHjBYwt1Ce/yQiTI6olpXPXHsb2dtnMPIO0PX9DADg/Bt77FvEsP+jiEHGDgD7xqmXR19W3UUBK9N3MiWyt88I3+sEKycs4pxZXE0BQjFXh0/+7TPIH94v4LfuK7j646pg39q2WU/hdUch7peRn1y3r8yftrlhtx4+nSlrO/qceQhrSP5MydpPTAMoz2snIa5p46+u2rcGRwHzO542PjTWCLWlA5F7101fZNxQrbjulHBjh/F1k7g1taUDjS32FWQf58daQCwmm3Fu+hlX5QICAgICAgICAgICAgICAgKs8Px0hhfnC/Rp2f1/HlXZHp88OxbidbxWVPV8gAjVPkPg6X+MWOHF+YJKIW2WVjtLBHbZ/Uhqk3bdkFyizhdMDbcCAF48LW6YvP1/HhWzAKv9BiLXK4JqUDwB4E6lmmV8nmwFsjtLtUZaAe53llbLbTfPzyZ7tGN1dn9lkfYB47vrKR8wt7AkvIE6t7CEye3vaemp4VaE4wl0DohPAd/6AFHo/f3x1fMDG7g7SV6MPuADBei3tIs33gcAZJPGPD3FXJ3wAQgeNVeAHtH9fTfnAKyouQI+GIlJ1mC//c1DWgHkyAxPZoXd8XwxaqwA2fMFT8g2QBJpBcieL8C967JNkOJ/v4ATN/UXM+MAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("83e283ab33558baa2cd0184d2e85f090c795a797bdbcb2cc47230c27f23fe9b1"), + name: Some(Arc::from("Pig Whisperer")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAACX0lEQVR42u2asUoEMRCG7wWuFqxVsLDxQOy0srMTrhNs7ER8gwML0QfQRhDsrMQXsBLEB7DwEa6xFgSjczKQG3ZnJpvd22R3Bn5udy+b2/kumSQ7GQwEG+9sOk6fT8esBrmbASAO743W+wkAHTcAfQMADqMoADjuPICPkzOHAqfxE48ldQpAFWU5zEHTRgemlxessByNDVoZgNQAUOc0APzz7ADcrWw50PT99f+TOPdXxIFRx8HgOwrArwv0vDZildQwByr7d78e7udU1gJofZKS6QLfL5OZygC8HRwWHlMAWE92MUACoG0B2QHAB0bhDC50FMD7aH2SkpsXYPBCjTeW5iTdf7W/6jjR+qmk+mkQBeg/k+FMtUy9DYAB6BkA6YEkUUCn28usQstLE6noidbN5MjFKHsAj7fnrkzgoHTNdwamwCDqMAfAB6EtS3+PK9MKAOnB61ItAGK7QGwMaVryVDjyX+D6n78a3L0eqvps2QpSs7KsFAO4oMT1VylIYfMEIQBUkgDQIX/M1gCQojoGRXA8ZhTIHoC2PK428XzhMSC0C5T9sN8F4Bxna9zD4hslAIDXpLxDkaC1qfMSsS1A258RAFcel9cAAK8VLaGxrrIlth9vGmkBVUYBreg9MQCSfN9gZmZmZmZmZmZmZmZmZmbWPYtNrtJ3hNltvTUABqBnABa9v4CqdQBN7S/gYPjftQ5Ak1Ln0u/avQJFr+s7CyBE2XeBxnN/TVuT+ws0GaYkAWizwZ0F4CdYiwKXAfCudTIGhHSBKvn/pGaGsS0gdHt9ctvtQ1sAVdsAfgGGgsd9iBy4ygAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("e1fc44f1d69fd2864df7b80618a38af4170d4800f2df4fbde81c17b74b2a818b"), + name: Some(Arc::from("Pig Whisperer (Alternate)")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAACWElEQVR42u2asUoDQRCG8wKpBTtBRQsbIyFlQLBLFxAVBB9AxAcQAilEH0AbQbCzsrC3FR/AIo+QNxAL10xgZG+4m9nL3Xq7m/nh5y65vc3Nl925vdtttQQd9TuG8/frCetW7FIACkABLDeAg90tg4aAcYv7yQP4en4yaAgat7g/Ob9knRSAPEcPoLe9ZvKMAUxvrlljuaJ67C6UZwUQIgA7OBcAuB8lgMf1rrE9/XzPBDcrYkA0cBAcy0CanUvre9vssI6mBdDkl0wLwAu9GnbnLgLwMTzN3acA7HqiBICmSZBrAWB6fjQA6IUXAZDuAkX1SA5uXECT2OHOSsbS+beDDcOZ1k8t1T/ZHxjbMNz+GbXnrmXorQAUwJIBkC5IMgV00VtlXba8NLCSjosA7kdnpoqTA/DyMP5zUdB2GTuY+TB6ZhowB8AG4VqW/h5XphEA0oXXZS8A7CBdAFTNIb4tAqj6L3B90P7cv2s79dmyfbxyDuCSEtdfpSSFzROMANBBAsCA7Hu2CwApq2NShMCDvAv8FwDX8vi0WQTAew7w1QVwtMb9Y/B0CVsAgN8d9/cypk+TecehtTk/bVZtAa5J0BUAPFpLALAuDgDaSwtY5C7g2mepqwAI8n2DSqVSqVQqlUqlUqlUqvRU9+RqdEtvFYACWDIAPtcX5L2Zhu/sMo0D8LW+wJ4y56bRgwTATbHTY9JagaJjuJ8kgDIOFoDL2gL47H3uz7d8ri+oZfa3CQCus8HJArDv4xSEAhAAJJEDynSBReb/g1ptXrUFJAmgzBqDpgH8AkujGNVuwmDKAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("25dc6421d47cad8e2bdf93f56fae9ab06fcfe218c8645c1775ae2e4563c065ad"), + name: Some(Arc::from("Ranch Ranger")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAMRWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNi4wLWMwMDIgNzkuMTY0MzYwLCAyMDIwLzAyLzEzLTAxOjA3OjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIiB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxNS41IChXaW5kb3dzKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTYtMDQtMjlUMTI6NTg6NTItMDc6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDIwLTA2LTAxVDA5OjQ0OjUxLTA3OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDIwLTA2LTAxVDA5OjQ0OjUxLTA3OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjI3OTc5OTlhLWMwZTUtNjE0MS1hODY1LWY3ZjY1YzY0OWZjMiIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjcwMjI5NWQ5LTk3MTgtNjE0MC05NWY5LTk5NDY4OWY2YzhkZiIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOmZiMDU3OTFjLWY0NzUtNzQ0ZS04Y2ViLTU5OGQxMTUzYjVjYSIgdGlmZjpPcmllbnRhdGlvbj0iMSIgdGlmZjpYUmVzb2x1dGlvbj0iNzIwMDAwLzEwMDAwIiB0aWZmOllSZXNvbHV0aW9uPSI3MjAwMDAvMTAwMDAiIHRpZmY6UmVzb2x1dGlvblVuaXQ9IjIiIGV4aWY6Q29sb3JTcGFjZT0iMSIgZXhpZjpQaXhlbFhEaW1lbnNpb249IjY0IiBleGlmOlBpeGVsWURpbWVuc2lvbj0iNjQiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmZiMDU3OTFjLWY0NzUtNzQ0ZS04Y2ViLTU5OGQxMTUzYjVjYSIgc3RFdnQ6d2hlbj0iMjAxNi0wNC0yOVQxMjo1ODo1Mi0wNzowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUuNSAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjhiNThhNzFkLTEzZGEtYzQ0NC1iMjY1LTNkYmM0MzZiM2EwOSIgc3RFdnQ6d2hlbj0iMjAyMC0wNS0xOFQxMzo0NTowNC0wNzowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDIxLjEgKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjb252ZXJ0ZWQiIHN0RXZ0OnBhcmFtZXRlcnM9ImZyb20gaW1hZ2UvcG5nIHRvIGFwcGxpY2F0aW9uL3ZuZC5hZG9iZS5waG90b3Nob3AiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImRlcml2ZWQiIHN0RXZ0OnBhcmFtZXRlcnM9ImNvbnZlcnRlZCBmcm9tIGltYWdlL3BuZyB0byBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1YjI0MjVkNC0yNmNiLThkNDgtODk3My0yYzdhNmZjOWRmYjkiIHN0RXZ0OndoZW49IjIwMjAtMDUtMThUMTM6NDU6MDQtMDc6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyMS4xIChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6YTY3MjBiMDUtMjc3ZS0yYzQ5LWFiODItNTQzOGUyYmFiNWIwIiBzdEV2dDp3aGVuPSIyMDIwLTA2LTAxVDA5OjQ0OjUxLTA3OjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjEuMSAoV2luZG93cykiIHN0RXZ0OmNoYW5nZWQ9Ii8iLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNvbnZlcnRlZCIgc3RFdnQ6cGFyYW1ldGVycz0iZnJvbSBhcHBsaWNhdGlvbi92bmQuYWRvYmUucGhvdG9zaG9wIHRvIGltYWdlL3BuZyIvPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iZGVyaXZlZCIgc3RFdnQ6cGFyYW1ldGVycz0iY29udmVydGVkIGZyb20gYXBwbGljYXRpb24vdm5kLmFkb2JlLnBob3Rvc2hvcCB0byBpbWFnZS9wbmciLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVk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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + // Striding Hero skin pack + // - https://www.minecraft.net/content/dam/games/minecraft/software/striding-hero-skinpack.zip + // - https://minecraft.wiki/w/Striding_Hero_(skin_pack) + Skin { + texture_key: Arc::from("721c05483a435d4362047ccb62e075ef5f001aa63a7e0e2afe03e60759bab91d"), + name: Some(Arc::from("Snowfeather")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAGhUExURQAAAPLR2+60xPDP2eOpueyywu/O2OCmtuuxweiuvuSquumvv+etvfXU3vPS3OqwwOvK1NqgsOGnt++1xfnY4iMjI+KouHRAcWU5Y6pdpp9ZnPHQ2vO5yeasvO7N1+zL1ejH0dacrNuhsffW4KV2ZvjX4fzb5e2zw96ktPva5O3M1v/g6tyisv7d5/bV3/TT3eWru8qTiv3c5t+ltT05OURBQd2js/rZ41VOVDs3O+nI0u7u7oVZxv3u//Xh9Pv7+6p84tOgjygkJsyalebFz/G3x9mfr/C2xoxKe5pZiRAQEBEREQ8PDzM3cICDtXt+r9Kjlbtqp8Z7tQUFBSAjURMTEwMDA3J1oA4ODhcXFxQUFA0NDUFDbWhrkf/e6P/f6T0LMGMcUWRnkzwqIzUlH1E5MFg+NAEBAQICAk9RegQEAwMDAgICAQUFBAEBAHNhVksxIUguHnRiV0ctHXVjWEwyInJgVUowIEkvH040JHZkWQYGBeXXie3elygoKD0cBxMLR4kAAEkkCylt23+u/xxeyWs7G4IAAHJCIsGgiQAAANjyOZsAAACLdFJOU////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wCFwxXvAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFx0lEQVRYR7VWj3/aRBSnKaXJJYClmHXFbFXkR+tkyejcbzfEOZg2KTiNP8gGY7Sz0LXW/XLq2s45NX+1791dAgTabvt8/HK53F3ufe+9F/LeC7kcE8JkeCoyPSVKRCZiWOHLR8IniEoxJR55ZyZBZgAkzJePhE8QmSURQpJEmnkXMKPyZR/Hjs3NwXWMT330NYgSyiEyAsKXfRw/Pj+PjU99+ARyhEhJJSVzDd7jy0fCJ9BSM+ET6smFMCN4cyfGUsr7Ex9MpLUPKUZ8cBB8ghPw/ibDaVFSUD7++hpkZgnJhnPxdHYqo04lMtORKInmhaykLi4pH52aBP9zx+FzNjo+8C5CH8skHzsRF1OiUpiV45l4WlSiefV0VNfSapokXXdunu5kz+lwfo7eKEKJ5GxeSqmTsmGc0QxDiWcVeKOCcvqUlpGyYswjkF33jOa61LQhgniS5FNFNVZcPmtMn102ThE9KuSJeFoTzyjxT8657vkLuLEI1zQOAGyFIZS7mCcXJXVy+dJlQ798aVkSdSIQEo6oyoJE8mn3/JVPvf06uw2sAEE4NUuWFLkQv3qtVLp21Tg3pU0QoheiipaVphbT7oVPr3zGN3MMrYQSk0SLRrRM3iiXSmVj9vPrJEVyeiFcUPTFREaA/QH54ZUQ+DqhpMOkIAFB6ZycVb6IKvmwIMRU7eSNac/sA0H/SJVKtXLzy69u0hWArguqJuekFQ6+7JqWZa0C+JSCElRr9crN0Nc+gawLgpqTcyu3KHwCkLdWDWMMQb1W/ebbkE9wXRcMI6cmRgjsAwjQhO++/+FHugIQdQOgjphgI0HQhGq9UqvUK/VqvVqDUc2yTLPQKJXLpYZkOY6lk4hSyCdFExyAl+VAM02Hy7shEAYxEGZENXhu3jYbpVLDEk3bcUxhKRa/c2fBdOB42hzbgQdcHghq7OwqksAYCCxLsRsNy4yYtm2bprIYv6MvWKg/HI8EtjVAwM6u1pqtZgt1oMeo9t2GKYPKNvIpi2kdlh3bpAS4w7a4PJqAJzdb7fa9drNVrVtUCAlgq4lWk5hU1DwCBxso1ifAk5utzlr73r12p9NsgTxstO420BSEFsvKRaBF76H+tHf6Jhir8Gahwxs2tNsygYC6HDYXk1oRVQd/Ajc8hA78yOWRAKTW7zMS6FFXJPiJSiNu4HsDGdQC5dEPAyZsdHsbm5sbvR7cNzYfbDIfgAUmHVEOELbhX4ltBf+d2HF5N7S1/fNOaPuX9Yfbjx5vPXm6A1I+ATrOgR5/hlEGDcvlVWigbZnLu6FHW48fPd15+Ov21s72k2fPnlDnowXUBaA7uhQWUR5NpO5aHSDoPuht9noPer91u5sb3W6X6rey8vsfqDGqyzsQRhVAtEwHXL6fWAbhxQM+PRRjCbx4wKeHYiwBxAPXhXjAp4diLIFI47f69iYUnmP/nBJ48QDH4+AR1Ghfp7172wWG5y4TovFgd4GOx4AR1DhBjVMo7t4elE107EI82NdfsPEoxhOoe3++3IN0yqDsp/VdPh6BR9Ci9xYnWNp7+dee9xLIvlR8dQQBE0fwkYwmQGYGaH9n5X/+pcNx8Jx4MPaT2iHynGD9Pr2Nwwv3xqtD5EfjAV/vY3eXvgH8xFgkoCGhHw94t05nb4GjfXAE/h+CYL2AwQnDEJ8OYSxBsF7A4IaBkE+HMJ4gUC9gNH8jgmC9gOnktU3AXI01A1w04bO6AL0AaXGgLvDACOr8I4I7E/Mp6qwuwNQwXBd4YAT9r5HWCSzZ469ao3UBZsNAXeCBEQx8jUxwDbJ1u7221myxtI4EmCv7OdHDiA+YIBDAoANgBJjuhusCD+MJOl690OmA6piwqScG6wIPIwTBeoHVBWj/cF3ggREMxINgvQBHY77F08f7IBgPgnMWB1gQoNGANe+6RTWAzo8HwXqBJWXoAnWBhxEfBOuFg+oCDyMEwXqBno7SgbqAwXX/A/VxkDAomWhdAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b914cf5106aaa82409fdd9213fbdb1479b4d65aecc5d5e22b1f25e5744c4c4f7"), + name: Some(Arc::from("Stray")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("5eb077c54ecfc7e760c36add887b68859d7a3160d331580ff859f7353d959151"), + name: Some(Arc::from("Strider")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAHmUExURQAAAFRnlFJlkltypFNmk1FkkTJBYDFAXy8+XT06STw5SDA/XiUwRzckGuFTAsc9AOG/jTYjGVpxo1lwolVolSQvRiMuRd9RAMk/At+9izkmHEpjky49XCYxSDUiGFxzpUhhkTs4RyItRLZb2FZpli08WztPdj1ReDxQdzlNdDpOdUlikldql95QAOC+jMU7ADglG+BSATdIakdgkDZHaTRFZ927id68ij5SeRgYGN1PAMg+Ae/v7zVGaDNEZjhJa+iY8X2s/X+u//Hx8QEBATonHRxeyUZfj0tklLdc2eeX8Do3Rk9YWzhMcxERERQUFBYWFmZ5oGd6oRMTExUVFQICAgMDA2h7ojNCYVhvoWR3nlduoEVejmV4n1BjkFlsl11wm1tumUxllTlKbDMotTIntCQceyMbeiggjCcfiyYeimdnZyoijlhrljQptlZWVjEmsyYefWZmZiUdiVVVVSUdfCEZeFdXVycfflRUVD9TelptmDAlsiIaeSsrKy4uLi0tLVNTUzAwMDUqt1JSUi8vLywsLAICAQQEAwMDAgUFBAEBAHNhVksxIUguHnRiV0ctHXVjWEwyInJgVUowIEkvH00zI040JHZkWQYGBRISElxvmmRkZCkhjSAYd1hYWHdlWgAAAAn2rnQAAACidFJOU///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////AEVMlGgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAcoSURBVFhH7Vb9W9vWFZbkq9iJzRqMbUKVEIXS62tpUFcSDe2q0K20pt06MIHQZg0JmyXZ3mabYoUwwuzSJf7ArmcH0q91q//THXnXIsF0rM+eZ9sPe3ls6V7OeX3Ovfe89zBdCpbzIMQj7pzXd/6C1x+g0y6GfvTCxaHh4WE6dOEScMERlgux3LlwOOL1h3102sXopbEX6etzcAmCQcRygoAuByLhSOSKl067GB0fp2/P4zgFxF4dEUP8tcsTE+GJsJ9OuxiIncIl4NngiMiy3IWJSCDi95+j0y5OjR/gEoQghBBiPf5AYCJwSgQv0edJHC8ix3JokvX4wl5vIOy9QKfPxPEaeIIjHjTp8fv8/pf9167R6TPB4CiJEQlLSJTRjxEnShhjgiXnQ+AvOjw1/cpUfHzoxfHhON0I5zhMjb06/UocjgWjYKwowKLKiJVFPkSIpmEizUSBlSgzpBuPj168NDo99erFsenX4kPg/cKl0dHp60NDQ70IFEyijgsb4lg+xIpEA0rgnMWaJMUIhtUbjsdfdxzj8aHX3vjJ9bE3rsfH4/1tZQgmMchAksU3Wf5NloXocUzXpZik3Ijqc7j7Fpzi4Z9S8+Hx+M/ir8cvxd1NYaKYkCjBGnqb4bj5txGsgBRTYpIkacpMNCa91X1nLN59l5oD3u2+FJ+KA+8/wGAl6iyXLqLEgoxQIjoXI1FIAiKZVTQsdbvxd96jxhTvd4eBk4KBiCVdk3SZjQQCYQ7FNIXADmgE6wqshwY2/R9zAVnRNyD4+S+wNgObxodhxT4II+fXYaxgTZMIUFG770XvIAXCgcgvF5cWezMAkYNTocqC//wFn9frdUs7uTwysrh08yYd9tAj8Pp8vpXVW+4/WCQHVVnlvFcmwpGJ8Mt0uns1uLa2srp6CsGH/suLt26t9GYAatAzIosj8vkrYRCH48L66PbtXy3On0LgB6zcXHIJhI+RnAgGPX6/9/yHvmsuwe07kMLKiRR0UZEEEQ6jRPBcIuGUBRYnOcQiVcRRXeFQiOfW10VBVe96kknx3r2FDTWR/DX17zK/icE2KiRlaDdg/XWCFYkgxIE6qByUgsSpKmJFPiFsmBuyaS5bonBvI2nJ1B+Oclonmexvf5eKkJRp4gzR9SgnqqIIlYF1qBGR42WZFX+fy5sJy0gaG5a5USisUf8uk9nEadP6JJvOhNNbkrkFB4vIsIsch3inqliVn1QFHhlFy142DNu6b22bDwr3qT8c5bRh7GQyGSOfy2YytoVnycwCEkIIhVgt+gddhBJnEzy7a6dsw7YtsC7YD8wC9e8y6dROPp/PGLliIZveMguKFtVRCIG8yeiDGRJDSFBhHSdtO58rmMWHhlkEouIe9e8yplH8Y7pUTn+6n8mallmYg2ONeJAWTuawEsP8ZEjgP0ahz8pm3i4WLWu7VPzsT6Ui9e8ylll+VJ5fTT16XMqa9ze3bkBpsizi2XWVh0VUkDNIyHypXLLMSqVo7Zcfm+XSDvXvMtVapVqvVysVeFbrB3XQgZgqhFiMxBCBg4CQmIAt5WuVSv2gWq05AOMa9e8yjebnLab55/l2s/OkcXjUIkQnwgJOECxwEijDOuwI5lS+9fSwXet0qu3Dw3a702lT/y7TaTzpHLXaXzQbrebhl18ekjldWpcWiDpLRA2kTVIXpKgkkmar/bQDrkftdvMpPKl/l6kdVOqVykHlq1qt7gQoKaCSWBJBpnqqgKKzLJyNRN3JE2zqtV4mVep/fLH8q1i6ubREX3v44QSrq/9lgpWlfzOFk/g/wf8Owfxq73EmKhX6cowBPaDzAwC70/WAfs33RmcAvNwq7OM/t4iQpyMIfTn453oQiETCi0tfu5foWtLjOakD/fGpBD5oVxdvHZet56M7d06WcX98KoFz3YOBG8FVuNd/IIHP//UzfUDyTjB4Ugf64wECbQbaVixBh0dic5pO4KLeDS4vq7viPVleSwjUzMUAgQ6XwYyOC4amQQOqx4RvzLuiZSUFYXcjuSm4l2ofAwRSVsOZ7F++fRghxp6NM3cf7piqmbu9sWntGoXk2QQZk2T37E/gwo9kMsRK5/L2XjKXM/cebNumYW5SMxcDBNHMw9w2dApG0Uhns6b5DXjlbPuvRvGhaZuFswm2nH6huJUydoz0t+m9lGnnDcgiZRZNw84fNxZ9DBCYqeLjTOlRNl9KZ21z00iVrJ0HOzt7O492Ctvl/PcQPKMH5l6pVGZWjfL+4yzEnIXuYXOzUvnb/n4JOontfM/oGV0Y0AN4OBMwrPdqv1Y9OGAcAagegBAwMP28LvQigC9XD1pHR+35Lzrzjc5hu3XUhJ6h2TjodGrfdTrfwafh2DyrCwNr0Gy1Op+3oN1oP2kePoFWotE+7DQa1L3h9gV9DBBUKl9VISEIrubE7NR+veqEDLn2sn1OF6rVvwMX7XggwzYzoAAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("b271a744ef479018927575952621b110b9c11f62730a95729af7e8591cf8dbf6"), + name: Some(Arc::from("Villager 1")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADDUExURQAAADlIXj9QaHyczI9vYIhkV4Gj02xNOyMjI0xma0NaXnSTv3CXnmmNk2yJsubw8pqgoKevr/L9/83X2DMkEVBTVDg6Ov///wDTFYdkVIpoX5BuXCQlJlg3LmN+o19/hFFHRWiLkaqnr2RgZnKZoHunr0Q8Op674mBlZoCttcHKzDZcEitMD2OoI4HbLkh9GG1xcnHAKFGJG7O7vL/IyUJUbUGdrTZEWcy/euXXier194Sm2C47TEhLTCYxPys3RwAAAMIErkMAAABBdFJOU/////////////////////////////////////////////////////////////////////////////////////8AMFXsxwAAAAlwSFlzAAALEwAACxMBAJqcGAAAA/JJREFUWEftlntX2zgQxROpZBcCUSxvabvZJQvIxDYJkAanLSmpv/+n2ntH8iMPDpTTPWf/4Dq2rMf8PDNyLHXKoK5SWqtu953XQWh+VjVA6Z7Wuqfe/SZ6DYD2BPwO7QIOD4+OcB6Gaq0aoHWfCP0U4Pj45IS/UK3V9qCP40kPnlILQA8Grwd0db+vB4Puc0k0G0UboBQC6CrMoCg078gMeR02AM3px2Eiq5SNjFRCI2+R/53EHbfmotMb9HoIPzbWRkNrTYTJxHTi2u8PBhqAoxMZaUw0NFZuT46kEHUQN4bBPqLjQEQaSDRpsa8A6Odw9OO6CdAcBsAfIgK02FOYmLJ8f1qW6Pfj0e9bKgFAxdZ++Hhw8PGDxAB7/1b1B7p8b81piX4/Hv2+pVID+PTnaDT6JACI7iMGAE6N/WsTIC2VGAKGMQd/QxFKaalVlqcYzX4OZ79vqdRR6uwM0x+bIRwYDU2ECuYQf2x0nCkVxqGfBfql2khepDH0DyQt0PnFJXRx7oJCc5lA2y9ZA7i6qgEXlxx2eeHORQ3AueRg62PzCwGbIdD+ZSEk43GSTFITxyn7kwxnbNPRKDVRljg81Dm2BWW8xyUL9gCQAECepxPHITRIbZrmqcCAuJ6yyDKXIQRAcXEtAOrjBB7AAOGieu6yDEDvEEyns9mUAHrCghYuCfZlx43x3MoDAkBxTgAYiHM6u7m5hQ94KqrA0HwDgBoByIEA6GF2Z1BFDSbXs5v57TVR9J4iqE4scgAAQgBgwjupJneWPKldz+bzz8wCUgdjZoFlsGcOJInWA+CPIFIBCGF6O5/fTuEaJwCZlDRmDWAycQ4R2Dy3SNuEEBjCg7sASKafEYE8WCaZnDZgcb9Y3BtMmzW4W9wziYlSRaGUjwEEZABi9pgflkmTA76BbRU0XX75+u3LUuEONWkpHh4erMXpS/sQ7AMA/4OqXC5Xy5X+/u3r99VqqZfQSvT4mMovlGmwLztXW1oXxRoqih84i3WorNdYLvA34uHLYN8sLK/VG+ANQL0BfgIgHwL/TeBN2C78b0PYXmz5RdxeVCs9DWgt9/yYbi/rlf5DwGtDSNI4NlikZN/gJXsCucFtGFZrBzBJ85yAYM9lyq+J3FVgXQ3Dau3xIIcH2DLAUtZ6rGNc2Pyi3KxIlfZ54EPgxiGs91hRw/bgBQB4ICFg3yIA7igkDpx0KgyrtceDGAA+TFLAzBHiF3aUYVitvQArSaQPASCrukCeDwFbi9wiBuwaIDgh1vSGk/gCD5q9wj23DgvuRhg8ncH1eQDfwLa2vwPb9f2A1n6htSfgtuAxDKu1AwjbhFrcD/CoyjAsqCz/BZr8DQ1gI8/rAAAAAElFTkSuQmCC" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("748923629fed7c6ec9462016b4480fa3cff8c16e82ee6fe26d4b707f4de10060"), + name: Some(Arc::from("Villager 2")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADYUExURQAAAGE3OWw9QNR4e9WyndGnltt8gbiUfmxsbGA4elIxasdwdIpRsoBOprhobPLl66CZnbCmrNjM0//x+TMkEVNNVjk3O////2YA0/Tr/+3e99Cok9GsntexmikkKaeAdqlfY3RfhFFHRYBokaqnr2RgZo1yoJp7r0Q8OtiE1u3m8tPN2MjBzJ+Atfry/zkQA2wsGXUyHnBtcl0iELe3t5mZmU0YBxYQBbizvMS/yWFVQ2VYRqFeU1JIOYuVx52o4PLq98mwjL6nhEY9MUtITEI5LToyKAAAAMtUDGQAAABIdFJOU///////////////////////////////////////////////////////////////////////////////////////////////AJzs8mAAAAAJcEhZcwAACxMAAAsTAQCanBgAAAPvSURBVFhH7ZZtd9o4EIWN1dBNSDAO2/Rt2YY6lgIEU5psS4C2IS3x//9He+9Ilo0Jp9mc9pz9kGtsWdLM45FkrAlyp0YYKhU2Gs+s9lzzT+UBoWoqpZrhs+eixwDoT8Af0DZgf//gAOe+q3p5gFItItQuwOHh0RF/rupVjaCFY2cEu1QBMIL24wEN1Wqpdrvxs0mMNooqIAwxgEaIFRS55i1FHV47JUBx+XHEx3EY4iIV18hbzP/WxB1W1iJotptNDP846nbjTrcbxVhMLCeurVa7rQA4OBLLKIo7UVdujw6kEAUYN8zgHzNwIGIFJJqU+BcA9NMc/bhuAhTNAPhTRIASfwoLk+cvTvIc/dYe/balEADUcbf78tXe3quXMgb427eq1Vb5i9dvTnL0W3v025ZCJeDtX71e760AIIaPMQBw8ub135sAaSnEIcCMc/AOilFKi1een8Ca/TRnv20pFITh6SmW/zjqIIBeJ4pRwRrij42O0zB0duhngX6plpIXqQ+9h6QFSs5S6CzRTq45N1D9JSsB5+cecJbSLD3TiagEaG32ah+bXwjYHAL9HzYEA2czSIeji9SYvjFjGI2ytNdD09hoPFRrtpXSJjFJ4vzzoN/XBoBsNEoHfQ0CHdIsTQGkozbj8YQFHRETh8HC+SMCVIxJswsANGBaJ3o8TocMyGryYTpNBCDOaKkB4IUIAODDDSYOhQCI1pMpj2nCDgHYKPy8BLoEYAh8CIP+OASPjzVwnkw/YBCo4gAHJjidPyJgaBgC5oBzCLH+MWNArF1eTS8nV/98gifDk0YG4/xlCFyFDKswAFsYmkA7hCS5vIL7ZzozfvTZ0/nnwWCgNVYRgAzTNiAExh4AxOWnzzYsmUDGTzn/PJhdz2bXQyxbNsTd7JqTaObzxWI+t150lhdBJg8Ve3H+ecA3sKoFXZdfvn77spzjDjVpWdzc3GQZTltmN87fAfA/KMrlcrVc3X7/9vX7arW8XUKr1e3tavXjRyo/V6bOPw/Oa1qsF+v13d1isb5br/Hwtbtiu8DfiIctnX+5sTxWT4AnAPUE+A+A6reApWv+vw6hvtnyi1jfVAvtBlS2e35H69t6od8IeOwQTDq6GGKTAgNudrPj1sCcgHfOzGsLMAAgA0C2PCQM1ZyAIGfmdR8ACQpt4WATBp8TPAiAIQCALbLY6wlwOQG3ZWfmtXMIAEgI1ZyAcTgzrx0RSLRUv5oTcIt3Zl73RHAhQ2DyJUFUcoIHAZjsZFhHZA3QZk5AOTOvLUCZK1wzdZhxEmUmeaB0Zl5bAL6BVdW/A/X6/YBKvlDNCXhxZl5bAJcmeFVzApbOzCnP/wXGOWDitX7aLAAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("3d996abc69ea70a20442855e429bf44b45111f9818d0f8c46272e12d12bec218"), + name: Some(Arc::from("Wither Skeleton")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAABOUExURQAAABsbGxUVFSkpKTQ0NB8fHzxBQUtNTVFTUw4ODjM0NDIzMzQ1NTIyMjAxMTEyMjU2Ni8wMC4vLxwcHCgoKEQSEzcFBl8tLlUjJAAAAGwkFmAAAAAadFJOU/////////////////////////////////8AFCIA2gAAAAlwSFlzAAALEwAACxMBAJqcGAAABNpJREFUWEfFl+liqzYQhUELclhMbhNs8v4v2u/MCLw3bfKjEyMkMXNm1ZLmq1LbhjbGlpdTnf6WLgAxpiQEH/53AIknnh8DIJxTjunnABH5X1oQW7z4TQwy8psF9fU93bjgaVASt2R8T002Ug4VATMixjojwMr3kgCAtSuHQycE65WuA8Tr4t8AZMoPGNONDRq7UaJc+V5S05rW0qWmSTxmgHzZqPK9pEZrILjDTjF6FF3+22A2sKaS0VtUyk1zKLkkSwWe8ap8L6kJb6FNAY9zI+IdUsvkRpXvJTVvoeu6g5KgbNA5HOTD20aV7yVZIcnc6+Lph3EaIN8gYqrTX2Li78aqpwBDyPqbUt+37XHYASTM7xHAxS8fZpMPU+ynFN/7vZiM6UqR6ClALw+macJ8rdBdwPWHWwArvNTGHHnoCioqLSExZIQtMWTKog08/FLLxrODNJKCldoTTpvG4zwizLIKeDCP058x5uE48F2QlmUW3gUgIccE2xEPfXRqCRS0YL8skBM0+q5dD+tA2kvcAFT5zCdMjWRw0qqOIAx/yYahS8TDvmvPUXhhrPJUImiF9ROKXPYY4CLlLOcsBqkjBinYEpMObLsCgLF0CkwXC0HO/XGahjzmjyFPcz9QU0NMmMVCIYhoV4Zpq7yWM4qks7M8xGHopz6kT3x676exn+c54tcIn/D5YTKMVV4Aij/xyrEo7TMRP059/pynkffQH8c2g2MhxF8SpDjs1aktjRBqG4xd7mLbQ9M4f+SBFNKdpjm0PanVlsnPAGirPABmABjyreQ49e/z8D595gH1f47AHdswD71iYAAEUzBV3nckHpwLRQ+Lu5QYPsgVydFIB57iTEZUyBCBvwTRFuzlKUpe7FBEo+DiHRpxgK6gqAG5sS8wW0wLZKMfkAOcTv83wG9cOK9naD2vkHpG/u2qv009UrMu4lokf4dQu+p7+5SaBdnzuqwaOIIajdTXwCc3cGNmIA5RcxLCejKAha+3AGK1X22k7LyAcQH4Oknwy4K4A7g9AsBBnzJZCF49e9BrFqzPLAKmVCPXZj+bs+ECs33dyAC2KjjpK/hnGyOwyF819aUPMBlzJQfADSd9hdn6ZquMUgcdK4qM8RHgQie3wvpAScJl5Z5935qN7gAMonq0eM+M8tczegAQuQq1PGaUTTylpwAPdGP0LRnA/WKy84CnDv1QZeeqwxtygLvl/ABg8pfxNT0FcPHLPYCRfv8AcOfCA4Ab8BzAVrFq3ZuzHU92XLHV0+hGwJ6vl3bVfTvfyAC2PYFm1SmhE1s7su4LvHTo++vqWN/IXFCVg6KVt+qk1zGmS4ceDSVosxxt1VCxSuzsACeNlpOWjx2AnHMS5gzEH10K/FjkSEhSBQiiLC1wPIjYoGoRQJsLF4JSpI/jiuuBjk3ONQEQINRChqI1sxiA8mAtG0WJHZeFtiuKpAXf7gN2KmJZ1oYgC1b2JKndAKxYgTnF3NndRHFUPpQAcHR/0hUgw6gtzdUjUwHqjkCrw5Gj0HOgayKy/CyEOinFhTrb1K4AKgHQyWW7MijzHWI6XAHQucoH53IAdW4BIHMZaZnLba8CKAbq7VcbnPXOA0ChbHHayl99lbBa/onAn/axEut7p+vrQlEU7NaKMbo6hnS3Hr6+/gadlgnNKz1F1wAAAABJRU5ErkJggg==" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + // The Garden Awakens skin pack + // - https://www.minecraft.net/content/dam/minecraftnet/games/minecraft/software/TheGardenAwakens_JavaSkin_CreakingSkin_B.zip + // - https://minecraft.wiki/w/The_Garden_Awakens_(skin_pack) + Skin { + texture_key: Arc::from("6f8fc677cdcd4c6eed67d90c08d23162abc3a3a85357c7636fdf80d874aa857f"), + name: Some(Arc::from("Pale Lumberjack")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAACvlJREFUeJztW21sU9cZfpw4yb2xQhMnmePrxnb4ipuo0CaYsoyEdk0QpKDBBCqa4m0Urer4gbRJq6UJJjT6Y9b+MZVOSGWC0K4SbEKMJRlNI0qkdDSFwdamAZaSj8YmNTYQZK5NEu5+XJ+Tc6+vr5M4qaHdI0W+7znnfrzvec97zvsRA1JgRcPTEgDcvnUHAFBUUgiWvrCjCbVvtePizzei9q12eh+hA4GAIdU7MomUHzcTAYwF/Jr3WqwChN8ceaQFYEw1wF5lly/6tOnHHVmZ/oBMI6UGDPcNA5hWeTLzlH7M8X8NSDXgm24DUgpgJrBYhfl4TEZgKK8ql4DE7Y3QBMm2wZUNKwBM2wqiIYQmULcT+swf/57RbXJeNCAdbHrtJWm4bxj2Kjsy8WucvD0JAAjevqX4MEK3bVkJAGg5N4HjzzsBAM2nrtD25lOXlPddVz4nWTuha39Qk4b40ofBarVKAGAufAIAcPx5JyxWgR5r27asRMu5QYTv3GWEMU03n7oCQD76jgX8+PX1Bxge9SN85+6MPiDTR+Usc+ETMBc+gfCdu7DbBFisArIdLgDA5d/9AhargE/PfwDfs7mwWAUFTcYAQLbDBYtVoMwTger9PlP91NfKrBay7DaBztbw6PSZnjC2qKYeAOA52oNsh4sKx3O0B8teelkxlvUJ7DZB8xcAZfzyZ5/PP0ezhJEwTdR5LOCHBaCMRkJBRNreUdz0lYoGgKmhfgCgS6Pl3CCAaaGSX7tNwOXPPqeakGlQG9C2ZaXmfk4EAQD+f3YBAIQ136dthHEWYwE/tQ1keRGo6UzbgISXv7jZI7F03yediv5UH0zc545GN10SFquADZ29M/qgf5//j+7zybmF4MKOJkX/bN3vb70v8K0XwIKeBB8HH8Gw6eXX6JoKh0LgeQ6iGAXPc3SQKEYBgLaxNG8LA0g84x8WLNSATg31UxuQzCdIRqcbdxjpG9G1CVni/Yiigc83USFoQSh34mRDQcoXs0bvVf/YTL41I1AsAXNxMQCg1Z2DbeejVBNYgfhHBuGFE0A/hHInrnx6GYB2xGiV7w8J3mNCRCkVDaUHWlRSOK/RqCw+3wRAVn/xfgThUAjemzY6y0K5E4Cs7q3uHACyEHieg39kcN4+RA9qV1ztqpO2opJCXNjRpNmfDMZwKISTDQXw3nQpGPPCCZ4fhH9kEKIYjY+xARgEIAvGPzKYMmKULt3R6MaGzl7Yq+w4LFgAxJcUE5E6Wm3DPikbBwxTyHa4YK+a7h/BiK4Asnieg/emLWE2b4eC4PNNVPXZMUK5EwPX+qlGJMPWnVt1+2eCbIcLHY1uBQ2Ath2ttsFiFXDAMEV3ncOCBe/v8ijuSwZjqzsHnt5BAKAMeXonIN6PxG2BPNA/MkgZ95WNwjMi7wjJosYdjW489/s/pW0Dpob6saimHofRTWmyw3Q0TjNCmJ8a6ke2wwVTcSmU5l0bWYA8o+xstrpz6NpfstyFkw0FEMqd8JWNYslyeQZIvx5e+dVOKox0EAkFqVdKMDXUj7GAP+GP9I1f6tb0U9QwNv25B6IYRROzx/M8B2+FiLVtBoixGGrjg2shOzNr48tiLYCqDdVyp2oNy9tgL6WJJszWBowF/ECS1JsWkqXpksEIxA803LQAvBUifDd48HziDXRr5LjEThW27tyKixcupnUOSHWaVDM829Onkec4iFF5RsWozNz+vigO1sewp1uCGIuBz8ujNxDmS4vNAPQzR0fmwQYsNLIA5mjLycxVLl2MNwMVMBcVUubFWExxYzAURjAU1nxo2Vhi+1NTD5N+hPGL0YS20vGZmLD0sSDBiPWVNuns1VFDqra5YNWzz0gFpnzci9xHgSkfk4H/4meVPI4PTGLP6lxsar3xzY8HbFr3PayrlSPU3joTzMVZECcn8Eb3vVk/67EUgH/sKwiW7+Be5D7CoYfw9UTAG3Owtz61k6ZGxjNDs0WBKR8fXrxCr48PBMEbczAei+GN7liKuxPxSAhAz2a8sLZOuhe5j9JiM4KhMLj49stxHKLRKCZKHeA4DpOhMEqLzdi4FFIwfh0MhfHJvy7rxwNYoq5xu5RsYKbAMVsuCyIMTuM8QsYXmPJTPl8hgJ7OExkJUZ+9OmpYX2mjwmc1IhqNor2zy9De2WUoMOWjvbPLQJgm23B7Z5ehtNiMaDRK/9o7u+aXl9loB8uMXhuLmifNktb1QkOhAZ7d+yTP7n2aLydOEDt2Pj/k0pdhw/pKm7S+0iZd+jKsOXvHmh0LJxi9GWaZrWvcnlRIBHPRADJmJuMA4Iyn4uu3V6yQ9AQ2VwHUPGmW1OrPzvqxZoekpwWp+rWQBUzPsN4SAJRGUs9g3nvwYEZtLIjqk6WgN/ZYs0M646lQMKulEQu6ZPTw15ayhBdrtbFQ7wLkeo2zRCIMr3GWSGucJYrrY80OaX2ljf6qx6T61gXZ9r5bUSp9dCNoSNVGoHcQIsIQJyfAG+Wo1Z7VuTj4sbZGqftSOWAL4gsU5ObOqG0mECcnAMhOD4GvJ7mrrNenhUfiKHwrIiZt89aZ4l5eLsZjMSzKy6Oa0LLEiOMDk/SeliVGmItzEQ49hLlYntuzV/XfPe9LYL7qC+xVdry3aRvGL3Ur+lPl/1c0PC2xNQbEOIZDckDmx21DivsfCQ1IhkgoqKhQmQvIkiBao8YjFw+wV9lpEWPT262YGupX/KWCusLEW2cCb8zBntXaNuiR1gAAc9YAspR8PXJE+uDHso1Q24S0bcCLmz2Sbv1A/rT1JvUHZAzPcwjelbPLNB8Qh7rWuOf1XyISCmJqqB+v+sdof6qaoh955RPruz7tg9usl0Cyk6K6poAyqqo/YMckq0HQwo4zJ7Hrow91cwykgGpZ7WIJkJl/13fCQJhXF1gBs9CAusbtEjn+enbvkwau9aOn84SBaACbPvf0TlAGk2kB6bs2cA6AdnX6hR1NeO699wHI1WBs0QXJG9RvXgMgcYaX1S6Wrl/8QtFWXlUuqStGqAawzo3WLLNn/9ZDBwyEFsqdinIaNdT1BwRslcnpddWa92Y7XLQMjrUFbK6RnWEWauYB7XIZKgDW3289dMCgdoEBpWDINakfEMUovDdt8RqC6eRpOBTC4eoHyue7c+i40+uqYbEKOFptoztAR6NbUf9Hrjsa3ehodFPBqGsEtZDKIaIq3XrogIEcYj74W2uCpMgYck2WgP+3r0jbzk/H47UMISmmIO1LlrsozWalxwJ+mtvLdrjotkfS3eRQtKimHpFQEABg+cnrCd96xlMhzTRBkgXIM04YVzNPZp+MIdfskuB5jqq0KEbR6s4Bz3NYstwFUYzCVzaqYN5XNkrHsclNlnkWpuJSyjDbNzXUrznDhHl1fEA99lizQ1LEA1iQNqK6enECtpaArR/wlY1SwbS6cxTLgL3Wyu+zhx7CPOlT5/7ZWIBWrEDNNEunfQ4gxdYEfF4e9ldJ8N3gFfWG5Fq9VZL/QiGYbXr7h0dOY3eVCYf6IvQXAHa5jHi7X3aU9tYXIBx6SPv21hfQNFraAljsdCoFEGfYWyFif58B5iJ5eyMpeFqHEJUN5182KtV9tgL46Tv/SGgTJyfgrTMljRkQ7FmdO7++gBiLyWt+VQz7+wzyjEeZGY8nOUqLzXIqXmf7TAe8MUcRF0jmB/h6IvPjC5AiCj4vD+aiQrwZMKNyKRT1A6IYBc9xSWsK5gqtmMDxgUnq/Y3HYiCxBDZ5KscMcvE/ntTxdU8DAhkAAAAASUVORK5CYII=" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("9a0af2b1fd9659480d43132db95cd7d459d1a66480fe42150e132d03b9731573"), + name: Some(Arc::from("Creaking")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + // Chase the Skies skin pack + // - https://www.minecraft.net/content/dam/minecraftnet/games/minecraft/software/ChaseTheSkies_JavaSkins.zip + // - https://minecraft.wiki/w/Chase_the_Skies_(skin_pack) + Skin { + texture_key: Arc::from("8409954698b6c7741460fdd85d6ec6a5e0a9ad04ade7e2c72c913f02936a607d"), + name: Some(Arc::from("Ghast Pilot")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }, + Skin { + texture_key: Arc::from("e12d98dab548e92cad7ac80f92d8fefbb9ca7a1af94aa4f428daf6ef723aa8e0"), + name: Some(Arc::from("Ghast Swimmer")), + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: Arc::from(Url::try_from( + "data:image/png;base64,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" + ).unwrap()), + source: SkinSource::Default, + is_equipped: false, + }] +}); diff --git a/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png new file mode 100644 index 000000000..639b3fe15 Binary files /dev/null and b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png differ diff --git a/packages/app-lib/src/api/minecraft_skins/png_util.rs b/packages/app-lib/src/api/minecraft_skins/png_util.rs new file mode 100644 index 000000000..65e008ef7 --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/png_util.rs @@ -0,0 +1,323 @@ +//! Miscellaneous PNG utilities for Minecraft skins. + +use std::sync::Arc; + +use base64::Engine; +use bytemuck::{AnyBitPattern, NoUninit}; +use bytes::Bytes; +use data_url::DataUrl; +use futures::{Stream, TryStreamExt, future::Either, stream}; +use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge}; +use url::Url; + +use crate::{ + ErrorKind, minecraft_skins::UrlOrBlob, util::fetch::REQWEST_CLIENT, +}; + +pub async fn url_to_data_stream( + url: &Url, +) -> crate::Result> + use<>> { + if url.scheme() == "data" { + let data = DataUrl::process(url.as_str())?.decode_to_vec()?.0.into(); + + Ok(Either::Left(stream::once(async { Ok(data) }))) + } else { + let response = REQWEST_CLIENT + .get(url.as_str()) + .header("Accept", "image/png") + .send() + .await + .and_then(|response| response.error_for_status())?; + + Ok(Either::Right(response.bytes_stream())) + } +} + +pub fn blob_to_data_url(png_data: impl AsRef<[u8]>) -> Option> { + let png_data = png_data.as_ref(); + + is_png(png_data).then(|| { + Url::parse(&format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png_data) + )) + .unwrap() + .into() + }) +} + +pub fn is_png(png_data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + png_data.starts_with(PNG_SIGNATURE) +} + +pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(png_data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + png_data + .get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + png_data + .get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} + +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// The normalized, processed is returned texture as a byte array in PNG format. +pub async fn normalize_skin_texture( + texture: &UrlOrBlob, +) -> crate::Result { + let texture_stream = SyncIoBridge::new(Box::pin( + match texture { + UrlOrBlob::Url(url) => Either::Left( + url_to_data_stream(url) + .await? + .map_err(std::io::Error::other) + .into_async_read(), + ), + UrlOrBlob::Blob(blob) => Either::Right( + stream::once({ + let blob = Bytes::clone(blob); + async { Ok(blob) } + }) + .into_async_read(), + ), + } + .compat(), + )); + + tokio::task::spawn_blocking(|| { + let mut png_reader = { + let mut decoder = png::Decoder::new(texture_stream); + decoder.set_transformations( + png::Transformations::normalize_to_color8(), + ); + decoder.read_info() + }?; + + // The code below assumes that the skin texture has valid dimensions. + // This also serves as a way to bail out early for obviously invalid or + // adversarial textures + if png_reader.info().width != 64 + || ![64, 32].contains(&png_reader.info().height) + { + Err(ErrorKind::InvalidSkinTexture)?; + } + + let is_legacy_skin = png_reader.info().height == 32; + + let mut texture_buf = if is_legacy_skin { + // Legacy skins have half the height, so duplicate the rows to + // turn them into a 64x64 texture + vec![0; png_reader.output_buffer_size() * 2] + } else { + // Modern skins are left as-is + vec![0; png_reader.output_buffer_size()] + }; + + let texture_buf_color_type = png_reader.output_color_type().0; + png_reader.next_frame(&mut texture_buf)?; + + if is_legacy_skin { + convert_legacy_skin_texture( + &mut texture_buf, + texture_buf_color_type, + png_reader.info(), + )?; + } + + let mut encoded_png = vec![]; + + let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64); + png_encoder.set_color(texture_buf_color_type); + png_encoder.set_depth(png::BitDepth::Eight); + png_encoder.set_filter(png::FilterType::NoFilter); + png_encoder.set_compression(png::Compression::Fast); + + // Keeping color space information properly set, to handle the occasional + // strange PNG with non-sRGB chromacities and/or different grayscale spaces + // that keeps most people wondering, is what sets a carefully crafted image + // manipulation routine apart :) + if let Some(source_chromacities) = + png_reader.info().source_chromaticities.as_ref().copied() + { + png_encoder.set_source_chromaticities(source_chromacities); + } + if let Some(source_gamma) = + png_reader.info().source_gamma.as_ref().copied() + { + png_encoder.set_source_gamma(source_gamma); + } + if let Some(source_srgb) = png_reader.info().srgb.as_ref().copied() { + png_encoder.set_source_srgb(source_srgb); + } + + let mut png_writer = png_encoder.write_header()?; + png_writer.write_image_data(&texture_buf)?; + png_writer.finish()?; + + Ok(encoded_png.into()) + }) + .await? +} + +/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the +/// native 64x64 format used by modern Minecraft clients. +/// +/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method. +#[inline] +fn convert_legacy_skin_texture( + texture_buf: &mut [u8], + texture_color_type: png::ColorType, + texture_info: &png::Info, +) -> crate::Result<()> { + /// The skin faces the game client copies around, in order, when converting a + /// legacy skin to the native 64x64 format. + const FACE_COPY_PARAMETERS: &[( + usize, + usize, + isize, + isize, + usize, + usize, + )] = &[ + (4, 16, 16, 32, 4, 4), + (8, 16, 16, 32, 4, 4), + (0, 20, 24, 32, 4, 12), + (4, 20, 16, 32, 4, 12), + (8, 20, 8, 32, 4, 12), + (12, 20, 16, 32, 4, 12), + (44, 16, -8, 32, 4, 4), + (48, 16, -8, 32, 4, 4), + (40, 20, 0, 32, 4, 12), + (44, 20, -8, 32, 4, 12), + (48, 20, -16, 32, 4, 12), + (52, 20, -8, 32, 4, 12), + ]; + + for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS { + macro_rules! do_copy { + ($pixel_type:ty) => { + copy_rect_mirror_horizontally::<$pixel_type>( + // This cast should never fail because all pixels have a depth of 8 bits + // after the transformations applied during decoding + ::bytemuck::try_cast_slice_mut(texture_buf).map_err(|_| ErrorKind::InvalidPng)?, + &texture_info, + *x, + *y, + *off_x, + *off_y, + *width, + *height, + ) + }; + } + + match texture_color_type.samples() { + 1 => do_copy!(rgb::Gray), + 2 => do_copy!(rgb::GrayAlpha), + 3 => do_copy!(rgb::Rgb), + 4 => do_copy!(rgb::Rgba), + _ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations + }; + } + + Ok(()) +} + +/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf` +/// whose top-left corner is at coordinates `(x, y)` to a destination rectangle whose top-left +/// corner is at coordinates `(x + off_x, y + off_y)`, while mirroring (i.e., flipping) the +/// pixels horizontally. +/// +/// Equivalent to Mojang's Blaze3D `NativeImage#copyRect(int, int, int, int, int, int, +/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`, +/// respectively. +#[allow(clippy::too_many_arguments)] +fn copy_rect_mirror_horizontally( + texture_buf: &mut [PixelType], + texture_info: &png::Info, + x: usize, + y: usize, + off_x: isize, + off_y: isize, + width: usize, + height: usize, +) { + for row in 0..height { + for col in 0..width { + let src_x = x + col; + let src_y = y + row; + let dst_x = (x as isize + off_x) as usize + (width - 1 - col); + let dst_y = (y as isize + off_y) as usize + row; + + texture_buf[dst_x + dst_y * texture_info.width as usize] = + texture_buf[src_x + src_y * texture_info.width as usize]; + } + } +} + +#[cfg(test)] +#[tokio::test] +async fn normalize_skin_texture_works() { + let legacy_png_data = &include_bytes!("assets/default/MissingNo.png")[..]; + let expected_normalized_png_data = + &include_bytes!("assets/test/MissingNo_normalized.png")[..]; + + let normalized_png_data = + normalize_skin_texture(&UrlOrBlob::Blob(legacy_png_data.into())) + .await + .expect("Failed to normalize skin texture"); + + let decode_to_pixels = |png_data: &[u8]| { + let decoder = png::Decoder::new(png_data); + let mut reader = decoder.read_info().expect("Failed to read PNG info"); + let mut buffer = vec![0; reader.output_buffer_size()]; + reader + .next_frame(&mut buffer) + .expect("Failed to decode PNG"); + (buffer, reader.info().clone()) + }; + + let (normalized_pixels, normalized_info) = + decode_to_pixels(&normalized_png_data); + let (expected_pixels, expected_info) = + decode_to_pixels(expected_normalized_png_data); + + // Check that dimensions match + assert_eq!(normalized_info.width, expected_info.width); + assert_eq!(normalized_info.height, expected_info.height); + assert_eq!(normalized_info.color_type, expected_info.color_type); + + // Check that pixel data matches + assert_eq!( + normalized_pixels, expected_pixels, + "Pixel data doesn't match" + ); +} diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 421d805c1..2beb93ed7 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod jre; pub mod logs; pub mod metadata; pub mod minecraft_auth; +pub mod minecraft_skins; pub mod mr_auth; pub mod pack; pub mod process; diff --git a/packages/app-lib/src/api/pack/import/atlauncher.rs b/packages/app-lib/src/api/pack/import/atlauncher.rs index f6dabca4c..1c8ba084d 100644 --- a/packages/app-lib/src/api/pack/import/atlauncher.rs +++ b/packages/app-lib/src/api/pack/import/atlauncher.rs @@ -97,12 +97,15 @@ pub struct ATLauncherMod { // Check if folder has a instance.json that parses pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool { - let instance: String = - io::read_to_string(&instance_folder.join("instance.json")) - .await - .unwrap_or("".to_string()); - let instance: Result = - serde_json::from_str::(&instance); + let instance = serde_json::from_str::( + &io::read_any_encoding_to_string( + &instance_folder.join("instance.json"), + ) + .await + .unwrap_or(("".into(), encoding_rs::UTF_8)) + .0, + ); + if let Err(e) = instance { tracing::warn!( "Could not parse instance.json at {}: {}", @@ -124,14 +127,17 @@ pub async fn import_atlauncher( ) -> crate::Result<()> { let atlauncher_instance_path = atlauncher_base_path .join("instances") - .join(instance_folder.clone()); + .join(&instance_folder); // Load instance.json - let atinstance: String = - io::read_to_string(&atlauncher_instance_path.join("instance.json")) - .await?; - let atinstance: ATInstance = - serde_json::from_str::(&atinstance)?; + let atinstance = serde_json::from_str::( + &io::read_any_encoding_to_string( + &atlauncher_instance_path.join("instance.json"), + ) + .await + .unwrap_or(("".into(), encoding_rs::UTF_8)) + .0, + )?; // Icon path should be {instance_folder}/instance.png if it exists, // Second possibility is ATLauncher/configs/images/{safe_pack_name}.png (safe pack name is alphanumeric lowercase) diff --git a/packages/app-lib/src/api/pack/import/curseforge.rs b/packages/app-lib/src/api/pack/import/curseforge.rs index 1c0819e4b..656595fec 100644 --- a/packages/app-lib/src/api/pack/import/curseforge.rs +++ b/packages/app-lib/src/api/pack/import/curseforge.rs @@ -36,13 +36,15 @@ pub struct InstalledModpack { // Check if folder has a minecraftinstance.json that parses pub async fn is_valid_curseforge(instance_folder: PathBuf) -> bool { - let minecraftinstance: String = - io::read_to_string(&instance_folder.join("minecraftinstance.json")) - .await - .unwrap_or("".to_string()); - let minecraftinstance: Result = - serde_json::from_str::(&minecraftinstance); - minecraftinstance.is_ok() + let minecraft_instance = serde_json::from_str::( + &io::read_any_encoding_to_string( + &instance_folder.join("minecraftinstance.json"), + ) + .await + .unwrap_or(("".into(), encoding_rs::UTF_8)) + .0, + ); + minecraft_instance.is_ok() } pub async fn import_curseforge( @@ -50,19 +52,20 @@ pub async fn import_curseforge( profile_path: &str, // path to profile ) -> crate::Result<()> { // Load minecraftinstance.json - let minecraft_instance: String = io::read_to_string( - &curseforge_instance_folder.join("minecraftinstance.json"), - ) - .await?; - let minecraft_instance: MinecraftInstance = - serde_json::from_str::(&minecraft_instance)?; - let override_title: Option = minecraft_instance.name.clone(); + let minecraft_instance = serde_json::from_str::( + &io::read_any_encoding_to_string( + &curseforge_instance_folder.join("minecraftinstance.json"), + ) + .await + .unwrap_or(("".into(), encoding_rs::UTF_8)) + .0, + )?; + let override_title = minecraft_instance.name; let backup_name = format!( "Curseforge-{}", curseforge_instance_folder .file_name() - .map(|a| a.to_string_lossy().to_string()) - .unwrap_or("Unknown".to_string()) + .map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string()) ); let state = State::get().await?; diff --git a/packages/app-lib/src/api/pack/import/gdlauncher.rs b/packages/app-lib/src/api/pack/import/gdlauncher.rs index 307301014..bb84f574c 100644 --- a/packages/app-lib/src/api/pack/import/gdlauncher.rs +++ b/packages/app-lib/src/api/pack/import/gdlauncher.rs @@ -25,12 +25,12 @@ pub struct GDLauncherLoader { // Check if folder has a config.json that parses pub async fn is_valid_gdlauncher(instance_folder: PathBuf) -> bool { - let config: String = - io::read_to_string(&instance_folder.join("config.json")) + let config = serde_json::from_str::( + &io::read_any_encoding_to_string(&instance_folder.join("config.json")) .await - .unwrap_or("".to_string()); - let config: Result = - serde_json::from_str::(&config); + .unwrap_or(("".into(), encoding_rs::UTF_8)) + .0, + ); config.is_ok() } @@ -39,18 +39,20 @@ pub async fn import_gdlauncher( profile_path: &str, // path to profile ) -> crate::Result<()> { // Load config.json - let config: String = - io::read_to_string(&gdlauncher_instance_folder.join("config.json")) - .await?; - let config: GDLauncherConfig = - serde_json::from_str::(&config)?; - let override_title: Option = config.loader.source_name.clone(); + let config = serde_json::from_str::( + &io::read_any_encoding_to_string( + &gdlauncher_instance_folder.join("config.json"), + ) + .await + .unwrap_or(("".into(), encoding_rs::UTF_8)) + .0, + )?; + let override_title = config.loader.source_name; let backup_name = format!( "GDLauncher-{}", gdlauncher_instance_folder .file_name() - .map(|a| a.to_string_lossy().to_string()) - .unwrap_or("Unknown".to_string()) + .map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string()) ); // Re-cache icon diff --git a/packages/app-lib/src/api/pack/import/mmc.rs b/packages/app-lib/src/api/pack/import/mmc.rs index 94083be88..030ad003d 100644 --- a/packages/app-lib/src/api/pack/import/mmc.rs +++ b/packages/app-lib/src/api/pack/import/mmc.rs @@ -26,6 +26,7 @@ enum MMCInstanceEnum { struct MMCInstanceGeneral { pub general: MMCInstance, } + #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "PascalCase")] pub struct MMCInstance { @@ -144,9 +145,9 @@ pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool { let instance_cfg = instance_folder.join("instance.cfg"); let mmc_pack = instance_folder.join("mmc-pack.json"); - let mmc_pack = match io::read_to_string(&mmc_pack).await { - Ok(mmc_pack) => mmc_pack, - Err(_) => return false, + let Ok((mmc_pack, _)) = io::read_any_encoding_to_string(&mmc_pack).await + else { + return false; }; load_instance_cfg(&instance_cfg).await.is_ok() @@ -155,7 +156,7 @@ pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool { #[tracing::instrument] pub async fn get_instances_subpath(config: PathBuf) -> Option { - let launcher = io::read_to_string(&config).await.ok()?; + let launcher = io::read_any_encoding_to_string(&config).await.ok()?.0; let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?; match launcher { MMCLauncherEnum::General(p) => Some(p.general.instance_dir), @@ -165,10 +166,9 @@ pub async fn get_instances_subpath(config: PathBuf) -> Option { // Loading the INI (instance.cfg) file async fn load_instance_cfg(file_path: &Path) -> crate::Result { - let instance_cfg: String = io::read_to_string(file_path).await?; - let instance_cfg_enum: MMCInstanceEnum = - serde_ini::from_str::(&instance_cfg)?; - match instance_cfg_enum { + match serde_ini::from_str::( + &io::read_any_encoding_to_string(file_path).await?.0, + )? { MMCInstanceEnum::General(instance_cfg) => Ok(instance_cfg.general), MMCInstanceEnum::Instance(instance_cfg) => Ok(instance_cfg), } @@ -183,9 +183,13 @@ pub async fn import_mmc( let mmc_instance_path = mmc_base_path.join("instances").join(instance_folder); - let mmc_pack = - io::read_to_string(&mmc_instance_path.join("mmc-pack.json")).await?; - let mmc_pack: MMCPack = serde_json::from_str::(&mmc_pack)?; + let mmc_pack = serde_json::from_str::( + &io::read_any_encoding_to_string( + &mmc_instance_path.join("mmc-pack.json"), + ) + .await? + .0, + )?; let instance_cfg = load_instance_cfg(&mmc_instance_path.join("instance.cfg")).await?; @@ -230,7 +234,7 @@ pub async fn import_mmc( // Kept separate as we may in the future want to add special handling for modrinth managed packs import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack).await?; } - Some(MMCManagedPackType::Flame) | Some(MMCManagedPackType::ATLauncher) => { + Some(MMCManagedPackType::Flame | MMCManagedPackType::ATLauncher) => { // For flame/atlauncher managed packs // Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft' import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack).await?; @@ -243,7 +247,7 @@ pub async fn import_mmc( _ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into()) } } else { - // Direclty import unmanaged pack + // Directly import unmanaged pack import_mmc_unmanaged( profile_path, minecraft_folder, diff --git a/packages/app-lib/src/api/pack/install_from.rs b/packages/app-lib/src/api/pack/install_from.rs index b1f35e013..d3ec041cf 100644 --- a/packages/app-lib/src/api/pack/install_from.rs +++ b/packages/app-lib/src/api/pack/install_from.rs @@ -357,9 +357,7 @@ pub async fn set_profile_information( } } - let game_version = if let Some(game_version) = game_version { - game_version - } else { + let Some(game_version) = game_version else { return Err(crate::ErrorKind::InputError( "Pack did not specify Minecraft version".to_string(), ) @@ -393,10 +391,7 @@ pub async fn set_profile_information( locked: if !ignore_lock { true } else { - prof.linked_data - .as_ref() - .map(|x| x.locked) - .unwrap_or(true) + prof.linked_data.as_ref().is_none_or(|x| x.locked) }, }) } diff --git a/packages/app-lib/src/api/pack/install_mrpack.rs b/packages/app-lib/src/api/pack/install_mrpack.rs index fd2b6bc5a..d65991a65 100644 --- a/packages/app-lib/src/api/pack/install_mrpack.rs +++ b/packages/app-lib/src/api/pack/install_mrpack.rs @@ -152,8 +152,7 @@ pub async fn install_zipped_mrpack_files( if let Some(env) = project.env { if env .get(&EnvType::Client) - .map(|x| x == &SideType::Unsupported) - .unwrap_or(false) + .is_some_and(|x| x == &SideType::Unsupported) { return Ok(()); } diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs index b1c21207d..da12fe309 100644 --- a/packages/app-lib/src/api/profile/mod.rs +++ b/packages/app-lib/src/api/profile/mod.rs @@ -586,7 +586,7 @@ pub async fn get_pack_export_candidates( .await .map_err(|e| IOError::with_path(e, &profile_base_dir))? { - let path: PathBuf = entry.path(); + let path = entry.path(); if path.is_dir() { // Two layers of files/folders if its a folder let mut read_dir = io::read_dir(&path).await?; @@ -595,10 +595,10 @@ pub async fn get_pack_export_candidates( .await .map_err(|e| IOError::with_path(e, &profile_base_dir))? { - let path: PathBuf = entry.path(); - - path_list - .push(pack_get_relative_path(&profile_base_dir, &path)?); + path_list.push(pack_get_relative_path( + &profile_base_dir, + &entry.path(), + )?); } } else { // One layer of files/folders if its a file @@ -642,10 +642,8 @@ pub async fn run( } /// Run Minecraft using a profile, and credentials for authentication -/// Returns Arc pointer to RwLock to Child #[tracing::instrument(skip(credentials))] - -pub async fn run_credentials( +async fn run_credentials( path: &str, credentials: &Credentials, quick_play_type: &QuickPlayType, @@ -662,14 +660,15 @@ pub async fn run_credentials( .hooks .pre_launch .as_ref() - .or(settings.hooks.pre_launch.as_ref()); + .or(settings.hooks.pre_launch.as_ref()) + .filter(|hook_command| !hook_command.is_empty()); if let Some(hook) = pre_launch_hooks { // TODO: hook parameters let mut cmd = hook.split(' '); if let Some(command) = cmd.next() { let full_path = get_full_path(&profile.path).await?; let result = Command::new(command) - .args(cmd.collect::>()) + .args(cmd) .current_dir(&full_path) .spawn() .map_err(|e| IOError::with_path(e, &full_path))? @@ -692,7 +691,12 @@ pub async fn run_credentials( .clone() .unwrap_or(settings.extra_launch_args); - let wrapper = profile.hooks.wrapper.clone().or(settings.hooks.wrapper); + let wrapper = profile + .hooks + .wrapper + .clone() + .or(settings.hooks.wrapper) + .filter(|hook_command| !hook_command.is_empty()); let memory = profile.memory.unwrap_or(settings.memory); let resolution = @@ -704,8 +708,12 @@ pub async fn run_credentials( .unwrap_or(settings.custom_env_vars); // Post post exit hooks - let post_exit_hook = - profile.hooks.post_exit.clone().or(settings.hooks.post_exit); + let post_exit_hook = profile + .hooks + .post_exit + .clone() + .or(settings.hooks.post_exit) + .filter(|hook_command| !hook_command.is_empty()); // Any options.txt settings that we want set, add here let mut mc_set_options: Vec<(String, String)> = vec![]; @@ -872,15 +880,12 @@ pub async fn create_mrpack_json( env.insert(EnvType::Client, SideType::Required); env.insert(EnvType::Server, SideType::Required); - let primary_file = - if let Some(primary_file) = version.files.first() { - primary_file - } else { - return Some(Err(crate::ErrorKind::OtherError( - format!("No primary file found for mod at: {path}"), - ) - .as_error())); - }; + let Some(primary_file) = version.files.first() else { + return Some(Err(crate::ErrorKind::OtherError(format!( + "No primary file found for mod at: {path}" + )) + .as_error())); + }; let file_size = primary_file.size; let downloads = vec![primary_file.url.clone()]; diff --git a/packages/app-lib/src/api/settings.rs b/packages/app-lib/src/api/settings.rs index 75e34d33c..761959683 100644 --- a/packages/app-lib/src/api/settings.rs +++ b/packages/app-lib/src/api/settings.rs @@ -24,6 +24,8 @@ pub async fn set(settings: Settings) -> crate::Result<()> { #[tracing::instrument] pub async fn cancel_directory_change() -> crate::Result<()> { + // This is called to handle state initialization errors due to folder migrations + // failing, so fetching a DB connection pool from `State::get` is not reliable here let pool = crate::state::db::connect().await?; let mut settings = Settings::get(&pool).await?; diff --git a/packages/app-lib/src/api/worlds.rs b/packages/app-lib/src/api/worlds.rs index 44704dfa9..0f274876a 100644 --- a/packages/app-lib/src/api/worlds.rs +++ b/packages/app-lib/src/api/worlds.rs @@ -26,6 +26,8 @@ use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use tokio::io::AsyncWriteExt; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; use tokio_util::compat::FuturesAsyncWriteCompatExt; use url::Url; @@ -255,7 +257,7 @@ async fn get_all_worlds_in_profile( AttachedWorldData::get_all_for_instance(profile_path, &state.pool) .await?; if !attached_data.is_empty() { - for world in worlds.iter_mut() { + for world in &mut worlds { if let Some(data) = attached_data .get(&(world.world_type(), world.world_id().to_owned())) { @@ -394,25 +396,27 @@ async fn get_server_worlds_in_profile( .await .ok(); + let first_server_index = worlds.len(); for (index, server) in servers.into_iter().enumerate() { if server.hidden { // TODO: Figure out whether we want to hide or show direct connect servers continue; } - let icon = server.icon.and_then(|icon| { - Url::parse(&format!("data:image/png;base64,{icon}")).ok() - }); - let last_played = join_log - .as_ref() - .and_then(|log| { - let address = parse_server_address(&server.ip).ok()?; - log.get(&(address.0.to_owned(), address.1)) - }) - .copied(); let world = World { name: server.name, - last_played, - icon: icon.map(Either::Right), + last_played: join_log + .as_ref() + .and_then(|log| { + let (host, port) = parse_server_address(&server.ip).ok()?; + log.get(&(host.to_owned(), port)) + }) + .copied(), + icon: server + .icon + .and_then(|icon| { + Url::parse(&format!("data:image/png;base64,{icon}")).ok() + }) + .map(Either::Right), display_status: DisplayStatus::Normal, details: WorldDetails::Server { index, @@ -423,6 +427,30 @@ async fn get_server_worlds_in_profile( worlds.push(world); } + if let Some(join_log) = join_log { + let mut futures = JoinSet::new(); + for (index, world) in worlds.iter().enumerate().skip(first_server_index) + { + if world.last_played.is_some() { + continue; + } + if let WorldDetails::Server { address, .. } = &world.details + && let Ok((host, port)) = parse_server_address(address) + { + let host = host.to_owned(); + futures.spawn(async move { + resolve_server_address(&host, port) + .await + .ok() + .map(|x| (index, x)) + }); + } + } + for (index, address) in futures.join_all().await.into_iter().flatten() { + worlds[index].last_played = join_log.get(&address).copied(); + } + } + Ok(()) } @@ -943,9 +971,13 @@ async fn resolve_server_address( host: &str, port: u16, ) -> Result<(String, u16)> { + static SIMULTANEOUS_DNS_QUERIES: Semaphore = Semaphore::const_new(24); + if host.parse::().is_ok() || host.parse::().is_ok() { return Ok((host.to_owned(), port)); } + + let _permit = SIMULTANEOUS_DNS_QUERIES.acquire().await?; let resolver = hickory_resolver::TokioResolver::builder_tokio()?.build(); Ok( match resolver.srv_lookup(format!("_minecraft._tcp.{host}")).await { diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 587c9559a..75c144f55 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -1,5 +1,8 @@ //! Theseus error type +use std::sync::Arc; + use crate::{profile, util}; +use data_url::DataUrlError; use tracing_error::InstrumentError; #[derive(thiserror::Error, Debug)] @@ -125,12 +128,35 @@ pub enum ErrorKind { #[error("Error resolving DNS: {0}")] DNSError(#[from] hickory_resolver::ResolveError), + + #[error("An online profile for {user_name} is not available")] + OnlineMinecraftProfileUnavailable { user_name: String }, + + #[error("Invalid data URL: {0}")] + InvalidDataUrl(#[from] DataUrlError), + + #[error("Invalid data URL: {0}")] + InvalidDataUrlBase64(#[from] data_url::forgiving_base64::InvalidBase64), + + #[error("Invalid PNG")] + InvalidPng, + + #[error("Invalid PNG: {0}")] + PngDecodingError(#[from] png::DecodingError), + + #[error("PNG encoding error: {0}")] + PngEncodingError(#[from] png::EncodingError), + + #[error( + "A skin texture must have a dimension of either 64x64 or 64x32 pixels" + )] + InvalidSkinTexture, } #[derive(Debug)] pub struct Error { - pub raw: std::sync::Arc, - pub source: tracing_error::TracedError>, + pub raw: Arc, + pub source: tracing_error::TracedError>, } impl std::error::Error for Error { @@ -148,7 +174,7 @@ impl std::fmt::Display for Error { impl> From for Error { fn from(source: E) -> Self { let error = Into::::into(source); - let boxed_error = std::sync::Arc::new(error); + let boxed_error = Arc::new(error); Self { raw: boxed_error.clone(), diff --git a/packages/app-lib/src/event/emit.rs b/packages/app-lib/src/event/emit.rs index e2e438618..b8d485a69 100644 --- a/packages/app-lib/src/event/emit.rs +++ b/packages/app-lib/src/event/emit.rs @@ -139,9 +139,7 @@ pub async fn edit_loading( // increment refers to by what relative increment to the loading struct's total to update // message is the message to display on the loading bar- if None, use the loading bar's default one // By convention, fraction is the fraction of the progress bar that is filled -#[allow(unused_variables)] #[tracing::instrument(level = "debug")] - pub fn emit_loading( key: &LoadingBarId, increment_frac: f64, @@ -149,22 +147,13 @@ pub fn emit_loading( ) -> crate::Result<()> { let event_state = crate::EventState::get()?; - let mut loading_bar = match event_state.loading_bars.get_mut(&key.0) { - Some(f) => f, - None => { - return Err(EventError::NoLoadingBar(key.0).into()); - } + let Some(mut loading_bar) = event_state.loading_bars.get_mut(&key.0) else { + return Err(EventError::NoLoadingBar(key.0).into()); }; // Tick up loading bar loading_bar.current += increment_frac; let display_frac = loading_bar.current / loading_bar.total; - let opt_display_frac = if display_frac >= 1.0 { - None // by convention, when its done, we submit None - // any further updates will be ignored (also sending None) - } else { - Some(display_frac) - }; if f64::abs(display_frac - loading_bar.last_sent) > 0.005 { // Emit event to indicatif progress bar @@ -187,7 +176,12 @@ pub fn emit_loading( .emit( "loading", LoadingPayload { - fraction: opt_display_frac, + fraction: if display_frac >= 1.0 { + None // by convention, when its done, we submit None + // any further updates will be ignored (also sending None) + } else { + Some(display_frac) + }, message: message .unwrap_or(&loading_bar.message) .to_string(), @@ -197,6 +191,9 @@ pub fn emit_loading( ) .map_err(EventError::from)?; + #[cfg(not(any(feature = "cli", feature = "tauri")))] + let _ = message; + loading_bar.last_sent = display_frac; } @@ -204,8 +201,6 @@ pub fn emit_loading( } // emit_warning(message) -#[allow(dead_code)] -#[allow(unused_variables)] pub async fn emit_warning(message: &str) -> crate::Result<()> { #[cfg(feature = "tauri")] { @@ -227,8 +222,6 @@ pub async fn emit_warning(message: &str) -> crate::Result<()> { // emit_command(CommandPayload::Something { something }) // ie: installing a pack, opening an .mrpack, etc // Generally used for url deep links and file opens that we want to handle in the frontend -#[allow(dead_code)] -#[allow(unused_variables)] pub async fn emit_command(command: CommandPayload) -> crate::Result<()> { tracing::debug!("Command: {}", serde_json::to_string(&command)?); #[cfg(feature = "tauri")] diff --git a/packages/app-lib/src/launcher/args.rs b/packages/app-lib/src/launcher/args.rs index 4617320d8..5d6bbc5d8 100644 --- a/packages/app-lib/src/launcher/args.rs +++ b/packages/app-lib/src/launcher/args.rs @@ -13,7 +13,7 @@ use daedalus::{ modded::SidedDataEntry, }; use dunce::canonicalize; -use std::collections::HashSet; +use hashlink::LinkedHashSet; use std::io::{BufRead, BufReader}; use std::{collections::HashMap, path::Path}; use uuid::Uuid; @@ -24,7 +24,7 @@ const TEMPORARY_REPLACE_CHAR: &str = "\n"; pub fn get_class_paths( libraries_path: &Path, libraries: &[Library], - client_path: &Path, + launcher_class_path: &[&Path], java_arch: &str, minecraft_updated: bool, ) -> crate::Result { @@ -48,20 +48,22 @@ pub fn get_class_paths( Some(get_lib_path(libraries_path, &library.name, false)) }) - .collect::, _>>()?; + .collect::, _>>()?; - cps.insert( - canonicalize(client_path) - .map_err(|_| { - crate::ErrorKind::LauncherError(format!( - "Specified class path {} does not exist", - client_path.to_string_lossy() - )) - .as_error() - })? - .to_string_lossy() - .to_string(), - ); + for launcher_path in launcher_class_path { + cps.insert( + canonicalize(launcher_path) + .map_err(|_| { + crate::ErrorKind::LauncherError(format!( + "Specified class path {} does not exist", + launcher_path.to_string_lossy() + )) + .as_error() + })? + .to_string_lossy() + .to_string(), + ); + } Ok(cps .into_iter() @@ -87,9 +89,9 @@ pub fn get_lib_path( lib: &str, allow_not_exist: bool, ) -> crate::Result { - let mut path = libraries_path.to_path_buf(); - - path.push(get_path_from_artifact(lib)?); + let path = libraries_path + .to_path_buf() + .join(get_path_from_artifact(lib)?); if !path.exists() && allow_not_exist { return Ok(path.to_string_lossy().to_string()); @@ -211,7 +213,7 @@ fn parse_jvm_argument( } #[allow(clippy::too_many_arguments)] -pub fn get_minecraft_arguments( +pub async fn get_minecraft_arguments( arguments: Option<&[Argument]>, legacy_arguments: Option<&str>, credentials: &Credentials, @@ -224,6 +226,9 @@ pub fn get_minecraft_arguments( java_arch: &str, quick_play_type: &QuickPlayType, ) -> crate::Result> { + let access_token = credentials.access_token.clone(); + let profile = credentials.maybe_online_profile().await; + if let Some(arguments) = arguments { let mut parsed_arguments = Vec::new(); @@ -233,9 +238,9 @@ pub fn get_minecraft_arguments( |arg| { parse_minecraft_argument( arg, - &credentials.access_token, - &credentials.username, - credentials.id, + &access_token, + &profile.name, + profile.id, version, asset_index_name, game_directory, @@ -255,9 +260,9 @@ pub fn get_minecraft_arguments( for x in legacy_arguments.split(' ') { parsed_arguments.push(parse_minecraft_argument( &x.replace(' ', TEMPORARY_REPLACE_CHAR), - &credentials.access_token, - &credentials.username, - credentials.id, + &access_token, + &profile.name, + profile.id, version, asset_index_name, game_directory, diff --git a/packages/app-lib/src/launcher/download.rs b/packages/app-lib/src/launcher/download.rs index c7c73887a..4a0a5879f 100644 --- a/packages/app-lib/src/launcher/download.rs +++ b/packages/app-lib/src/launcher/download.rs @@ -37,12 +37,7 @@ pub async fn download_minecraft( let assets_index = download_assets_index(st, version, Some(loading_bar), force).await?; - let amount = if version - .processors - .as_ref() - .map(|x| !x.is_empty()) - .unwrap_or(false) - { + let amount = if version.processors.as_ref().is_some_and(|x| !x.is_empty()) { 25.0 } else { 40.0 diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index d3e9d90b9..d39fd94a9 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -9,15 +9,17 @@ use crate::state::{ Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage, }; use crate::util::io; -use crate::{State, process, state as st}; +use crate::{State, get_resource_file, process, state as st}; use chrono::Utc; use daedalus as d; use daedalus::minecraft::{LoggingSide, RuleAction, VersionInfo}; use daedalus::modded::LoaderVersion; +use regex::Regex; use serde::Deserialize; use st::Profile; -use std::collections::HashMap; +use std::fmt::Write; use std::path::PathBuf; +use tokio::io::AsyncWriteExt; use tokio::process::Command; mod args; @@ -123,12 +125,10 @@ pub async fn get_java_version_from_profile( version_info: &VersionInfo, ) -> crate::Result> { if let Some(java) = profile.java_path.as_ref() { - let java = crate::api::jre::check_jre(std::path::PathBuf::from(java)) - .await - .ok() - .flatten(); + let java = + crate::api::jre::check_jre(std::path::PathBuf::from(java)).await; - if let Some(java) = java { + if let Ok(java) = java { return Ok(Some(java)); } } @@ -136,8 +136,7 @@ pub async fn get_java_version_from_profile( let key = version_info .java_version .as_ref() - .map(|it| it.major_version) - .unwrap_or(8); + .map_or(8, |it| it.major_version); let state = State::get().await?; @@ -252,8 +251,7 @@ pub async fn install_minecraft( let loader_version_id = loader_version.clone(); crate::api::profile::edit(&profile.path, |prof| { - prof.loader_version = - loader_version_id.clone().map(|x| x.id.clone()); + prof.loader_version = loader_version_id.clone().map(|x| x.id); async { Ok(()) } }) @@ -278,8 +276,7 @@ pub async fn install_minecraft( let key = version_info .java_version .as_ref() - .map(|it| it.major_version) - .unwrap_or(8); + .map_or(8, |it| it.major_version); let (java_version, set_java) = if let Some(java_version) = get_java_version_from_profile(profile, &version_info).await? { @@ -291,13 +288,7 @@ pub async fn install_minecraft( }; // Test jre version - let java_version = crate::api::jre::check_jre(java_version.clone()) - .await? - .ok_or_else(|| { - crate::ErrorKind::LauncherError(format!( - "Java path invalid or non-functional: {java_version:?}" - )) - })?; + let java_version = crate::api::jre::check_jre(java_version.clone()).await?; if set_java { java_version.upsert(&state.pool).await?; @@ -352,9 +343,11 @@ pub async fn install_minecraft( } } - let cp = wrap_ref_builder!(cp = processor.classpath.clone() => { - cp.push(processor.jar.clone()) - }); + let cp = { + let mut cp = processor.classpath.clone(); + cp.push(processor.jar.clone()); + cp + }; let child = Command::new(&java_version.path) .arg("-cp") @@ -560,14 +553,7 @@ pub async fn launch_minecraft( // Test jre version let java_version = - crate::api::jre::check_jre(java_version.path.clone().into()) - .await? - .ok_or_else(|| { - crate::ErrorKind::LauncherError(format!( - "Java path invalid or non-functional: {}", - java_version.path - )) - })?; + crate::api::jre::check_jre(java_version.path.clone().into()).await?; let client_path = state .directories @@ -577,7 +563,9 @@ pub async fn launch_minecraft( let args = version_info.arguments.clone().unwrap_or_default(); let mut command = match wrapper { Some(hook) => { - wrap_ref_builder!(it = Command::new(hook) => {it.arg(&java_version.path)}) + let mut command = Command::new(hook); + command.arg(&java_version.path); + command } None => Command::new(&java_version.path), }; @@ -601,34 +589,49 @@ pub async fn launch_minecraft( io::create_dir_all(&natives_dir).await?; } - command - .args( - args::get_jvm_arguments( - args.get(&d::minecraft::ArgumentType::Jvm) - .map(|x| x.as_slice()), - &natives_dir, + let (main_class_keep_alive, main_class_path) = + get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?; + + command.args( + args::get_jvm_arguments( + args.get(&d::minecraft::ArgumentType::Jvm) + .map(|x| x.as_slice()), + &natives_dir, + &state.directories.libraries_dir(), + &state.directories.log_configs_dir(), + &args::get_class_paths( &state.directories.libraries_dir(), - &state.directories.log_configs_dir(), - &args::get_class_paths( - &state.directories.libraries_dir(), - version_info.libraries.as_slice(), - &client_path, - &java_version.architecture, - minecraft_updated, - )?, - &version_jar, - *memory, - Vec::from(java_args), + version_info.libraries.as_slice(), + &[&main_class_path, &client_path], &java_version.architecture, - quick_play_type, - version_info - .logging - .as_ref() - .and_then(|x| x.get(&LoggingSide::Client)), - )? - .into_iter() - .collect::>(), - ) + minecraft_updated, + )?, + &version_jar, + *memory, + Vec::from(java_args), + &java_version.architecture, + quick_play_type, + version_info + .logging + .as_ref() + .and_then(|x| x.get(&LoggingSide::Client)), + )? + .into_iter(), + ); + + // The java launcher requires access to java.lang.reflect in order to force access in to + // whatever module the main class is in + if java_version.parsed_version >= 9 { + command.arg("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + } + + // The java launcher code requires internal JDK code in Java 25+ in order to support JEP 512 + if java_version.parsed_version >= 25 { + command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED"); + } + + command + .arg("com.modrinth.theseus.MinecraftLaunch") .arg(version_info.main_class.clone()) .args( args::get_minecraft_arguments( @@ -644,9 +647,9 @@ pub async fn launch_minecraft( *resolution, &java_version.architecture, quick_play_type, - )? - .into_iter() - .collect::>(), + ) + .await? + .into_iter(), ) .current_dir(instance_path.clone()); @@ -655,27 +658,42 @@ pub async fn launch_minecraft( if std::env::var("CARGO").is_ok() { command.env_remove("DYLD_FALLBACK_LIBRARY_PATH"); } - // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwites them) + // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them) command.env_remove("_JAVA_OPTIONS"); command.envs(env_args); // Overwrites the minecraft options.txt file with the settings from the profile // Uses 'a:b' syntax which is not quite yaml - use regex::Regex; - if !mc_set_options.is_empty() { let options_path = instance_path.join("options.txt"); - let mut options_string = String::new(); - if options_path.exists() { - options_string = io::read_to_string(&options_path).await?; + + let (mut options_string, input_encoding) = if options_path.exists() { + io::read_any_encoding_to_string(&options_path).await? + } else { + (String::new(), encoding_rs::UTF_8) + }; + + // UTF-16 encodings may be successfully detected and read, but we cannot encode + // them back, and it's technically possible that the game client strongly expects + // such encoding + if input_encoding != input_encoding.output_encoding() { + return Err(crate::ErrorKind::LauncherError(format!( + "The instance options.txt file uses an unsupported encoding: {}. \ + Please either turn off instance options that need to modify this file, \ + or convert the file to an encoding that both the game and this app support, \ + such as UTF-8.", + input_encoding.name() + )) + .into()); } + for (key, value) in mc_set_options { let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?; // check if the regex exists in the file if !re.is_match(&options_string) { // The key was not found in the file, so append it - options_string.push_str(&format!("\n{key}:{value}")); + write!(&mut options_string, "\n{key}:{value}").unwrap(); } else { let replaced_string = re .replace_all(&options_string, &format!("{key}:{value}")) @@ -684,7 +702,8 @@ pub async fn launch_minecraft( } } - io::write(&options_path, options_string).await?; + io::write(&options_path, input_encoding.encode(&options_string).0) + .await?; } crate::api::profile::edit(&profile.path, |prof| { @@ -694,31 +713,6 @@ pub async fn launch_minecraft( }) .await?; - let mut censor_strings = HashMap::new(); - let username = whoami::username(); - censor_strings - .insert(format!("/{username}/"), "/{COMPUTER_USERNAME}/".to_string()); - censor_strings.insert( - format!("\\{username}\\"), - "\\{COMPUTER_USERNAME}\\".to_string(), - ); - censor_strings.insert( - credentials.access_token.clone(), - "{MINECRAFT_ACCESS_TOKEN}".to_string(), - ); - censor_strings.insert( - credentials.username.clone(), - "{MINECRAFT_USERNAME}".to_string(), - ); - censor_strings.insert( - credentials.id.as_simple().to_string(), - "{MINECRAFT_UUID}".to_string(), - ); - censor_strings.insert( - credentials.id.as_hyphenated().to_string(), - "{MINECRAFT_UUID}".to_string(), - ); - // If in tauri, and the 'minimize on launch' setting is enabled, minimize the window #[cfg(feature = "tauri")] { @@ -753,6 +747,40 @@ pub async fn launch_minecraft( post_exit_hook, state.directories.profile_logs_dir(&profile.path), version_info.logging.is_some(), + main_class_keep_alive, + async |process: &ProcessMetadata, stdin| { + let process_start_time = process.start_time.to_rfc3339(); + let profile_created_time = profile.created.to_rfc3339(); + let profile_modified_time = profile.modified.to_rfc3339(); + let system_properties = [ + ("modrinth.process.startTime", Some(&process_start_time)), + ("modrinth.profile.created", Some(&profile_created_time)), + ("modrinth.profile.icon", profile.icon_path.as_ref()), + ( + "modrinth.profile.link.project", + profile.linked_data.as_ref().map(|x| &x.project_id), + ), + ( + "modrinth.profile.link.version", + profile.linked_data.as_ref().map(|x| &x.version_id), + ), + ("modrinth.profile.modified", Some(&profile_modified_time)), + ("modrinth.profile.name", Some(&profile.name)), + ]; + for (key, value) in system_properties { + let Some(value) = value else { + continue; + }; + stdin.write_all(b"property\t").await?; + stdin.write_all(key.as_bytes()).await?; + stdin.write_u8(b'\t').await?; + stdin.write_all(value.as_bytes()).await?; + stdin.write_u8(b'\n').await?; + } + stdin.write_all(b"launch\n").await?; + stdin.flush().await?; + Ok(()) + }, ) .await } diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index de42549c3..cd6ee1df2 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -461,8 +461,7 @@ impl CacheValue { CacheValue::Team(members) => members .iter() .next() - .map(|x| x.team_id.as_str()) - .unwrap_or(DEFAULT_ID) + .map_or(DEFAULT_ID, |x| x.team_id.as_str()) .to_string(), CacheValue::Organization(org) => org.id.clone(), CacheValue::File(file) => file.hash.clone(), @@ -556,7 +555,6 @@ macro_rules! impl_cache_methods { $( paste::paste! { #[tracing::instrument(skip(pool, fetch_semaphore))] - #[allow(dead_code)] pub async fn []( id: &str, cache_behaviour: Option, @@ -568,7 +566,6 @@ macro_rules! impl_cache_methods { } #[tracing::instrument(skip(pool, fetch_semaphore))] - #[allow(dead_code)] pub async fn []( ids: &[&str], cache_behaviour: Option, @@ -597,7 +594,6 @@ macro_rules! impl_cache_method_singular { $( paste::paste! { #[tracing::instrument(skip(pool, fetch_semaphore))] - #[allow(dead_code)] pub async fn [] ( cache_behaviour: Option, pool: &SqlitePool, @@ -735,18 +731,13 @@ impl CachedEntry { remaining_keys.retain(|x| { x != &&*row.id - && !row - .alias - .as_ref() - .map(|y| { - if type_.case_sensitive_alias().unwrap_or(true) - { - x == y - } else { - y.to_lowercase() == x.to_lowercase() - } - }) - .unwrap_or(false) + && !row.alias.as_ref().is_some_and(|y| { + if type_.case_sensitive_alias().unwrap_or(true) { + x == y + } else { + y.to_lowercase() == x.to_lowercase() + } + }) }); if let Some(data) = parsed_data { @@ -991,7 +982,7 @@ impl CachedEntry { let key = key.to_string(); if let Some(position) = teams.iter().position(|x| { - x.first().map(|x| x.team_id == key).unwrap_or(false) + x.first().is_some_and(|x| x.team_id == key) }) { let team = teams.remove(position); diff --git a/packages/app-lib/src/state/db.rs b/packages/app-lib/src/state/db.rs index 387d381f2..de5464c4c 100644 --- a/packages/app-lib/src/state/db.rs +++ b/packages/app-lib/src/state/db.rs @@ -1,5 +1,4 @@ use crate::state::DirectoryInfo; -use sqlx::migrate::MigrateDatabase; use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, }; @@ -20,14 +19,11 @@ pub(crate) async fn connect() -> crate::Result> { let uri = format!("sqlite:{}", settings_dir.join("app.db").display()); - if !Sqlite::database_exists(&uri).await? { - Sqlite::create_database(&uri).await?; - } - let conn_options = SqliteConnectOptions::from_str(&uri)? .busy_timeout(Duration::from_secs(30)) .journal_mode(SqliteJournalMode::Wal) - .optimize_on_close(true, None); + .optimize_on_close(true, None) + .create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(100) @@ -36,5 +32,33 @@ pub(crate) async fn connect() -> crate::Result> { sqlx::migrate!().run(&pool).await?; + if let Err(err) = stale_data_cleanup(&pool).await { + tracing::warn!( + "Failed to clean up stale data from state database: {err}" + ); + } + Ok(pool) } + +/// Cleans up data from the database that is no longer referenced, but must be +/// kept around for a little while to allow users to recover from accidental +/// deletions. +async fn stale_data_cleanup(pool: &Pool) -> crate::Result<()> { + let mut tx = pool.begin().await?; + + sqlx::query!( + "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)" + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} diff --git a/packages/app-lib/src/state/dirs.rs b/packages/app-lib/src/state/dirs.rs index 83a822f6e..21f62eb78 100644 --- a/packages/app-lib/src/state/dirs.rs +++ b/packages/app-lib/src/state/dirs.rs @@ -42,9 +42,8 @@ impl DirectoryInfo { )) })?; - let config_dir = config_dir - .map(PathBuf::from) - .unwrap_or_else(|| settings_dir.clone()); + let config_dir = + config_dir.map_or_else(|| settings_dir.clone(), PathBuf::from); Ok(Self { settings_dir, @@ -193,8 +192,7 @@ impl DirectoryInfo { let move_dir = settings .custom_dir .as_ref() - .map(PathBuf::from) - .unwrap_or_else(|| app_dir.clone()); + .map_or_else(|| app_dir.clone(), PathBuf::from); async fn is_dir_writeable( new_config_dir: &Path, @@ -220,7 +218,7 @@ impl DirectoryInfo { let disks = sysinfo::Disks::new_with_refreshed_list(); - for disk in disks.iter() { + for disk in &disks { if path.starts_with(disk.mount_point()) { return Ok(Some(disk.available_space())); } diff --git a/packages/app-lib/src/state/friends.rs b/packages/app-lib/src/state/friends.rs index 831b31551..008660d9f 100644 --- a/packages/app-lib/src/state/friends.rs +++ b/packages/app-lib/src/state/friends.rs @@ -174,7 +174,7 @@ impl FriendsSocket { ServerToClientMessage::FriendRequest { from } => { let _ = emit_friend(FriendPayload::FriendRequest { from }).await; } - ServerToClientMessage::FriendRequestRejected { .. } => todo!(), + ServerToClientMessage::FriendRequestRejected { .. } => {}, // TODO ServerToClientMessage::FriendSocketListening { .. } => {}, // TODO ServerToClientMessage::FriendSocketStoppedListening { .. } => {}, // TODO diff --git a/packages/app-lib/src/state/java_globals.rs b/packages/app-lib/src/state/java_globals.rs index e1fd2a079..64d449e19 100644 --- a/packages/app-lib/src/state/java_globals.rs +++ b/packages/app-lib/src/state/java_globals.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone)] pub struct JavaVersion { - pub major_version: u32, + pub parsed_version: u32, pub version: String, pub architecture: String, pub path: String, @@ -30,7 +30,7 @@ impl JavaVersion { .await?; Ok(res.map(|x| JavaVersion { - major_version, + parsed_version: major_version, version: x.full_version, architecture: x.architecture, path: x.path, @@ -52,7 +52,7 @@ impl JavaVersion { acc.insert( x.major_version as u32, JavaVersion { - major_version: x.major_version as u32, + parsed_version: x.major_version as u32, version: x.full_version, architecture: x.architecture, path: x.path, @@ -70,7 +70,7 @@ impl JavaVersion { &self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, ) -> crate::Result<()> { - let major_version = self.major_version as i32; + let major_version = self.parsed_version as i32; sqlx::query!( " diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index 0fcc01ddc..7a04defde 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -19,6 +19,8 @@ use std::path::PathBuf; use tokio::sync::Semaphore; use uuid::Uuid; +use super::MinecraftProfile; + pub async fn migrate_legacy_data<'a, E>(exec: E) -> crate::Result<()> where E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy, @@ -29,9 +31,7 @@ where return Ok(()); }; - let old_launcher_root = if let Some(dir) = default_settings_dir() { - dir - } else { + let Some(old_launcher_root) = default_settings_dir() else { return Ok(()); }; let old_launcher_root_str = old_launcher_root.to_string_lossy().to_string(); @@ -85,7 +85,7 @@ where settings.prev_custom_dir = Some(old_launcher_root_str.clone()); for (_, legacy_version) in legacy_settings.java_globals.0 { - if let Ok(Some(java_version)) = + if let Ok(java_version) = check_jre(PathBuf::from(legacy_version.path)).await { java_version.upsert(exec).await?; @@ -119,13 +119,16 @@ where .await { let minecraft_users_len = minecraft_auth.users.len(); - for (uuid, credential) in minecraft_auth.users { + for (uuid, legacy_credentials) in minecraft_auth.users { Credentials { - id: credential.id, - username: credential.username, - access_token: credential.access_token, - refresh_token: credential.refresh_token, - expires: credential.expires, + offline_profile: MinecraftProfile { + id: legacy_credentials.id, + name: legacy_credentials.username, + ..MinecraftProfile::default() + }, + access_token: legacy_credentials.access_token, + refresh_token: legacy_credentials.refresh_token, + expires: legacy_credentials.expires, active: minecraft_auth.default_user == Some(uuid) || minecraft_users_len == 1, } @@ -177,12 +180,10 @@ where let profile_path = entry.path().join("profile.json"); - let profile = if let Ok(profile) = + let Ok(profile) = read_json::(&profile_path, &io_semaphore) .await - { - profile - } else { + else { continue; }; @@ -285,7 +286,7 @@ where TeamMember { team_id: x.team_id, - user: user.clone(), + user, is_owner: x.role == "Owner", role: x.role, ordering: x.ordering, diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index 96b3044ba..febfd67da 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -5,25 +5,38 @@ use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD}; use chrono::{DateTime, Duration, TimeZone, Utc}; use dashmap::DashMap; use futures::TryStreamExt; +use heck::ToTitleCase; use p256::ecdsa::signature::Signer; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; use rand::Rng; use rand::rngs::OsRng; -use reqwest::Response; use reqwest::header::HeaderMap; +use reqwest::{Response, StatusCode}; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::json; use sha2::Digest; +use std::borrow::Cow; use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::future::Future; +use std::hash::{BuildHasherDefault, DefaultHasher}; +use std::io; +use std::ops::Deref; +use std::sync::Arc; +use std::time::Instant; +use tokio::runtime::{Handle, RuntimeFlavor}; +use tokio::sync::Mutex; +use tokio::task; +use url::Url; use uuid::Uuid; #[derive(Debug, Clone, Copy)] pub enum MinecraftAuthStep { GetDeviceToken, - SisuAuthenicate, + SisuAuthenticate, GetOAuthToken, RefreshOAuthToken, SisuAuthorize, @@ -53,7 +66,7 @@ pub enum MinecraftAuthenticationError { raw: String, #[source] source: serde_json::Error, - status_code: reqwest::StatusCode, + status_code: StatusCode, }, #[error("Request failed during step {step:?}: {source}")] Request { @@ -172,36 +185,87 @@ pub async fn login_finish( minecraft_entitlements(&minecraft_token.access_token).await?; let mut credentials = Credentials { - id: Uuid::default(), - username: String::default(), + offline_profile: MinecraftProfile::default(), access_token: minecraft_token.access_token, refresh_token: oauth_token.value.refresh_token, expires: oauth_token.date + Duration::seconds(oauth_token.value.expires_in as i64), active: true, }; - credentials.get_profile().await?; + + // During login, we need to fetch the online profile at least once to get the + // player UUID and name to use for the offline profile, in order for that offline + // profile to make sense. It's also important to modify the returned credentials + // object, as otherwise continued usage of it will skip the profile cache due to + // the dummy UUID + let online_profile = credentials + .online_profile() + .await + .ok_or(io::Error::other("Failed to fetch player profile"))?; + credentials.offline_profile = MinecraftProfile { + id: online_profile.id, + name: online_profile.name.clone(), + ..credentials.offline_profile + }; credentials.upsert(exec).await?; Ok(credentials) } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Deserialize, Debug)] pub struct Credentials { - pub id: Uuid, - pub username: String, + /// The offline profile of the user these credentials are for. + /// + /// Such a profile can only be relied upon to have a proper player UUID, which is + /// never changed. A potentially stale username may be available, but no other data + /// such as skins or capes is available. + #[serde(rename = "profile")] + pub offline_profile: MinecraftProfile, pub access_token: String, pub refresh_token: String, pub expires: DateTime, pub active: bool, } +/// An entry in the player profile cache, keyed by player UUID. +pub(super) enum ProfileCacheEntry { + /// A cached profile that is valid, even though it may be stale. + Hit(Arc), + /// A negative profile fetch result due to an authentication error, + /// from which we're recovering by holding off from repeatedly + /// attempting to fetch the profile until the token is refreshed + /// or some time has passed. + AuthErrorBackoff { + likely_expired_token: String, + last_attempt: Instant, + }, +} + +/// A thread-safe cache of online profiles, used to avoid fetching the +/// same profile multiple times as long as they don't get too stale. +/// +/// The cache has to be static because credential objects are short lived +/// and disposable, and in the future several threads may be interested in +/// profile data. +pub(super) static PROFILE_CACHE: Mutex< + HashMap>, +> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new())); + impl Credentials { + /// Refreshes the authentication tokens for this user if they are expired, or + /// very close to expiration. async fn refresh( &mut self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { + // Use a margin of 5 minutes to give e.g. Minecraft and potentially + // other operations that depend on a fresh token 5 minutes to complete + // from now, and deal with some classes of clock skew + if self.expires > Utc::now() + Duration::minutes(5) { + return Ok(()); + } + let oauth_token = oauth_refresh(&self.refresh_token).await?; let (pair, current_date, _) = DeviceTokenPair::refresh_and_get_device_token( @@ -235,22 +299,118 @@ impl Credentials { self.expires = oauth_token.date + Duration::seconds(oauth_token.value.expires_in as i64); - self.get_profile().await?; - self.upsert(exec).await?; Ok(()) } - async fn get_profile(&mut self) -> crate::Result<()> { - let profile = minecraft_profile(&self.access_token).await?; + #[tracing::instrument(skip(self))] + pub async fn online_profile(&self) -> Option> { + let mut profile_cache = PROFILE_CACHE.lock().await; - self.id = profile.id.unwrap_or_default(); - self.username = profile.name; + loop { + match profile_cache.entry(self.offline_profile.id) { + Entry::Occupied(entry) => { + match entry.get() { + ProfileCacheEntry::Hit(profile) + if profile.is_fresh() => + { + return Some(Arc::clone(profile)); + } + ProfileCacheEntry::Hit(_) => { + // The profile is stale, so remove it and try again + entry.remove(); + continue; + } + // Auth errors must be handled with a backoff strategy because it + // has been experimentally found that Mojang quickly rate limits + // the profile data endpoint on repeated attempts with bad auth + ProfileCacheEntry::AuthErrorBackoff { + likely_expired_token, + last_attempt, + } if &self.access_token != likely_expired_token + || Instant::now() + .saturating_duration_since(*last_attempt) + > std::time::Duration::from_secs(60) => + { + entry.remove(); + continue; + } + ProfileCacheEntry::AuthErrorBackoff { .. } => { + return None; + } + } + } + Entry::Vacant(entry) => { + match minecraft_profile(&self.access_token).await { + Ok(profile) => { + let profile = Arc::new(profile); + let cache_entry = + ProfileCacheEntry::Hit(Arc::clone(&profile)); - Ok(()) + // When fetching a profile for the first time, the player UUID may + // be unknown (i.e., set to a dummy value), so make sure we don't + // cache it in the wrong place + if entry.key() != &profile.id { + profile_cache.insert(profile.id, cache_entry); + } else { + entry.insert(cache_entry); + } + + return Some(profile); + } + Err( + err @ MinecraftAuthenticationError::DeserializeResponse { + status_code: StatusCode::UNAUTHORIZED, + .. + }, + ) => { + tracing::warn!( + "Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}", + self.offline_profile.id + ); + + // We have to assume the player UUID key we have is correct here, which + // should always be the case assuming a non-adversarial server. In any + // case, any cache poisoning is inconsequential due to the entry expiration + // and the fact that we use at most one single dummy UUID + entry.insert(ProfileCacheEntry::AuthErrorBackoff { + likely_expired_token: self.access_token.clone(), + last_attempt: Instant::now(), + }); + + return None; + } + Err(err) => { + tracing::warn!( + "Failed to fetch online profile for UUID {}: {err}", + self.offline_profile.id + ); + + return None; + } + } + } + } + } } + /// Attempts to fetch the online profile for this user if possible, and if that fails + /// falls back to the known offline profile data. + /// + /// See also the [`online_profile`](Self::online_profile) method. + pub async fn maybe_online_profile( + &self, + ) -> MaybeOnlineMinecraftProfile<'_> { + let online_profile = self.online_profile().await; + online_profile.map_or_else( + || MaybeOnlineMinecraftProfile::Offline(&self.offline_profile), + MaybeOnlineMinecraftProfile::Online, + ) + } + + /// Like [`get_active`](Self::get_active), but enforces credentials to be + /// successfully refreshed unless the network is unreachable or times out. #[tracing::instrument] pub async fn get_default_credential( exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, @@ -258,37 +418,35 @@ impl Credentials { let credentials = Self::get_active(exec).await?; if let Some(mut creds) = credentials { - if creds.expires < Utc::now() { - let res = creds.refresh(exec).await; + let res = creds.refresh(exec).await; - match res { - Ok(_) => Ok(Some(creds)), - Err(err) => { - if let ErrorKind::MinecraftAuthenticationError( - MinecraftAuthenticationError::Request { - ref source, - .. - }, - ) = *err.raw - { - if source.is_connect() || source.is_timeout() { - return Ok(Some(creds)); - } + match res { + Ok(_) => Ok(Some(creds)), + Err(err) => { + if let ErrorKind::MinecraftAuthenticationError( + MinecraftAuthenticationError::Request { + ref source, + .. + }, + ) = *err.raw + { + if source.is_connect() || source.is_timeout() { + return Ok(Some(creds)); } - - Err(err) } + + Err(err) } - } else { - Ok(Some(creds)) } } else { Ok(None) } } + /// Fetches the currently selected credentials from the database, attempting + /// to refresh them if they are expired. pub async fn get_active( - exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { let res = sqlx::query!( " @@ -301,21 +459,31 @@ impl Credentials { .fetch_optional(exec) .await?; - Ok(res.map(|x| Self { - id: Uuid::parse_str(&x.uuid).unwrap_or_default(), - username: x.username, - access_token: x.access_token, - refresh_token: x.refresh_token, - expires: Utc - .timestamp_opt(x.expires, 0) - .single() - .unwrap_or_else(Utc::now), - active: x.active == 1, - })) + Ok(match res { + Some(x) => { + let mut credentials = Self { + offline_profile: MinecraftProfile { + id: Uuid::parse_str(&x.uuid).unwrap_or_default(), + name: x.username, + ..MinecraftProfile::default() + }, + access_token: x.access_token, + refresh_token: x.refresh_token, + expires: Utc + .timestamp_opt(x.expires, 0) + .single() + .unwrap_or_else(Utc::now), + active: x.active == 1, + }; + credentials.refresh(exec).await.ok(); + Some(credentials) + } + None => None, + }) } pub async fn get_all( - exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>, + exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result> { let res = sqlx::query!( " @@ -327,23 +495,27 @@ impl Credentials { .fetch(exec) .try_fold(DashMap::new(), |acc, x| { let uuid = Uuid::parse_str(&x.uuid).unwrap_or_default(); - - acc.insert( - uuid, - Self { + let mut credentials = Self { + offline_profile: MinecraftProfile { id: uuid, - username: x.username, - access_token: x.access_token, - refresh_token: x.refresh_token, - expires: Utc - .timestamp_opt(x.expires, 0) - .single() - .unwrap_or_else(Utc::now), - active: x.active == 1, + name: x.username, + ..MinecraftProfile::default() }, - ); + access_token: x.access_token, + refresh_token: x.refresh_token, + expires: Utc + .timestamp_opt(x.expires, 0) + .single() + .unwrap_or_else(Utc::now), + active: x.active == 1, + }; - async move { Ok(acc) } + async move { + credentials.refresh(exec).await.ok(); + acc.insert(uuid, credentials); + + Ok(acc) + } }) .await?; @@ -354,8 +526,9 @@ impl Credentials { &self, exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy, ) -> crate::Result<()> { + let profile = self.maybe_online_profile().await; let expires = self.expires.timestamp(); - let uuid = self.id.as_hyphenated().to_string(); + let uuid = profile.id.as_hyphenated().to_string(); if self.active { sqlx::query!( @@ -381,7 +554,7 @@ impl Credentials { ", uuid, self.active, - self.username, + profile.name, self.access_token, self.refresh_token, expires, @@ -411,6 +584,46 @@ impl Credentials { } } +impl Serialize for Credentials { + fn serialize( + &self, + serializer: S, + ) -> Result { + // Opportunistically hydrate the profile with its online data if possible for frontend + // consumption, transparently handling all the possible Tokio runtime states the current + // thread may be in the most efficient way + let profile = match Handle::try_current().ok() { + Some(runtime) + if runtime.runtime_flavor() == RuntimeFlavor::CurrentThread => + { + runtime.block_on(self.maybe_online_profile()) + } + Some(runtime) => task::block_in_place(|| { + runtime.block_on(self.maybe_online_profile()) + }), + None => tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_or_else( + |_| { + MaybeOnlineMinecraftProfile::Offline( + &self.offline_profile, + ) + }, + |runtime| runtime.block_on(self.maybe_online_profile()), + ), + }; + + let mut ser = serializer.serialize_struct("Credentials", 5)?; + ser.serialize_field("profile", &*profile)?; + ser.serialize_field("access_token", &self.access_token)?; + ser.serialize_field("refresh_token", &self.refresh_token)?; + ser.serialize_field("expires", &self.expires)?; + ser.serialize_field("active", &self.active)?; + ser.end() + } +} + pub struct DeviceTokenPair { pub token: DeviceToken, pub key: DeviceTokenKey, @@ -639,7 +852,7 @@ async fn sisu_authenticate( "TitleId": "1794566092", }), key, - MinecraftAuthStep::SisuAuthenicate, + MinecraftAuthStep::SisuAuthenticate, current_date, ) .await?; @@ -911,13 +1124,197 @@ async fn minecraft_token( }) } -#[derive(Deserialize)] -struct MinecraftProfile { - pub id: Option, - pub name: String, +#[derive( + sqlx::Type, Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq, +)] +#[serde(rename_all = "UPPERCASE")] +#[sqlx(rename_all = "UPPERCASE")] +pub enum MinecraftSkinVariant { + /// The classic player model, with arms that are 4 pixels wide. + Classic, + /// The slim player model, with arms that are 3 pixels wide. + Slim, + /// The player model is unknown. + #[serde(other)] + Unknown, // Defensive handling of unexpected Mojang API return values to + // prevent breaking the entire profile parsing } -#[tracing::instrument] +#[derive(Deserialize, Serialize, Debug, Copy, Clone, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum MinecraftCharacterExpressionState { + /// This expression is selected for being displayed ingame. + /// + /// At the moment, at most one expression can be selected at a time. + Active, + /// This expression is not selected for being displayed ingame. + Inactive, + /// The expression selection status is unknown. + #[serde(other)] + Unknown, // Defensive handling of unexpected Mojang API return values to + // prevent breaking the entire profile parsing +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct MinecraftSkin { + /// The UUID of this skin object. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this UUID + /// changes every time the player changes their skin, even if the skin + /// texture is the same as before. + pub id: Uuid, + /// The selection state of the skin. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this + /// is always `ACTIVE`, as only a single skin representing the current + /// skin is returned. + pub state: MinecraftCharacterExpressionState, + /// The URL to the skin texture. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint the file + /// name for this URL is a hash of the skin texture, so that different + /// players using the same skin texture will share a texture URL. + pub url: Arc, + /// A hash of the skin texture. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this + /// is always set and the same as the file name of the skin texture URL. + #[serde( + default, // Defensive handling of unexpected Mojang API return values to + // prevent breaking the entire profile parsing + rename = "textureKey" + )] + pub texture_key: Option>, + /// The player model variant this skin is for. + pub variant: MinecraftSkinVariant, + /// User-friendly name for the skin. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint this is + /// only set if the player has not set a custom skin, and this skin object + /// is therefore the default skin for the player's UUID. + #[serde( + default, + rename = "alias", + deserialize_with = "normalize_skin_alias_case" + )] + pub name: Option, +} + +impl MinecraftSkin { + /// Robustly computes the texture key for this skin, falling back to its + /// URL file name and finally to the skin UUID when necessary. + pub fn texture_key(&self) -> Arc { + self.texture_key.as_ref().cloned().unwrap_or_else(|| { + self.url + .path_segments() + .and_then(|mut path_segments| { + path_segments.next_back().map(String::from) + }) + .unwrap_or_else(|| self.id.as_simple().to_string()) + .into() + }) + } +} + +fn normalize_skin_alias_case<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + // Skin aliases have been spotted to be returned in all caps, so make sure + // they are normalized to a prettier title case + Ok(>>::deserialize(deserializer)? + .map(|alias| alias.to_title_case())) +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct MinecraftCape { + /// The UUID of the cape. + pub id: Uuid, + /// The selection state of the cape. + pub state: MinecraftCharacterExpressionState, + /// The URL to the cape texture. + pub url: Arc, + /// The user-friendly name for the cape. + #[serde(rename = "alias")] + pub name: Arc, +} + +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +pub struct MinecraftProfile { + /// The UUID of the player. + #[serde(default)] + pub id: Uuid, + /// The username of the player. + pub name: String, + /// The skins the player is known to have. + /// + /// As of 2025-04-08, in the production Mojang profile endpoint every + /// player has a single skin. + pub skins: Vec, + /// The capes the player is known to have. + pub capes: Vec, + /// The instant when the profile was fetched. See also [Self::is_fresh]. + #[serde(skip)] + pub fetch_time: Option, +} + +impl MinecraftProfile { + /// Checks whether the profile data is fresh (i.e., highly likely to be + /// up-to-date because it was fetched recently) or stale. If it is not + /// known when this profile data has been fetched from Mojang servers (i.e., + /// `fetch_time` is `None`), the profile is considered stale. + /// + /// This can be used to determine if the profile data should be fetched again + /// from the Mojang API: the vanilla launcher was seen refreshing profile + /// data every 60 seconds when re-entering the skin selection screen, and + /// external applications may change this data at any time. + fn is_fresh(&self) -> bool { + self.fetch_time.is_some_and(|last_profile_fetch_time| { + Instant::now().saturating_duration_since(last_profile_fetch_time) + < std::time::Duration::from_secs(60) + }) + } + + /// Returns the currently selected skin for this profile. + pub fn current_skin(&self) -> crate::Result<&MinecraftSkin> { + Ok(self + .skins + .iter() + .find(|skin| { + skin.state == MinecraftCharacterExpressionState::Active + }) + // There should always be one active skin, even when the player uses their default skin + .ok_or_else(|| { + ErrorKind::OtherError("No active skin found".into()) + })?) + } + + /// Returns the currently selected cape for this profile. + pub fn current_cape(&self) -> Option<&MinecraftCape> { + self.capes.iter().find(|cape| { + cape.state == MinecraftCharacterExpressionState::Active + }) + } +} + +pub enum MaybeOnlineMinecraftProfile<'profile> { + /// An online profile, fetched from the Mojang API. + Online(Arc), + /// An offline profile, which has not been fetched from the Mojang API. + Offline(&'profile MinecraftProfile), +} + +impl Deref for MaybeOnlineMinecraftProfile<'_> { + type Target = MinecraftProfile; + + fn deref(&self) -> &Self::Target { + match self { + Self::Online(profile) => profile, + Self::Offline(profile) => profile, + } + } +} + +#[tracing::instrument(skip(token))] async fn minecraft_profile( token: &str, ) -> Result { @@ -926,6 +1323,9 @@ async fn minecraft_profile( .get("https://api.minecraftservices.com/minecraft/profile") .header("Accept", "application/json") .bearer_auth(token) + // Profiles may be refreshed periodically in response to user actions, + // so we want each refresh to be fast + .timeout(std::time::Duration::from_secs(10)) .send() }) .await @@ -942,14 +1342,23 @@ async fn minecraft_profile( } })?; - serde_json::from_str(&text).map_err(|source| { - MinecraftAuthenticationError::DeserializeResponse { - source, - raw: text, - step: MinecraftAuthStep::MinecraftProfile, - status_code: status, - } - }) + let mut profile = + serde_json::from_str::(&text).map_err(|source| { + MinecraftAuthenticationError::DeserializeResponse { + source, + raw: text, + step: MinecraftAuthStep::MinecraftProfile, + status_code: status, + } + })?; + profile.fetch_time = Some(Instant::now()); + + tracing::debug!( + "Successfully fetched Minecraft profile for {}", + profile.name + ); + + Ok(profile) } #[derive(Deserialize)] @@ -967,7 +1376,7 @@ async fn minecraft_entitlements( .bearer_auth(token) .send() }) - .await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?; + .await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?; let status = res.status(); let text = res.text().await.map_err(|source| { @@ -1154,12 +1563,10 @@ fn get_date_header(headers: &HeaderMap) -> DateTime { .get(reqwest::header::DATE) .and_then(|x| x.to_str().ok()) .and_then(|x| DateTime::parse_from_rfc2822(x).ok()) - .map(|x| x.with_timezone(&Utc)) - .unwrap_or(Utc::now()) + .map_or(Utc::now(), |x| x.with_timezone(&Utc)) } #[tracing::instrument] -#[allow(clippy::format_collect)] fn generate_oauth_challenge() -> String { let mut rng = rand::thread_rng(); diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs new file mode 100644 index 000000000..a5baad20c --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mod.rs @@ -0,0 +1,180 @@ +use futures::{Stream, StreamExt, stream}; +use uuid::{Uuid, fmt::Hyphenated}; + +use super::MinecraftSkinVariant; + +pub mod mojang_api; + +/// Represents the default cape for a Minecraft player. +#[derive(Debug, Clone)] +pub struct DefaultMinecraftCape { + /// The UUID of a cape for a Minecraft player, which comes from its profile. + /// + /// This UUID may or may not be different for every player, even if they refer to the same cape. + pub id: Uuid, +} + +impl DefaultMinecraftCape { + pub async fn set( + minecraft_user_id: Uuid, + cape_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = cape_id.as_hyphenated(); + + sqlx::query!( + "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)", + minecraft_user_id, cape_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } + + pub async fn get( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + Ok(sqlx::query_as!( + Self, + "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .fetch_optional(&mut *db.acquire().await?) + .await?) + } + + pub async fn remove( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + sqlx::query!( + "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", + minecraft_user_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } +} + +/// Represents a custom skin for a Minecraft player. +#[derive(Debug, Clone)] +pub struct CustomMinecraftSkin { + /// The key for the texture skin, which is akin to a hash that identifies it. + pub texture_key: String, + /// The variant of the skin model. + pub variant: MinecraftSkinVariant, + /// The UUID of the cape that this skin uses, which should match one of the + /// cape UUIDs the player has in its profile. + /// + /// If `None`, the skin does not have an explicit cape set, and the default + /// cape for this player, if any, should be used. + pub cape_id: Option, +} + +impl CustomMinecraftSkin { + pub async fn add( + minecraft_user_id: Uuid, + texture_key: &str, + texture: &[u8], + variant: MinecraftSkinVariant, + cape_id: Option, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = cape_id.map(|id| id.hyphenated()); + + let mut transaction = db.begin().await?; + + sqlx::query!( + "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + texture_key, texture + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + minecraft_user_id, texture_key, variant, cape_id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(()) + } + + pub async fn get_many( + minecraft_user_id: Uuid, + offset: u32, + count: u32, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + Ok(stream::iter(sqlx::query!( + "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \ + FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? \ + ORDER BY rowid ASC \ + LIMIT ? OFFSET ?", + minecraft_user_id, count, offset + ) + .fetch_all(&mut *db.acquire().await?) + .await?) + .map(|row| Self { + texture_key: row.texture_key, + variant: row.variant, + cape_id: row.cape_id.map(Uuid::from), + })) + } + + pub async fn get_all( + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + // Limit ourselves to 2048 skins, so that memory usage even when storing base64 + // PNG data of a 64x64 texture with random pixels stays around ~150 MiB + Self::get_many(minecraft_user_id, 0, 2048, db).await + } + + pub async fn texture_blob( + &self, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + Ok(sqlx::query_scalar!( + "SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?", + self.texture_key + ) + .fetch_one(&mut *db.acquire().await?) + .await?) + } + + pub async fn remove( + &self, + minecraft_user_id: Uuid, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result<()> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + let cape_id = self.cape_id.map(|id| id.hyphenated()); + + sqlx::query!( + "DELETE FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?", + minecraft_user_id, self.texture_key, self.variant, cape_id + ) + .execute(&mut *db.acquire().await?) + .await?; + + Ok(()) + } +} diff --git a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs new file mode 100644 index 000000000..49b5249ed --- /dev/null +++ b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs @@ -0,0 +1,142 @@ +use std::{error::Error, sync::Arc, time::Instant}; + +use bytes::Bytes; +use futures::TryStream; +use reqwest::{Body, multipart::Part}; +use serde_json::json; +use uuid::Uuid; + +use super::MinecraftSkinVariant; +use crate::{ + ErrorKind, + data::Credentials, + state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry}, + util::fetch::REQWEST_CLIENT, +}; + +/// Provides operations for interacting with capes on a Minecraft player profile. +pub struct MinecraftCapeOperation; + +impl MinecraftCapeOperation { + pub async fn equip( + credentials: &Credentials, + cape_id: Uuid, + ) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .put("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .json(&json!({ + "capeId": cape_id.hyphenated(), + })) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +/// Provides operations for interacting with skins on a Minecraft player profile. +pub struct MinecraftSkinOperation; + +impl MinecraftSkinOperation { + pub async fn equip( + credentials: &Credentials, + texture: TextureStream, + variant: MinecraftSkinVariant, + ) -> crate::Result<()> + where + TextureStream: TryStream + Send + 'static, + TextureStream::Error: Into>, + Bytes: From, + { + let form = reqwest::multipart::Form::new() + .text( + "variant", + match variant { + MinecraftSkinVariant::Slim => "slim", + MinecraftSkinVariant::Classic => "classic", + _ => { + return Err(ErrorKind::OtherError( + "Cannot equip skin of unknown model variant".into(), + ) + .into()); + } + }, + ) + .part( + "file", + Part::stream(Body::wrap_stream(texture)) + .mime_str("image/png")? + .file_name("skin.png"), + ); + + update_profile_cache_from_response( + REQWEST_CLIENT + .post( + "https://api.minecraftservices.com/minecraft/profile/skins", + ) + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .multipart(form) + .send() + .await + .and_then(|response| response.error_for_status())?, + ) + .await; + + Ok(()) + } + + pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { + update_profile_cache_from_response( + REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") + .header("Accept", "application/json") + .bearer_auth(&credentials.access_token) + .send() + .await + .and_then(|response| response.error_for_status())? + ) + .await; + + Ok(()) + } +} + +async fn update_profile_cache_from_response(response: reqwest::Response) { + let Some(mut profile) = response.json::().await.ok() + else { + tracing::warn!( + "Failed to parse player profile from skin or cape operation response, not updating profile cache" + ); + return; + }; + + profile.fetch_time = Some(Instant::now()); + + PROFILE_CACHE + .lock() + .await + .insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile))); +} diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index ec6d5426e..ab7a5e3e9 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -28,6 +28,8 @@ pub use self::discord::*; mod minecraft_auth; pub use self::minecraft_auth::*; +pub mod minecraft_skins; + mod cache; pub use self::cache::*; diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index 324fcda4d..a4727468c 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -8,12 +8,14 @@ use quick_xml::Reader; use quick_xml::events::Event; use serde::Deserialize; use serde::Serialize; +use std::fmt::Debug; use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::ExitStatus; +use tempfile::TempDir; use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::{Child, Command}; +use tokio::process::{Child, ChildStdin, Command}; use uuid::Uuid; const LAUNCHER_LOG_PATH: &str = "launcher_log.txt"; @@ -35,6 +37,7 @@ impl ProcessManager { } } + #[allow(clippy::too_many_arguments)] pub async fn insert_new_process( &self, profile_path: &str, @@ -42,24 +45,42 @@ impl ProcessManager { post_exit_command: Option, logs_folder: PathBuf, xml_logging: bool, + main_class_keep_alive: TempDir, + post_process_init: impl AsyncFnOnce( + &ProcessMetadata, + &mut ChildStdin, + ) -> crate::Result<()>, ) -> crate::Result { mc_command.stdout(std::process::Stdio::piped()); mc_command.stderr(std::process::Stdio::piped()); + mc_command.stdin(std::process::Stdio::piped()); let mut mc_proc = mc_command.spawn().map_err(IOError::from)?; let stdout = mc_proc.stdout.take(); let stderr = mc_proc.stderr.take(); - let process = Process { + let mut process = Process { metadata: ProcessMetadata { uuid: Uuid::new_v4(), start_time: Utc::now(), profile_path: profile_path.to_string(), }, child: mc_proc, + _main_class_keep_alive: main_class_keep_alive, }; + if let Err(e) = post_process_init( + &process.metadata, + &mut process.child.stdin.as_mut().unwrap(), + ) + .await + { + tracing::error!("Failed to run post-process init: {e}"); + let _ = process.child.kill().await; + return Err(e); + } + let metadata = process.metadata.clone(); if !logs_folder.exists() { @@ -193,6 +214,7 @@ pub struct ProcessMetadata { struct Process { metadata: ProcessMetadata, child: Child, + _main_class_keep_alive: TempDir, } #[derive(Debug, Default)] @@ -692,7 +714,7 @@ impl Process { let mut cmd = hook.split(' '); if let Some(command) = cmd.next() { let mut command = Command::new(command); - command.args(cmd.collect::>()).current_dir( + command.args(cmd).current_dir( profile::get_full_path(&profile_path).await?, ); command.spawn().map_err(IOError::from)?; diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs index 2aa09ece1..7cf70a285 100644 --- a/packages/app-lib/src/state/profiles.rs +++ b/packages/app-lib/src/state/profiles.rs @@ -1022,8 +1022,10 @@ impl Profile { file.hash, file.project_type .filter(|x| *x != ProjectType::Mod) - .map(|x| x.get_loaders().join("+")) - .unwrap_or_else(|| profile.loader.as_str().to_string()), + .map_or_else( + || profile.loader.as_str().to_string(), + |x| x.get_loaders().join("+") + ), profile.game_version ) } diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs index 89d7bc044..2615e150e 100644 --- a/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs @@ -13,6 +13,7 @@ pub struct Settings { pub theme: Theme, pub default_page: DefaultPage, pub collapsed_navigation: bool, + pub hide_nametag_skins_page: bool, pub advanced_rendering: bool, pub native_decorations: bool, pub toggle_sidebar: bool, @@ -56,7 +57,7 @@ impl Settings { " SELECT max_concurrent_writes, max_concurrent_downloads, - theme, default_page, collapsed_navigation, advanced_rendering, native_decorations, + theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations, discord_rpc, developer_mode, telemetry, personalized_ads, onboarded, json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars, @@ -75,6 +76,7 @@ impl Settings { theme: Theme::from_string(&res.theme), default_page: DefaultPage::from_string(&res.default_page), collapsed_navigation: res.collapsed_navigation == 1, + hide_nametag_skins_page: res.hide_nametag_skins_page == 1, advanced_rendering: res.advanced_rendering == 1, native_decorations: res.native_decorations == 1, toggle_sidebar: res.toggle_sidebar == 1, @@ -167,7 +169,8 @@ impl Settings { migrated = $25, toggle_sidebar = $26, - feature_flags = $27 + feature_flags = $27, + hide_nametag_skins_page = $28 ", max_concurrent_writes, max_concurrent_downloads, @@ -195,7 +198,8 @@ impl Settings { self.prev_custom_dir, self.migrated, self.toggle_sidebar, - feature_flags + feature_flags, + self.hide_nametag_skins_page ) .execute(exec) .await?; @@ -247,9 +251,13 @@ pub struct WindowSize(pub u16, pub u16); /// Game initialization hooks #[derive(Serialize, Deserialize, Debug, Clone)] +#[serde_with::serde_as] pub struct Hooks { + #[serde_as(as = "serde_with::NoneAsEmptyString")] pub pre_launch: Option, + #[serde_as(as = "serde_with::NoneAsEmptyString")] pub wrapper: Option, + #[serde_as(as = "serde_with::NoneAsEmptyString")] pub post_exit: Option, } diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs index 2323ec7d9..fb62386aa 100644 --- a/packages/app-lib/src/util/fetch.rs +++ b/packages/app-lib/src/util/fetch.rs @@ -80,10 +80,9 @@ pub async fn fetch_advanced( ) -> crate::Result { let _permit = semaphore.0.acquire().await?; - let creds = if !header + let creds = if header .as_ref() - .map(|x| &*x.0.to_lowercase() == "authorization") - .unwrap_or(false) + .is_none_or(|x| &*x.0.to_lowercase() != "authorization") && (url.starts_with("https://cdn.modrinth.com") || url.starts_with(MODRINTH_API_URL) || url.starts_with(MODRINTH_API_URL_V3)) diff --git a/packages/app-lib/src/util/io.rs b/packages/app-lib/src/util/io.rs index 9d82c24d3..7321f3d94 100644 --- a/packages/app-lib/src/util/io.rs +++ b/packages/app-lib/src/util/io.rs @@ -2,7 +2,6 @@ // A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error. use std::{io::Write, path::Path}; - use tempfile::NamedTempFile; use tokio::task::spawn_blocking; @@ -35,7 +34,6 @@ impl IOError { } } -// dunce canonicalize pub fn canonicalize( path: impl AsRef, ) -> Result { @@ -46,7 +44,6 @@ pub fn canonicalize( }) } -// read_dir pub async fn read_dir( path: impl AsRef, ) -> Result { @@ -59,7 +56,6 @@ pub async fn read_dir( }) } -// create_dir pub async fn create_dir( path: impl AsRef, ) -> Result<(), IOError> { @@ -72,7 +68,6 @@ pub async fn create_dir( }) } -// create_dir_all pub async fn create_dir_all( path: impl AsRef, ) -> Result<(), IOError> { @@ -85,7 +80,6 @@ pub async fn create_dir_all( }) } -// remove_dir_all pub async fn remove_dir_all( path: impl AsRef, ) -> Result<(), IOError> { @@ -98,20 +92,37 @@ pub async fn remove_dir_all( }) } -// read_to_string -pub async fn read_to_string( +/// Reads a text file to a string, automatically detecting its encoding and +/// substituting any invalid characters with the Unicode replacement character. +/// +/// This function is best suited for reading Minecraft instance files, whose +/// encoding may vary depending on the platform, launchers, client versions +/// (older Minecraft versions tended to rely on the system's default codepage +/// more on Windows platforms), and mods used, while not being highly sensitive +/// to occasional occurrences of mojibake or character replacements. +pub async fn read_any_encoding_to_string( path: impl AsRef, -) -> Result { +) -> Result<(String, &'static encoding_rs::Encoding), IOError> { let path = path.as_ref(); - tokio::fs::read_to_string(path) - .await - .map_err(|e| IOError::IOPathError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let file_bytes = + tokio::fs::read(path) + .await + .map_err(|e| IOError::IOPathError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + + let file_encoding = { + let mut encoding_detector = chardetng::EncodingDetector::new(); + encoding_detector.feed(&file_bytes, true); + encoding_detector.guess(None, true) + }; + + let (file_string, actual_file_encoding, _) = + file_encoding.decode(&file_bytes); + Ok((file_string.to_string(), actual_file_encoding)) } -// read pub async fn read( path: impl AsRef, ) -> Result, IOError> { @@ -124,7 +135,6 @@ pub async fn read( }) } -// write pub async fn write( path: impl AsRef, data: impl AsRef<[u8]>, @@ -186,7 +196,6 @@ pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result { } } -// rename pub async fn rename_or_move( from: impl AsRef, to: impl AsRef, @@ -228,7 +237,6 @@ async fn move_recursive(from: &Path, to: &Path) -> Result<(), IOError> { Ok(()) } -// copy pub async fn copy( from: impl AsRef, to: impl AsRef, @@ -243,7 +251,6 @@ pub async fn copy( }) } -// remove file pub async fn remove_file( path: impl AsRef, ) -> Result<(), IOError> { @@ -256,7 +263,6 @@ pub async fn remove_file( }) } -// open file pub async fn open_file( path: impl AsRef, ) -> Result { @@ -269,7 +275,6 @@ pub async fn open_file( }) } -// remove dir pub async fn remove_dir( path: impl AsRef, ) -> Result<(), IOError> { @@ -282,7 +287,6 @@ pub async fn remove_dir( }) } -// metadata pub async fn metadata( path: impl AsRef, ) -> Result { @@ -294,3 +298,44 @@ pub async fn metadata( path: path.to_string_lossy().to_string(), }) } + +/// Gets a resource file from the executable. Returns `theseus::Result<(TempDir, PathBuf)>`. +#[macro_export] +macro_rules! get_resource_file { + (directory: $relative_dir:expr, file: $file_name:expr) => { + 'get_resource_file: { + let dir = match tempfile::tempdir() { + Ok(dir) => dir, + Err(e) => { + break 'get_resource_file $crate::Result::Err( + $crate::util::io::IOError::from(e).into(), + ); + } + }; + let path = dir.path().join($file_name); + if let Err(e) = $crate::util::io::write( + &path, + include_bytes!(concat!($relative_dir, "/", $file_name)), + ) + .await + { + break 'get_resource_file $crate::Result::Err(e.into()); + } + let path = match $crate::util::io::canonicalize(path) { + Ok(path) => path, + Err(e) => { + break 'get_resource_file $crate::Result::Err(e.into()); + } + }; + $crate::Result::Ok((dir, path)) + } + }; + + ($relative_dir:literal / $file_name:literal) => { + get_resource_file!(directory: $relative_dir, file: $file_name) + }; + + (env $dir_env_name:literal / $file_name:literal) => { + get_resource_file!(directory: env!($dir_env_name), file: $file_name) + }; +} diff --git a/packages/app-lib/src/util/jre.rs b/packages/app-lib/src/util/jre.rs index e32c93649..ed197a32b 100644 --- a/packages/app-lib/src/util/jre.rs +++ b/packages/app-lib/src/util/jre.rs @@ -7,7 +7,7 @@ use std::process::Command; use std::{collections::HashSet, path::Path}; use tokio::task::JoinError; -use crate::State; +use crate::{State, get_resource_file}; #[cfg(target_os = "windows")] use winreg::{ RegKey, @@ -183,7 +183,6 @@ pub async fn get_all_jre() -> Result, JREError> { // Gets all JREs from the PATH env variable #[tracing::instrument] - async fn get_all_autoinstalled_jre_path() -> Result, JREError> { Box::pin(async move { @@ -227,13 +226,11 @@ async fn get_all_jre_path() -> HashSet { paths.unwrap_or_else(|_| HashSet::new()) } -#[cfg(target_os = "windows")] -#[allow(dead_code)] -pub const JAVA_BIN: &str = "javaw.exe"; - -#[cfg(not(target_os = "windows"))] -#[allow(dead_code)] -pub const JAVA_BIN: &str = "java"; +pub const JAVA_BIN: &str = if cfg!(target_os = "windows") { + "javaw.exe" +} else { + "java" +}; // For each example filepath in 'paths', perform check_java_at_filepath, checking each one concurrently // and returning a JavaVersion for every valid path that points to a java bin @@ -241,54 +238,49 @@ pub const JAVA_BIN: &str = "java"; pub async fn check_java_at_filepaths( paths: HashSet, ) -> HashSet { - let jres = stream::iter(paths.into_iter()) + stream::iter(paths.into_iter()) .map(|p: PathBuf| { tokio::task::spawn(async move { check_java_at_filepath(&p).await }) }) .buffer_unordered(64) - .collect::>() - .await; - - jres.into_iter().flat_map(|x| x.ok()).flatten().collect() + .filter_map(async |x| x.ok().and_then(Result::ok)) + .collect() + .await } // For example filepath 'path', attempt to resolve it and get a Java version at this path // If no such path exists, or no such valid java at this path exists, returns None #[tracing::instrument] - -pub async fn check_java_at_filepath(path: &Path) -> Option { +pub async fn check_java_at_filepath(path: &Path) -> crate::Result { // Attempt to canonicalize the potential java filepath // If it fails, this path does not exist and None is returned (no Java here) - let Ok(path) = io::canonicalize(path) else { - return None; - }; + let path = io::canonicalize(path)?; // Checks for existence of Java at this filepath // Adds JAVA_BIN to the end of the path if it is not already there - let java = if path.file_name()?.to_str()? != JAVA_BIN { + let java = if path + .file_name() + .and_then(|x| x.to_str()) + .is_some_and(|x| x != JAVA_BIN) + { path.join(JAVA_BIN) } else { path }; if !java.exists() { - return None; + return Err(JREError::NoExecutable(java).into()); }; - let bytes = include_bytes!("../../library/JavaInfo.class"); - let Ok(tempdir) = tempfile::tempdir() else { - return None; - }; - let file_path = tempdir.path().join("JavaInfo.class"); - io::write(&file_path, bytes).await.ok()?; + let (_temp, file_path) = + get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?; let output = Command::new(&java) .arg("-cp") - .arg(file_path.parent().unwrap()) - .arg("JavaInfo") + .arg(file_path) + .arg("com.modrinth.theseus.JavaInfo") .env_remove("_JAVA_OPTIONS") - .output() - .ok()?; + .output()?; let stdout = String::from_utf8_lossy(&output.stdout); @@ -310,64 +302,49 @@ pub async fn check_java_at_filepath(path: &Path) -> Option { // Extract version info from it if let Some(arch) = java_arch { if let Some(version) = java_version { - if let Ok((_, major_version)) = - extract_java_majorminor_version(version) - { + if let Ok(version) = extract_java_version(version) { let path = java.to_string_lossy().to_string(); - return Some(JavaVersion { - major_version, + return Ok(JavaVersion { + parsed_version: version, path, version: version.to_string(), architecture: arch.to_string(), }); } + + return Err(JREError::InvalidJREVersion(version.to_owned()).into()); } } - None + + Err(JREError::FailedJavaCheck(java).into()) } -/// Extract major/minor version from a java version string -/// Gets the minor version or an error, and assumes 1 for major version if it could not find -/// "1.8.0_361" -> (1, 8) -/// "20" -> (1, 20) -pub fn extract_java_majorminor_version( - version: &str, -) -> Result<(u32, u32), JREError> { +pub fn extract_java_version(version: &str) -> Result { let mut split = version.split('.'); - let major_opt = split.next(); - let mut major; - // Try minor. If doesn't exist, in format like "20" so use major - let mut minor = if let Some(minor) = split.next() { - major = major_opt.unwrap_or("1").parse::()?; - minor.parse::()? - } else { - // Formatted like "20", only one value means that is minor version - major = 1; - major_opt - .ok_or_else(|| JREError::InvalidJREVersion(version.to_string()))? - .parse::()? - }; - - // Java start should always be 1. If more than 1, it is formatted like "17.0.1.2" and starts with minor version - if major > 1 { - minor = major; - major = 1; + let version = split.next().unwrap(); + let version = version.split_once('-').map_or(version, |(x, _)| x); + let mut version = version.parse::()?; + if version == 1 { + version = split.next().map_or(Ok(1), |x| x.parse::())?; } - Ok((major, minor)) + Ok(version) } #[derive(thiserror::Error, Debug)] pub enum JREError { - #[error("Command error : {0}")] + #[error("Command error: {0}")] IOError(#[from] std::io::Error), #[error("Env error: {0}")] EnvError(#[from] env::VarError), - #[error("No JRE found for required version: {0}")] - NoJREFound(String), + #[error("No executable found at {0}")] + NoExecutable(PathBuf), + + #[error("Could not check Java version at path {0}")] + FailedJavaCheck(PathBuf), #[error("Invalid JRE version string: {0}")] InvalidJREVersion(String), @@ -378,9 +355,9 @@ pub enum JREError { #[error("Join error: {0}")] JoinError(#[from] JoinError), - #[error("No stored tag for Minecraft Version {0}")] + #[error("No stored tag for Minecraft version {0}")] NoMinecraftVersionFound(String), - #[error("Error getting launcher sttae")] + #[error("Error getting launcher state")] StateError, } diff --git a/packages/app-lib/src/util/mod.rs b/packages/app-lib/src/util/mod.rs index 5a310291c..492ffbe86 100644 --- a/packages/app-lib/src/util/mod.rs +++ b/packages/app-lib/src/util/mod.rs @@ -4,15 +4,3 @@ pub mod io; pub mod jre; pub mod platform; pub mod server_ping; - -/// Wrap a builder which uses a mut reference into one which outputs an owned value -macro_rules! wrap_ref_builder { - ($id:ident = $init:expr => $transform:block) => {{ - let mut it = $init; - { - let $id = &mut it; - $transform; - } - it - }}; -} diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index ed5947d1f..66c7e120c 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ariadne" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] serde = { workspace = true, features = ["derive"] } @@ -13,3 +13,6 @@ rand.workspace = true either.workspace = true chrono = { workspace = true, features = ["serde"] } serde_cbor.workspace = true + +[lints] +workspace = true diff --git a/packages/ariadne/src/ids.rs b/packages/ariadne/src/ids.rs index 9f51b145c..5b389c8f0 100644 --- a/packages/ariadne/src/ids.rs +++ b/packages/ariadne/src/ids.rs @@ -71,7 +71,6 @@ pub enum DecodingError { } #[macro_export] -#[doc(hidden)] macro_rules! impl_base62_display { ($struct:ty) => { impl std::fmt::Display for $struct { diff --git a/packages/assets/.eslintrc.js b/packages/assets/.eslintrc.js index 03154d8dd..299763458 100644 --- a/packages/assets/.eslintrc.js +++ b/packages/assets/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { root: true, extends: ['custom/library'], - ignorePatterns: ['**/*.scss', '**/*.svg', 'node_modules/', 'dist/'], + ignorePatterns: ['**/*.scss', '**/*.svg', 'node_modules/', 'dist/', '**/*.gltf'], env: { node: true, }, diff --git a/packages/assets/README.md b/packages/assets/README.md new file mode 100644 index 000000000..6c2e3bc73 --- /dev/null +++ b/packages/assets/README.md @@ -0,0 +1,13 @@ +# `@modrinth/assets` + +This package contains various assets used across the Modrinth platform, including icons, images, and branding materials. + +Modrinth uses the [Lucide icon set](https://lucide.dev/) for its icons, which are automatically imported and exported in the `index.ts` file. This file is generated through the `pnpm run fix` command, which also ensures that all icons are consistent and correctly formatted. + +The "Mr Rinth"/"Rinthbot" branding assets were created and given to Modrinth by [Devin (integrav)](https://github.com/intergrav) and are used across the platform. These assets are also included in this package. + +## Adding New Assets + +If you're adding a new icon from the [Lucide icon set](https://lucide.dev/), download the icon as an SVG file and place it in the `icons` directory. The icon should be named in kebab-case (e.g., `example-icon.svg`). Then run the `pnpm run fix` command to automatically generate the necessary imports and exports. + +If you're adding anything else, you should manually add the import statement to `index.ts` and ensure it is exported correctly. diff --git a/packages/assets/build/generate-exports.ts b/packages/assets/build/generate-exports.ts new file mode 100644 index 000000000..c4c555fec --- /dev/null +++ b/packages/assets/build/generate-exports.ts @@ -0,0 +1,211 @@ +import fs from 'fs' +import path from 'path' + +function toPascalCase(str: string): string { + return str + .split(/[-_.]/) + .filter((part) => part.length > 0) + .map((word) => { + if (/^\d/.test(word)) { + return word.charAt(0).toUpperCase() + word.slice(1) + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() + }) + .join('') +} + +function generateIconExports(): { imports: string; exports: string } { + const packageRoot = path.resolve(__dirname, '..') + const iconsDir = path.join(packageRoot, 'icons') + + if (!fs.existsSync(iconsDir)) { + throw new Error(`Icons directory not found: ${iconsDir}`) + } + + const files = fs + .readdirSync(iconsDir) + .filter((file) => file.endsWith('.svg')) + .sort() + + let imports = '' + let exports = '' + + files.forEach((file) => { + const baseName = path.basename(file, '.svg') + let pascalName = toPascalCase(baseName) + + if (pascalName === '') { + pascalName = 'Unknown' + } + + if (!pascalName.endsWith('Icon')) { + pascalName += 'Icon' + } + + const privateName = `_${pascalName}` + + imports += `import ${privateName} from './icons/${file}?component'\n` + exports += `export const ${pascalName} = ${privateName}\n` + }) + + return { imports, exports } +} + +function runTests(): void { + console.log('🧪 Running conversion tests...\n') + + const testCases: Array<{ input: string; expected: string }> = [ + { input: 'align-left', expected: 'AlignLeftIcon' }, + { input: 'arrow-big-up-dash', expected: 'ArrowBigUpDashIcon' }, + { input: 'check-check', expected: 'CheckCheckIcon' }, + { input: 'chevron-left', expected: 'ChevronLeftIcon' }, + { input: 'file-archive', expected: 'FileArchiveIcon' }, + { input: 'heart-handshake', expected: 'HeartHandshakeIcon' }, + { input: 'monitor-smartphone', expected: 'MonitorSmartphoneIcon' }, + { input: 'x-circle', expected: 'XCircleIcon' }, + { input: 'rotate-ccw', expected: 'RotateCcwIcon' }, + { input: 'bell-ring', expected: 'BellRingIcon' }, + { input: 'more-horizontal', expected: 'MoreHorizontalIcon' }, + { input: 'list_bulleted', expected: 'ListBulletedIcon' }, + { input: 'test.name', expected: 'TestNameIcon' }, + { input: 'test-name_final.icon', expected: 'TestNameFinalIcon' }, + ] + + let passed = 0 + let failed = 0 + + testCases.forEach(({ input, expected }) => { + const result = toPascalCase(input) + (toPascalCase(input).endsWith('Icon') ? '' : 'Icon') + const success = result === expected + + if (success) { + console.log(`✅ ${input} → ${result}`) + passed++ + } else { + console.log(`❌ ${input} → ${result} (expected: ${expected})`) + failed++ + } + }) + + console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`) + + if (failed > 0) { + process.exit(1) + } +} + +function generateFiles(): void { + try { + console.log('🔄 Generating icon exports...') + + const { imports, exports } = generateIconExports() + const output = `// Auto-generated icon imports and exports +// Do not edit this file manually - run 'pnpm run fix' to regenerate + +${imports} +${exports}` + + const packageRoot = path.resolve(__dirname, '..') + const outputPath = path.join(packageRoot, 'generated-icons.ts') + fs.writeFileSync(outputPath, output) + + console.log(`✅ Generated icon exports to: ${outputPath}`) + console.log( + `📦 Generated ${imports.split('\n').filter((line) => line.trim()).length} icon imports/exports`, + ) + } catch (error) { + console.error('❌ Error generating icons:', error) + process.exit(1) + } +} + +function main(): void { + const args = process.argv.slice(2) + + if (args.includes('--test')) { + runTests() + } else if (args.includes('--validate')) { + validateIconConsistency() + } else { + generateFiles() + } +} + +main() + +function getExpectedIconExports(iconsDir: string): string[] { + if (!fs.existsSync(iconsDir)) { + return [] + } + + return fs + .readdirSync(iconsDir) + .filter((file) => file.endsWith('.svg')) + .map((file) => { + const baseName = path.basename(file, '.svg') + let pascalName = toPascalCase(baseName) + + if (pascalName === '') { + pascalName = 'Unknown' + } + + if (!pascalName.endsWith('Icon')) { + pascalName += 'Icon' + } + + return pascalName + }) + .sort() +} + +function getActualIconExports(indexFile: string): string[] { + if (!fs.existsSync(indexFile)) { + return [] + } + + const content = fs.readFileSync(indexFile, 'utf8') + const exportMatches = content.match(/export const (\w+Icon) = _\w+Icon/g) || [] + + return exportMatches + .map((match) => { + const result = match.match(/export const (\w+Icon)/) + return result ? result[1] : '' + }) + .filter((name) => name.endsWith('Icon')) + .sort() +} + +function validateIconConsistency(): void { + try { + console.log('🔍 Validating icon consistency...') + + const packageRoot = path.resolve(__dirname, '..') + const iconsDir = path.join(packageRoot, 'icons') + const declarationFile = path.join(packageRoot, 'generated-icons.ts') + + const expectedExports = getExpectedIconExports(iconsDir) + const actualExports = getActualIconExports(declarationFile) + + const missingExports = expectedExports.filter((name) => !actualExports.includes(name)) + const extraExports = actualExports.filter((name) => !expectedExports.includes(name)) + + if (missingExports.length > 0) { + console.error(`❌ Missing icon exports: ${missingExports.join(', ')}`) + console.error("Run 'pnpm run fix' to generate them.") + process.exit(1) + } + + if (extraExports.length > 0) { + console.error( + `❌ Extra icon exports (no corresponding SVG files): ${extraExports.join(', ')}`, + ) + console.error("Run 'pnpm run fix' to clean them up.") + process.exit(1) + } + + console.log('✅ Icon exports are consistent with SVG files') + } catch (error) { + console.error('❌ Error validating icons:', error) + process.exit(1) + } +} diff --git a/packages/assets/external/apple.svg b/packages/assets/external/apple.svg index 34399ed18..34f0563c6 100644 --- a/packages/assets/external/apple.svg +++ b/packages/assets/external/apple.svg @@ -1,9 +1 @@ - - - - Black Logo Square - Created with Sketch. - - - - +Apple diff --git a/packages/assets/external/bluesky.svg b/packages/assets/external/bluesky.svg index 471a4b564..c456933ab 100644 --- a/packages/assets/external/bluesky.svg +++ b/packages/assets/external/bluesky.svg @@ -1,3 +1 @@ - - - +Bluesky diff --git a/packages/assets/external/bmac.svg b/packages/assets/external/bmac.svg index be7e015eb..78957b27a 100644 --- a/packages/assets/external/bmac.svg +++ b/packages/assets/external/bmac.svg @@ -1,16 +1 @@ - - - - - - - - - - - - - - - - +Buy Me A Coffee diff --git a/packages/assets/external/discord.svg b/packages/assets/external/discord.svg index a655bf5ce..681f56cd6 100644 --- a/packages/assets/external/discord.svg +++ b/packages/assets/external/discord.svg @@ -1,10 +1 @@ - - - - - - - - - - +Discord diff --git a/packages/assets/external/github.svg b/packages/assets/external/github.svg index 5581249b5..6abd17039 100644 --- a/packages/assets/external/github.svg +++ b/packages/assets/external/github.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - +GitHub diff --git a/packages/assets/external/kofi.svg b/packages/assets/external/kofi.svg index 7ca78ce40..79391d150 100644 --- a/packages/assets/external/kofi.svg +++ b/packages/assets/external/kofi.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file +Ko-fi diff --git a/packages/assets/external/mastodon.svg b/packages/assets/external/mastodon.svg index 67698f10d..32c2dbef1 100644 --- a/packages/assets/external/mastodon.svg +++ b/packages/assets/external/mastodon.svg @@ -1 +1 @@ - +Mastodon diff --git a/packages/assets/external/opencollective.svg b/packages/assets/external/opencollective.svg index 70e5816e1..c717f1d85 100644 --- a/packages/assets/external/opencollective.svg +++ b/packages/assets/external/opencollective.svg @@ -1 +1 @@ -Open Collective \ No newline at end of file +Open Collective diff --git a/packages/assets/external/patreon.svg b/packages/assets/external/patreon.svg index 13b742d04..d74b3cae8 100644 --- a/packages/assets/external/patreon.svg +++ b/packages/assets/external/patreon.svg @@ -1 +1 @@ - +Patreon diff --git a/packages/assets/external/paypal.svg b/packages/assets/external/paypal.svg index a5da5a46c..ab48a0185 100644 --- a/packages/assets/external/paypal.svg +++ b/packages/assets/external/paypal.svg @@ -1 +1 @@ -PayPal \ No newline at end of file +PayPal diff --git a/packages/assets/external/reddit.svg b/packages/assets/external/reddit.svg index 5cfd11243..55e198be1 100644 --- a/packages/assets/external/reddit.svg +++ b/packages/assets/external/reddit.svg @@ -1 +1 @@ - \ No newline at end of file +Reddit diff --git a/packages/assets/external/tumblr.svg b/packages/assets/external/tumblr.svg index 86b2f2eec..bc76382b7 100644 --- a/packages/assets/external/tumblr.svg +++ b/packages/assets/external/tumblr.svg @@ -1,10 +1 @@ - - - - - - - - - - +Tumblr diff --git a/packages/assets/external/twitter.svg b/packages/assets/external/twitter.svg index 3cf66ecf6..2e154f7d3 100644 --- a/packages/assets/external/twitter.svg +++ b/packages/assets/external/twitter.svg @@ -1,10 +1 @@ - - - - - - - - - - +X diff --git a/packages/assets/external/youtube.svg b/packages/assets/external/youtube.svg index 5de99f86c..3ac2c781a 100644 --- a/packages/assets/external/youtube.svg +++ b/packages/assets/external/youtube.svg @@ -1 +1 @@ - \ No newline at end of file +YouTube diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts new file mode 100644 index 000000000..249a53b60 --- /dev/null +++ b/packages/assets/generated-icons.ts @@ -0,0 +1,384 @@ +// Auto-generated icon imports and exports +// Do not edit this file manually - run 'pnpm run fix' to regenerate + +import _AlignLeftIcon from './icons/align-left.svg?component' +import _ArchiveIcon from './icons/archive.svg?component' +import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component' +import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component' +import _AsteriskIcon from './icons/asterisk.svg?component' +import _BadgeCheckIcon from './icons/badge-check.svg?component' +import _BanIcon from './icons/ban.svg?component' +import _BellRingIcon from './icons/bell-ring.svg?component' +import _BellIcon from './icons/bell.svg?component' +import _BlocksIcon from './icons/blocks.svg?component' +import _BoldIcon from './icons/bold.svg?component' +import _BookOpenIcon from './icons/book-open.svg?component' +import _BookTextIcon from './icons/book-text.svg?component' +import _BookIcon from './icons/book.svg?component' +import _BookmarkIcon from './icons/bookmark.svg?component' +import _BotIcon from './icons/bot.svg?component' +import _BoxImportIcon from './icons/box-import.svg?component' +import _BoxIcon from './icons/box.svg?component' +import _BracesIcon from './icons/braces.svg?component' +import _BrushCleaningIcon from './icons/brush-cleaning.svg?component' +import _CalendarIcon from './icons/calendar.svg?component' +import _CardIcon from './icons/card.svg?component' +import _ChangeSkinIcon from './icons/change-skin.svg?component' +import _ChartIcon from './icons/chart.svg?component' +import _CheckCheckIcon from './icons/check-check.svg?component' +import _CheckCircleIcon from './icons/check-circle.svg?component' +import _CheckIcon from './icons/check.svg?component' +import _ChevronLeftIcon from './icons/chevron-left.svg?component' +import _ChevronRightIcon from './icons/chevron-right.svg?component' +import _ClearIcon from './icons/clear.svg?component' +import _ClientIcon from './icons/client.svg?component' +import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component' +import _CloudIcon from './icons/cloud.svg?component' +import _CodeIcon from './icons/code.svg?component' +import _CoffeeIcon from './icons/coffee.svg?component' +import _CogIcon from './icons/cog.svg?component' +import _CoinsIcon from './icons/coins.svg?component' +import _CollectionIcon from './icons/collection.svg?component' +import _CompassIcon from './icons/compass.svg?component' +import _ContractIcon from './icons/contract.svg?component' +import _CopyIcon from './icons/copy.svg?component' +import _CopyrightIcon from './icons/copyright.svg?component' +import _CpuIcon from './icons/cpu.svg?component' +import _CrownIcon from './icons/crown.svg?component' +import _CubeIcon from './icons/cube.svg?component' +import _CurrencyIcon from './icons/currency.svg?component' +import _DashboardIcon from './icons/dashboard.svg?component' +import _DatabaseIcon from './icons/database.svg?component' +import _DownloadIcon from './icons/download.svg?component' +import _DropdownIcon from './icons/dropdown.svg?component' +import _EditIcon from './icons/edit.svg?component' +import _ExpandIcon from './icons/expand.svg?component' +import _ExternalIcon from './icons/external.svg?component' +import _EyeOffIcon from './icons/eye-off.svg?component' +import _EyeIcon from './icons/eye.svg?component' +import _FileArchiveIcon from './icons/file-archive.svg?component' +import _FileTextIcon from './icons/file-text.svg?component' +import _FileIcon from './icons/file.svg?component' +import _FilterXIcon from './icons/filter-x.svg?component' +import _FilterIcon from './icons/filter.svg?component' +import _FolderArchiveIcon from './icons/folder-archive.svg?component' +import _FolderOpenIcon from './icons/folder-open.svg?component' +import _FolderSearchIcon from './icons/folder-search.svg?component' +import _GameIcon from './icons/game.svg?component' +import _GapIcon from './icons/gap.svg?component' +import _GaugeIcon from './icons/gauge.svg?component' +import _GitGraphIcon from './icons/git-graph.svg?component' +import _GlassesIcon from './icons/glasses.svg?component' +import _GlobeIcon from './icons/globe.svg?component' +import _GridIcon from './icons/grid.svg?component' +import _HamburgerIcon from './icons/hamburger.svg?component' +import _HammerIcon from './icons/hammer.svg?component' +import _HashIcon from './icons/hash.svg?component' +import _Heading1Icon from './icons/heading-1.svg?component' +import _Heading2Icon from './icons/heading-2.svg?component' +import _Heading3Icon from './icons/heading-3.svg?component' +import _HeartHandshakeIcon from './icons/heart-handshake.svg?component' +import _HeartIcon from './icons/heart.svg?component' +import _HistoryIcon from './icons/history.svg?component' +import _HomeIcon from './icons/home.svg?component' +import _ImageIcon from './icons/image.svg?component' +import _ImportIcon from './icons/import.svg?component' +import _InProgressIcon from './icons/in-progress.svg?component' +import _InfoIcon from './icons/info.svg?component' +import _IssuesIcon from './icons/issues.svg?component' +import _ItalicIcon from './icons/italic.svg?component' +import _KeyIcon from './icons/key.svg?component' +import _KeyboardIcon from './icons/keyboard.svg?component' +import _LanguagesIcon from './icons/languages.svg?component' +import _LeftArrowIcon from './icons/left-arrow.svg?component' +import _LibraryIcon from './icons/library.svg?component' +import _LightBulbIcon from './icons/light-bulb.svg?component' +import _LinkIcon from './icons/link.svg?component' +import _ListBulletedIcon from './icons/list-bulleted.svg?component' +import _ListEndIcon from './icons/list-end.svg?component' +import _ListOrderedIcon from './icons/list-ordered.svg?component' +import _ListIcon from './icons/list.svg?component' +import _LoaderIcon from './icons/loader.svg?component' +import _LockOpenIcon from './icons/lock-open.svg?component' +import _LockIcon from './icons/lock.svg?component' +import _LogInIcon from './icons/log-in.svg?component' +import _LogOutIcon from './icons/log-out.svg?component' +import _MailIcon from './icons/mail.svg?component' +import _ManageIcon from './icons/manage.svg?component' +import _MaximizeIcon from './icons/maximize.svg?component' +import _MemoryStickIcon from './icons/memory-stick.svg?component' +import _MessageIcon from './icons/message.svg?component' +import _MicrophoneIcon from './icons/microphone.svg?component' +import _MinimizeIcon from './icons/minimize.svg?component' +import _MinusIcon from './icons/minus.svg?component' +import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component' +import _MonitorIcon from './icons/monitor.svg?component' +import _MoonIcon from './icons/moon.svg?component' +import _MoreHorizontalIcon from './icons/more-horizontal.svg?component' +import _MoreVerticalIcon from './icons/more-vertical.svg?component' +import _NewspaperIcon from './icons/newspaper.svg?component' +import _NoSignalIcon from './icons/no-signal.svg?component' +import _OmorphiaIcon from './icons/omorphia.svg?component' +import _OrganizationIcon from './icons/organization.svg?component' +import _PackageClosedIcon from './icons/package-closed.svg?component' +import _PackageOpenIcon from './icons/package-open.svg?component' +import _PackageIcon from './icons/package.svg?component' +import _PaintbrushIcon from './icons/paintbrush.svg?component' +import _PickaxeIcon from './icons/pickaxe.svg?component' +import _PlayIcon from './icons/play.svg?component' +import _PlugIcon from './icons/plug.svg?component' +import _PlusIcon from './icons/plus.svg?component' +import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component' +import _RadioButtonIcon from './icons/radio-button.svg?component' +import _ReceiptTextIcon from './icons/receipt-text.svg?component' +import _RedoIcon from './icons/redo.svg?component' +import _ReplyIcon from './icons/reply.svg?component' +import _ReportIcon from './icons/report.svg?component' +import _RestoreIcon from './icons/restore.svg?component' +import _RightArrowIcon from './icons/right-arrow.svg?component' +import _RotateClockwiseIcon from './icons/rotate-clockwise.svg?component' +import _RotateCounterClockwiseIcon from './icons/rotate-counter-clockwise.svg?component' +import _RssIcon from './icons/rss.svg?component' +import _SaveIcon from './icons/save.svg?component' +import _ScaleIcon from './icons/scale.svg?component' +import _ScanEyeIcon from './icons/scan-eye.svg?component' +import _SearchIcon from './icons/search.svg?component' +import _SendIcon from './icons/send.svg?component' +import _ServerPlusIcon from './icons/server-plus.svg?component' +import _ServerIcon from './icons/server.svg?component' +import _SettingsIcon from './icons/settings.svg?component' +import _ShareIcon from './icons/share.svg?component' +import _ShieldIcon from './icons/shield.svg?component' +import _SignalIcon from './icons/signal.svg?component' +import _SkullIcon from './icons/skull.svg?component' +import _SlashIcon from './icons/slash.svg?component' +import _SortAscIcon from './icons/sort-asc.svg?component' +import _SortDescIcon from './icons/sort-desc.svg?component' +import _SparklesIcon from './icons/sparkles.svg?component' +import _SpinnerIcon from './icons/spinner.svg?component' +import _StarIcon from './icons/star.svg?component' +import _StopCircleIcon from './icons/stop-circle.svg?component' +import _StrikethroughIcon from './icons/strikethrough.svg?component' +import _SunIcon from './icons/sun.svg?component' +import _SunriseIcon from './icons/sunrise.svg?component' +import _TagIcon from './icons/tag.svg?component' +import _TagsIcon from './icons/tags.svg?component' +import _TerminalSquareIcon from './icons/terminal-square.svg?component' +import _TestIcon from './icons/test.svg?component' +import _TextQuoteIcon from './icons/text-quote.svg?component' +import _TimerIcon from './icons/timer.svg?component' +import _TransferIcon from './icons/transfer.svg?component' +import _TrashIcon from './icons/trash.svg?component' +import _TriangleAlertIcon from './icons/triangle-alert.svg?component' +import _UnderlineIcon from './icons/underline.svg?component' +import _UndoIcon from './icons/undo.svg?component' +import _UnknownDonationIcon from './icons/unknown-donation.svg?component' +import _UnknownIcon from './icons/unknown.svg?component' +import _UnlinkIcon from './icons/unlink.svg?component' +import _UnplugIcon from './icons/unplug.svg?component' +import _UpdatedIcon from './icons/updated.svg?component' +import _UploadIcon from './icons/upload.svg?component' +import _UserPlusIcon from './icons/user-plus.svg?component' +import _UserXIcon from './icons/user-x.svg?component' +import _UserIcon from './icons/user.svg?component' +import _UsersIcon from './icons/users.svg?component' +import _VersionIcon from './icons/version.svg?component' +import _WikiIcon from './icons/wiki.svg?component' +import _WindowIcon from './icons/window.svg?component' +import _WorldIcon from './icons/world.svg?component' +import _WrenchIcon from './icons/wrench.svg?component' +import _XCircleIcon from './icons/x-circle.svg?component' +import _XIcon from './icons/x.svg?component' +import _ZoomInIcon from './icons/zoom-in.svg?component' +import _ZoomOutIcon from './icons/zoom-out.svg?component' + +export const AlignLeftIcon = _AlignLeftIcon +export const ArchiveIcon = _ArchiveIcon +export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon +export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon +export const AsteriskIcon = _AsteriskIcon +export const BadgeCheckIcon = _BadgeCheckIcon +export const BanIcon = _BanIcon +export const BellRingIcon = _BellRingIcon +export const BellIcon = _BellIcon +export const BlocksIcon = _BlocksIcon +export const BoldIcon = _BoldIcon +export const BookOpenIcon = _BookOpenIcon +export const BookTextIcon = _BookTextIcon +export const BookIcon = _BookIcon +export const BookmarkIcon = _BookmarkIcon +export const BotIcon = _BotIcon +export const BoxImportIcon = _BoxImportIcon +export const BoxIcon = _BoxIcon +export const BracesIcon = _BracesIcon +export const BrushCleaningIcon = _BrushCleaningIcon +export const CalendarIcon = _CalendarIcon +export const CardIcon = _CardIcon +export const ChangeSkinIcon = _ChangeSkinIcon +export const ChartIcon = _ChartIcon +export const CheckCheckIcon = _CheckCheckIcon +export const CheckCircleIcon = _CheckCircleIcon +export const CheckIcon = _CheckIcon +export const ChevronLeftIcon = _ChevronLeftIcon +export const ChevronRightIcon = _ChevronRightIcon +export const ClearIcon = _ClearIcon +export const ClientIcon = _ClientIcon +export const ClipboardCopyIcon = _ClipboardCopyIcon +export const CloudIcon = _CloudIcon +export const CodeIcon = _CodeIcon +export const CoffeeIcon = _CoffeeIcon +export const CogIcon = _CogIcon +export const CoinsIcon = _CoinsIcon +export const CollectionIcon = _CollectionIcon +export const CompassIcon = _CompassIcon +export const ContractIcon = _ContractIcon +export const CopyIcon = _CopyIcon +export const CopyrightIcon = _CopyrightIcon +export const CpuIcon = _CpuIcon +export const CrownIcon = _CrownIcon +export const CubeIcon = _CubeIcon +export const CurrencyIcon = _CurrencyIcon +export const DashboardIcon = _DashboardIcon +export const DatabaseIcon = _DatabaseIcon +export const DownloadIcon = _DownloadIcon +export const DropdownIcon = _DropdownIcon +export const EditIcon = _EditIcon +export const ExpandIcon = _ExpandIcon +export const ExternalIcon = _ExternalIcon +export const EyeOffIcon = _EyeOffIcon +export const EyeIcon = _EyeIcon +export const FileArchiveIcon = _FileArchiveIcon +export const FileTextIcon = _FileTextIcon +export const FileIcon = _FileIcon +export const FilterXIcon = _FilterXIcon +export const FilterIcon = _FilterIcon +export const FolderArchiveIcon = _FolderArchiveIcon +export const FolderOpenIcon = _FolderOpenIcon +export const FolderSearchIcon = _FolderSearchIcon +export const GameIcon = _GameIcon +export const GapIcon = _GapIcon +export const GaugeIcon = _GaugeIcon +export const GitGraphIcon = _GitGraphIcon +export const GlassesIcon = _GlassesIcon +export const GlobeIcon = _GlobeIcon +export const GridIcon = _GridIcon +export const HamburgerIcon = _HamburgerIcon +export const HammerIcon = _HammerIcon +export const HashIcon = _HashIcon +export const Heading1Icon = _Heading1Icon +export const Heading2Icon = _Heading2Icon +export const Heading3Icon = _Heading3Icon +export const HeartHandshakeIcon = _HeartHandshakeIcon +export const HeartIcon = _HeartIcon +export const HistoryIcon = _HistoryIcon +export const HomeIcon = _HomeIcon +export const ImageIcon = _ImageIcon +export const ImportIcon = _ImportIcon +export const InProgressIcon = _InProgressIcon +export const InfoIcon = _InfoIcon +export const IssuesIcon = _IssuesIcon +export const ItalicIcon = _ItalicIcon +export const KeyIcon = _KeyIcon +export const KeyboardIcon = _KeyboardIcon +export const LanguagesIcon = _LanguagesIcon +export const LeftArrowIcon = _LeftArrowIcon +export const LibraryIcon = _LibraryIcon +export const LightBulbIcon = _LightBulbIcon +export const LinkIcon = _LinkIcon +export const ListBulletedIcon = _ListBulletedIcon +export const ListEndIcon = _ListEndIcon +export const ListOrderedIcon = _ListOrderedIcon +export const ListIcon = _ListIcon +export const LoaderIcon = _LoaderIcon +export const LockOpenIcon = _LockOpenIcon +export const LockIcon = _LockIcon +export const LogInIcon = _LogInIcon +export const LogOutIcon = _LogOutIcon +export const MailIcon = _MailIcon +export const ManageIcon = _ManageIcon +export const MaximizeIcon = _MaximizeIcon +export const MemoryStickIcon = _MemoryStickIcon +export const MessageIcon = _MessageIcon +export const MicrophoneIcon = _MicrophoneIcon +export const MinimizeIcon = _MinimizeIcon +export const MinusIcon = _MinusIcon +export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon +export const MonitorIcon = _MonitorIcon +export const MoonIcon = _MoonIcon +export const MoreHorizontalIcon = _MoreHorizontalIcon +export const MoreVerticalIcon = _MoreVerticalIcon +export const NewspaperIcon = _NewspaperIcon +export const NoSignalIcon = _NoSignalIcon +export const OmorphiaIcon = _OmorphiaIcon +export const OrganizationIcon = _OrganizationIcon +export const PackageClosedIcon = _PackageClosedIcon +export const PackageOpenIcon = _PackageOpenIcon +export const PackageIcon = _PackageIcon +export const PaintbrushIcon = _PaintbrushIcon +export const PickaxeIcon = _PickaxeIcon +export const PlayIcon = _PlayIcon +export const PlugIcon = _PlugIcon +export const PlusIcon = _PlusIcon +export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon +export const RadioButtonIcon = _RadioButtonIcon +export const ReceiptTextIcon = _ReceiptTextIcon +export const RedoIcon = _RedoIcon +export const ReplyIcon = _ReplyIcon +export const ReportIcon = _ReportIcon +export const RestoreIcon = _RestoreIcon +export const RightArrowIcon = _RightArrowIcon +export const RotateClockwiseIcon = _RotateClockwiseIcon +export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon +export const RssIcon = _RssIcon +export const SaveIcon = _SaveIcon +export const ScaleIcon = _ScaleIcon +export const ScanEyeIcon = _ScanEyeIcon +export const SearchIcon = _SearchIcon +export const SendIcon = _SendIcon +export const ServerPlusIcon = _ServerPlusIcon +export const ServerIcon = _ServerIcon +export const SettingsIcon = _SettingsIcon +export const ShareIcon = _ShareIcon +export const ShieldIcon = _ShieldIcon +export const SignalIcon = _SignalIcon +export const SkullIcon = _SkullIcon +export const SlashIcon = _SlashIcon +export const SortAscIcon = _SortAscIcon +export const SortDescIcon = _SortDescIcon +export const SparklesIcon = _SparklesIcon +export const SpinnerIcon = _SpinnerIcon +export const StarIcon = _StarIcon +export const StopCircleIcon = _StopCircleIcon +export const StrikethroughIcon = _StrikethroughIcon +export const SunIcon = _SunIcon +export const SunriseIcon = _SunriseIcon +export const TagIcon = _TagIcon +export const TagsIcon = _TagsIcon +export const TerminalSquareIcon = _TerminalSquareIcon +export const TestIcon = _TestIcon +export const TextQuoteIcon = _TextQuoteIcon +export const TimerIcon = _TimerIcon +export const TransferIcon = _TransferIcon +export const TrashIcon = _TrashIcon +export const TriangleAlertIcon = _TriangleAlertIcon +export const UnderlineIcon = _UnderlineIcon +export const UndoIcon = _UndoIcon +export const UnknownDonationIcon = _UnknownDonationIcon +export const UnknownIcon = _UnknownIcon +export const UnlinkIcon = _UnlinkIcon +export const UnplugIcon = _UnplugIcon +export const UpdatedIcon = _UpdatedIcon +export const UploadIcon = _UploadIcon +export const UserPlusIcon = _UserPlusIcon +export const UserXIcon = _UserXIcon +export const UserIcon = _UserIcon +export const UsersIcon = _UsersIcon +export const VersionIcon = _VersionIcon +export const WikiIcon = _WikiIcon +export const WindowIcon = _WindowIcon +export const WorldIcon = _WorldIcon +export const WrenchIcon = _WrenchIcon +export const XCircleIcon = _XCircleIcon +export const XIcon = _XIcon +export const ZoomInIcon = _ZoomInIcon +export const ZoomOutIcon = _ZoomOutIcon diff --git a/packages/assets/icons.d.ts b/packages/assets/icons.d.ts index 1715850fc..9e7826981 100644 --- a/packages/assets/icons.d.ts +++ b/packages/assets/icons.d.ts @@ -9,3 +9,8 @@ declare module '*.webp' { const src: string export default src } + +declare module '*?url' { + const src: string + export default src +} diff --git a/packages/assets/icons/arrow-big-right-dash.svg b/packages/assets/icons/arrow-big-right-dash.svg new file mode 100644 index 000000000..2e13baa29 --- /dev/null +++ b/packages/assets/icons/arrow-big-right-dash.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/packages/assets/icons/badge-check.svg b/packages/assets/icons/badge-check.svg new file mode 100644 index 000000000..ad45322e3 --- /dev/null +++ b/packages/assets/icons/badge-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/book-open.svg b/packages/assets/icons/book-open.svg new file mode 100644 index 000000000..7f220f404 --- /dev/null +++ b/packages/assets/icons/book-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/brush-cleaning.svg b/packages/assets/icons/brush-cleaning.svg new file mode 100644 index 000000000..0a7875cd3 --- /dev/null +++ b/packages/assets/icons/brush-cleaning.svg @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/packages/assets/icons/change-skin.svg b/packages/assets/icons/change-skin.svg new file mode 100644 index 000000000..762605150 --- /dev/null +++ b/packages/assets/icons/change-skin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/assets/icons/git-graph.svg b/packages/assets/icons/git-graph.svg new file mode 100644 index 000000000..9b2b37b51 --- /dev/null +++ b/packages/assets/icons/git-graph.svg @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/assets/icons/github.svg b/packages/assets/icons/github.svg deleted file mode 100644 index 90908bb31..000000000 --- a/packages/assets/icons/github.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/packages/assets/icons/keyboard.svg b/packages/assets/icons/keyboard.svg new file mode 100644 index 000000000..e6f32531d --- /dev/null +++ b/packages/assets/icons/keyboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/rotate-cw.svg b/packages/assets/icons/rotate-clockwise.svg similarity index 100% rename from packages/assets/icons/rotate-cw.svg rename to packages/assets/icons/rotate-clockwise.svg diff --git a/packages/assets/icons/rotate-ccw.svg b/packages/assets/icons/rotate-counter-clockwise.svg similarity index 100% rename from packages/assets/icons/rotate-ccw.svg rename to packages/assets/icons/rotate-counter-clockwise.svg diff --git a/packages/assets/icons/rss.svg b/packages/assets/icons/rss.svg new file mode 100644 index 000000000..7d895b4ca --- /dev/null +++ b/packages/assets/icons/rss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/server-plus.svg b/packages/assets/icons/server-plus.svg new file mode 100644 index 000000000..b9bab1bd8 --- /dev/null +++ b/packages/assets/icons/server-plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/assets/icons/triangle-alert.svg b/packages/assets/icons/triangle-alert.svg new file mode 100644 index 000000000..ff1236513 --- /dev/null +++ b/packages/assets/icons/triangle-alert.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/packages/assets/index.ts b/packages/assets/index.ts index df7df4247..2ff3fe768 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -1,4 +1,12 @@ -// NOTE: re-export using consts to help TypeScript resolve the proper type +/** + * NOTE: You should re-export any manually added icons + * using consts to help TypeScript resolve the proper type + * + * NOTE: If an icon is part of the lucide icon set, it should be placed in the "icons" folder + * and automatically generated through the "pnpm run fix" command. + */ + +import './omorphia.scss' // Branding import _ModrinthIcon from './branding/logo.svg?component' @@ -39,192 +47,6 @@ import _TwitterIcon from './external/twitter.svg?component' import _WindowsIcon from './external/windows.svg?component' import _YouTubeIcon from './external/youtube.svg?component' -// Icons -import _AlignLeftIcon from './icons/align-left.svg?component' -import _ArchiveIcon from './icons/archive.svg?component' -import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component' -import _AsteriskIcon from './icons/asterisk.svg?component' -import _BanIcon from './icons/ban.svg?component' -import _BellIcon from './icons/bell.svg?component' -import _BellRingIcon from './icons/bell-ring.svg?component' -import _BlocksIcon from './icons/blocks.svg?component' -import _BookIcon from './icons/book.svg?component' -import _BookTextIcon from './icons/book-text.svg?component' -import _BookmarkIcon from './icons/bookmark.svg?component' -import _BotIcon from './icons/bot.svg?component' -import _BoxIcon from './icons/box.svg?component' -import _BoxImportIcon from './icons/box-import.svg?component' -import _BracesIcon from './icons/braces.svg?component' -import _CalendarIcon from './icons/calendar.svg?component' -import _CardIcon from './icons/card.svg?component' -import _ChartIcon from './icons/chart.svg?component' -import _CheckIcon from './icons/check.svg?component' -import _CheckCheckIcon from './icons/check-check.svg?component' -import _CheckCircleIcon from './icons/check-circle.svg?component' -import _ChevronLeftIcon from './icons/chevron-left.svg?component' -import _ChevronRightIcon from './icons/chevron-right.svg?component' -import _ClearIcon from './icons/clear.svg?component' -import _ClientIcon from './icons/client.svg?component' -import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component' -import _CodeIcon from './icons/code.svg?component' -import _CoffeeIcon from './icons/coffee.svg?component' -import _CoinsIcon from './icons/coins.svg?component' -import _CollectionIcon from './icons/collection.svg?component' -import _CompassIcon from './icons/compass.svg?component' -import _ContractIcon from './icons/contract.svg?component' -import _CopyIcon from './icons/copy.svg?component' -import _CopyrightIcon from './icons/copyright.svg?component' -import _CrownIcon from './icons/crown.svg?component' -import _CurrencyIcon from './icons/currency.svg?component' -import _DashboardIcon from './icons/dashboard.svg?component' -import _DatabaseIcon from './icons/database.svg?component' -import _DownloadIcon from './icons/download.svg?component' -import _DropdownIcon from './icons/dropdown.svg?component' -import _EditIcon from './icons/edit.svg?component' -import _ExpandIcon from './icons/expand.svg?component' -import _ExternalIcon from './icons/external.svg?component' -import _EyeIcon from './icons/eye.svg?component' -import _EyeOffIcon from './icons/eye-off.svg?component' -import _FileIcon from './icons/file.svg?component' -import _FileArchiveIcon from './icons/file-archive.svg?component' -import _FileTextIcon from './icons/file-text.svg?component' -import _FilterIcon from './icons/filter.svg?component' -import _FilterXIcon from './icons/filter-x.svg?component' -import _FolderArchiveIcon from './icons/folder-archive.svg?component' -import _FolderOpenIcon from './icons/folder-open.svg?component' -import _FolderSearchIcon from './icons/folder-search.svg?component' -import _GapIcon from './icons/gap.svg?component' -import _GaugeIcon from './icons/gauge.svg?component' -import _GameIcon from './icons/game.svg?component' -import _GitHubIcon from './icons/github.svg?component' -import _GlassesIcon from './icons/glasses.svg?component' -import _GlobeIcon from './icons/globe.svg?component' -import _GridIcon from './icons/grid.svg?component' -import _HamburgerIcon from './icons/hamburger.svg?component' -import _HammerIcon from './icons/hammer.svg?component' -import _HashIcon from './icons/hash.svg?component' -import _HeartIcon from './icons/heart.svg?component' -import _HeartHandshakeIcon from './icons/heart-handshake.svg?component' -import _HistoryIcon from './icons/history.svg?component' -import _HomeIcon from './icons/home.svg?component' -import _ImageIcon from './icons/image.svg?component' -import _InProgressIcon from './icons/in-progress.svg?component' -import _InfoIcon from './icons/info.svg?component' -import _IssuesIcon from './icons/issues.svg?component' -import _KeyIcon from './icons/key.svg?component' -import _LanguagesIcon from './icons/languages.svg?component' -import _LeftArrowIcon from './icons/left-arrow.svg?component' -import _LibraryIcon from './icons/library.svg?component' -import _LightBulbIcon from './icons/light-bulb.svg?component' -import _LinkIcon from './icons/link.svg?component' -import _ListIcon from './icons/list.svg?component' -import _ListEndIcon from './icons/list-end.svg?component' -import _LockIcon from './icons/lock.svg?component' -import _LockOpenIcon from './icons/lock-open.svg?component' -import _LogInIcon from './icons/log-in.svg?component' -import _LogOutIcon from './icons/log-out.svg?component' -import _MailIcon from './icons/mail.svg?component' -import _ManageIcon from './icons/manage.svg?component' -import _MaximizeIcon from './icons/maximize.svg?component' -import _MemoryStickIcon from './icons/memory-stick.svg?component' -import _MessageIcon from './icons/message.svg?component' -import _MicrophoneIcon from './icons/microphone.svg?component' -import _MinimizeIcon from './icons/minimize.svg?component' -import _MinusIcon from './icons/minus.svg?component' -import _MonitorIcon from './icons/monitor.svg?component' -import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component' -import _MoonIcon from './icons/moon.svg?component' -import _MoreHorizontalIcon from './icons/more-horizontal.svg?component' -import _MoreVerticalIcon from './icons/more-vertical.svg?component' -import _NewspaperIcon from './icons/newspaper.svg?component' -import _NoSignalIcon from './icons/no-signal.svg?component' -import _OmorphiaIcon from './icons/omorphia.svg?component' -import _OrganizationIcon from './icons/organization.svg?component' -import _PackageIcon from './icons/package.svg?component' -import _PackageOpenIcon from './icons/package-open.svg?component' -import _PackageClosedIcon from './icons/package-closed.svg?component' -import _PaintBrushIcon from './icons/paintbrush.svg?component' -import _PickaxeIcon from './icons/pickaxe.svg?component' -import _PlayIcon from './icons/play.svg?component' -import _PlugIcon from './icons/plug.svg?component' -import _PlusIcon from './icons/plus.svg?component' -import _RadioButtonIcon from './icons/radio-button.svg?component' -import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component' -import _ReceiptTextIcon from './icons/receipt-text.svg?component' -import _ReplyIcon from './icons/reply.svg?component' -import _ReportIcon from './icons/report.svg?component' -import _RestoreIcon from './icons/restore.svg?component' -import _RightArrowIcon from './icons/right-arrow.svg?component' -import _RotateCounterClockwiseIcon from './icons/rotate-ccw.svg?component' -import _RotateClockwiseIcon from './icons/rotate-cw.svg?component' -import _SaveIcon from './icons/save.svg?component' -import _ScaleIcon from './icons/scale.svg?component' -import _ScanEyeIcon from './icons/scan-eye.svg?component' -import _SearchIcon from './icons/search.svg?component' -import _SendIcon from './icons/send.svg?component' -import _ServerIcon from './icons/server.svg?component' -import _SettingsIcon from './icons/settings.svg?component' -import _ShareIcon from './icons/share.svg?component' -import _ShieldIcon from './icons/shield.svg?component' -import _SignalIcon from './icons/signal.svg?component' -import _SkullIcon from './icons/skull.svg?component' -import _SlashIcon from './icons/slash.svg?component' -import _SortAscendingIcon from './icons/sort-asc.svg?component' -import _SortDescendingIcon from './icons/sort-desc.svg?component' -import _SparklesIcon from './icons/sparkles.svg?component' -import _SpinnerIcon from './icons/spinner.svg?component' -import _StarIcon from './icons/star.svg?component' -import _StopCircleIcon from './icons/stop-circle.svg?component' -import _SunIcon from './icons/sun.svg?component' -import _SunriseIcon from './icons/sunrise.svg?component' -import _TagIcon from './icons/tag.svg?component' -import _TagsIcon from './icons/tags.svg?component' -import _TerminalSquareIcon from './icons/terminal-square.svg?component' -import _TransferIcon from './icons/transfer.svg?component' -import _TrashIcon from './icons/trash.svg?component' -import _UndoIcon from './icons/undo.svg?component' -import _RedoIcon from './icons/redo.svg?component' -import _UnknownIcon from './icons/unknown.svg?component' -import _UnknownDonationIcon from './icons/unknown-donation.svg?component' -import _UpdatedIcon from './icons/updated.svg?component' -import _UnlinkIcon from './icons/unlink.svg?component' -import _UnplugIcon from './icons/unplug.svg?component' -import _UploadIcon from './icons/upload.svg?component' -import _UserIcon from './icons/user.svg?component' -import _UserPlusIcon from './icons/user-plus.svg?component' -import _UserXIcon from './icons/user-x.svg?component' -import _UsersIcon from './icons/users.svg?component' -import _VersionIcon from './icons/version.svg?component' -import _WikiIcon from './icons/wiki.svg?component' -import _WindowIcon from './icons/window.svg?component' -import _WorldIcon from './icons/world.svg?component' -import _WrenchIcon from './icons/wrench.svg?component' -import _XIcon from './icons/x.svg?component' -import _XCircleIcon from './icons/x-circle.svg?component' -import _ZoomInIcon from './icons/zoom-in.svg?component' -import _ZoomOutIcon from './icons/zoom-out.svg?component' -import _CubeIcon from './icons/cube.svg?component' -import _CloudIcon from './icons/cloud.svg?component' -import _CogIcon from './icons/cog.svg?component' -import _CPUIcon from './icons/cpu.svg?component' -import _LoaderIcon from './icons/loader.svg?component' -import _ImportIcon from './icons/import.svg?component' -import _TimerIcon from './icons/timer.svg?component' - -// Editor Icons -import _BoldIcon from './icons/bold.svg?component' -import _ItalicIcon from './icons/italic.svg?component' -import _UnderlineIcon from './icons/underline.svg?component' -import _StrikethroughIcon from './icons/strikethrough.svg?component' -import _ListBulletedIcon from './icons/list-bulleted.svg?component' -import _ListOrderedIcon from './icons/list-ordered.svg?component' -import _TextQuoteIcon from './icons/text-quote.svg?component' -import _Heading1Icon from './icons/heading-1.svg?component' -import _Heading2Icon from './icons/heading-2.svg?component' -import _Heading3Icon from './icons/heading-3.svg?component' - -import './omorphia.scss' - export const ModrinthIcon = _ModrinthIcon export const FourOhFourNotFound = _FourOhFourNotFound export const ModrinthPlusIcon = _ModrinthPlusIcon @@ -260,184 +82,9 @@ export const TumblrIcon = _TumblrIcon export const TwitterIcon = _TwitterIcon export const WindowsIcon = _WindowsIcon export const YouTubeIcon = _YouTubeIcon -export const AlignLeftIcon = _AlignLeftIcon -export const ArchiveIcon = _ArchiveIcon -export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon -export const AsteriskIcon = _AsteriskIcon -export const BanIcon = _BanIcon -export const BellIcon = _BellIcon -export const BellRingIcon = _BellRingIcon -export const BlocksIcon = _BlocksIcon -export const BookIcon = _BookIcon -export const BookTextIcon = _BookTextIcon -export const BookmarkIcon = _BookmarkIcon -export const BotIcon = _BotIcon -export const BoxIcon = _BoxIcon -export const BoxImportIcon = _BoxImportIcon -export const BracesIcon = _BracesIcon -export const CalendarIcon = _CalendarIcon -export const ChartIcon = _ChartIcon -export const CheckIcon = _CheckIcon -export const CheckCheckIcon = _CheckCheckIcon -export const CheckCircleIcon = _CheckCircleIcon -export const ChevronLeftIcon = _ChevronLeftIcon -export const ChevronRightIcon = _ChevronRightIcon -export const ClearIcon = _ClearIcon -export const ClientIcon = _ClientIcon -export const ClipboardCopyIcon = _ClipboardCopyIcon -export const CodeIcon = _CodeIcon -export const CoffeeIcon = _CoffeeIcon -export const CoinsIcon = _CoinsIcon -export const CollectionIcon = _CollectionIcon -export const CompassIcon = _CompassIcon -export const ContractIcon = _ContractIcon -export const CopyIcon = _CopyIcon -export const CopyrightIcon = _CopyrightIcon -export const CrownIcon = _CrownIcon -export const CurrencyIcon = _CurrencyIcon -export const DashboardIcon = _DashboardIcon -export const DatabaseIcon = _DatabaseIcon -export const DownloadIcon = _DownloadIcon -export const DropdownIcon = _DropdownIcon -export const EditIcon = _EditIcon -export const ExitIcon = _XIcon -export const ExpandIcon = _ExpandIcon -export const ExternalIcon = _ExternalIcon -export const EyeIcon = _EyeIcon -export const EyeOffIcon = _EyeOffIcon -export const FileIcon = _FileIcon -export const FileArchiveIcon = _FileArchiveIcon -export const FileTextIcon = _FileTextIcon -export const FilterIcon = _FilterIcon -export const FilterXIcon = _FilterXIcon -export const FolderArchiveIcon = _FolderArchiveIcon -export const FolderOpenIcon = _FolderOpenIcon -export const FolderSearchIcon = _FolderSearchIcon -export const GapIcon = _GapIcon -export const GaugeIcon = _GaugeIcon -export const GameIcon = _GameIcon -export const GitHubIcon = _GitHubIcon -export const GlassesIcon = _GlassesIcon -export const GlobeIcon = _GlobeIcon -export const GridIcon = _GridIcon -export const HamburgerIcon = _HamburgerIcon -export const HammerIcon = _HammerIcon -export const HashIcon = _HashIcon -export const HeartIcon = _HeartIcon -export const HeartHandshakeIcon = _HeartHandshakeIcon -export const HistoryIcon = _HistoryIcon -export const HomeIcon = _HomeIcon -export const ImageIcon = _ImageIcon -export const InProgressIcon = _InProgressIcon -export const InfoIcon = _InfoIcon -export const IssuesIcon = _IssuesIcon -export const KeyIcon = _KeyIcon -export const LanguagesIcon = _LanguagesIcon -export const LeftArrowIcon = _LeftArrowIcon -export const LibraryIcon = _LibraryIcon -export const LightBulbIcon = _LightBulbIcon -export const LinkIcon = _LinkIcon -export const ListIcon = _ListIcon -export const ListEndIcon = _ListEndIcon -export const LockIcon = _LockIcon -export const LockOpenIcon = _LockOpenIcon -export const LogInIcon = _LogInIcon -export const LogOutIcon = _LogOutIcon -export const MailIcon = _MailIcon -export const ManageIcon = _ManageIcon -export const MaximizeIcon = _MaximizeIcon -export const MemoryStickIcon = _MemoryStickIcon -export const MessageIcon = _MessageIcon -export const MicrophoneIcon = _MicrophoneIcon -export const MinimizeIcon = _MinimizeIcon -export const MinusIcon = _MinusIcon -export const MonitorIcon = _MonitorIcon -export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon -export const MoonIcon = _MoonIcon -export const MoreHorizontalIcon = _MoreHorizontalIcon -export const MoreVerticalIcon = _MoreVerticalIcon -export const NewspaperIcon = _NewspaperIcon -export const NoSignalIcon = _NoSignalIcon -export const OmorphiaIcon = _OmorphiaIcon -export const OrganizationIcon = _OrganizationIcon -export const PackageIcon = _PackageIcon -export const PackageOpenIcon = _PackageOpenIcon -export const PackageClosedIcon = _PackageClosedIcon -export const PaintBrushIcon = _PaintBrushIcon -export const PickaxeIcon = _PickaxeIcon -export const PlayIcon = _PlayIcon -export const PlugIcon = _PlugIcon -export const PlusIcon = _PlusIcon -export const RadioButtonIcon = _RadioButtonIcon -export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon -export const ReceiptTextIcon = _ReceiptTextIcon -export const ReplyIcon = _ReplyIcon -export const ReportIcon = _ReportIcon -export const RestoreIcon = _RestoreIcon -export const RightArrowIcon = _RightArrowIcon -export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon -export const RotateClockwiseIcon = _RotateClockwiseIcon -export const SaveIcon = _SaveIcon -export const ScaleIcon = _ScaleIcon -export const ScanEyeIcon = _ScanEyeIcon -export const SearchIcon = _SearchIcon -export const SendIcon = _SendIcon -export const ServerIcon = _ServerIcon -export const SettingsIcon = _SettingsIcon -export const ShareIcon = _ShareIcon -export const ShieldIcon = _ShieldIcon -export const SignalIcon = _SignalIcon -export const SkullIcon = _SkullIcon -export const SlashIcon = _SlashIcon -export const SortAscendingIcon = _SortAscendingIcon -export const SortDescendingIcon = _SortDescendingIcon -export const SparklesIcon = _SparklesIcon -export const SpinnerIcon = _SpinnerIcon -export const StarIcon = _StarIcon -export const StopCircleIcon = _StopCircleIcon -export const SunIcon = _SunIcon -export const SunriseIcon = _SunriseIcon -export const TagIcon = _TagIcon -export const TagsIcon = _TagsIcon -export const TerminalSquareIcon = _TerminalSquareIcon -export const TransferIcon = _TransferIcon -export const TrashIcon = _TrashIcon -export const UndoIcon = _UndoIcon -export const RedoIcon = _RedoIcon -export const UnknownIcon = _UnknownIcon -export const UnknownDonationIcon = _UnknownDonationIcon -export const UpdatedIcon = _UpdatedIcon -export const UnlinkIcon = _UnlinkIcon -export const UnplugIcon = _UnplugIcon -export const UploadIcon = _UploadIcon -export const UserIcon = _UserIcon -export const UserPlusIcon = _UserPlusIcon -export const UserXIcon = _UserXIcon -export const UsersIcon = _UsersIcon -export const VersionIcon = _VersionIcon -export const WikiIcon = _WikiIcon -export const WindowIcon = _WindowIcon -export const WorldIcon = _WorldIcon -export const WrenchIcon = _WrenchIcon -export const XIcon = _XIcon -export const XCircleIcon = _XCircleIcon -export const ZoomInIcon = _ZoomInIcon -export const ZoomOutIcon = _ZoomOutIcon -export const BoldIcon = _BoldIcon -export const ItalicIcon = _ItalicIcon -export const UnderlineIcon = _UnderlineIcon -export const StrikethroughIcon = _StrikethroughIcon -export const ListBulletedIcon = _ListBulletedIcon -export const ListOrderedIcon = _ListOrderedIcon -export const TextQuoteIcon = _TextQuoteIcon -export const Heading1Icon = _Heading1Icon -export const Heading2Icon = _Heading2Icon -export const Heading3Icon = _Heading3Icon -export const CubeIcon = _CubeIcon -export const CloudIcon = _CloudIcon -export const CogIcon = _CogIcon -export const CPUIcon = _CPUIcon -export const LoaderIcon = _LoaderIcon -export const ImportIcon = _ImportIcon -export const CardIcon = _CardIcon -export const TimerIcon = _TimerIcon + +// Skin Models +export { default as ClassicPlayerModel } from './models/classic-player.gltf?url' +export { default as SlimPlayerModel } from './models/slim-player.gltf?url' + +export * from './generated-icons' diff --git a/packages/assets/models/classic-player.fbx b/packages/assets/models/classic-player.fbx new file mode 100644 index 000000000..9bc8e3936 Binary files /dev/null and b/packages/assets/models/classic-player.fbx differ diff --git a/packages/assets/models/classic-player.gltf b/packages/assets/models/classic-player.gltf new file mode 100644 index 000000000..6ff9b9ab3 --- /dev/null +++ b/packages/assets/models/classic-player.gltf @@ -0,0 +1,2466 @@ +{ + "asset": { + "generator": "FBX2glTF v0.13.1", + "version": "2.0" + }, + "scene": 0, + "buffers": [ + { + "byteLength": 68020, + "uri": "data:application/octet-stream;base64,AAAAAAAAAACJiAg9iYiIPc3MzD2JiAg+q6oqPs3MTD7v7m4+iYiIPpqZmT6rqqo+vLu7Ps3MzD7e3d0+7+7uPgAAAD+JiAg/ERERP5qZGT8iIiI/q6oqPzMzMz+8uzs/REREP83MTD9VVVU/3t1dP2ZmZj/v7m4/d3d3PwAAgD9ERIQ/iYiIP83MjD8REZE/VVWVP5qZmT/e3Z0/IiKiP2Zmpj+rqqo/7+6uPzMzsz93d7c/vLu7PwAAwD9ERMQ/iYjIP83MzD8REdE/VVXVP5qZ2T/e3d0/IiLiP2Zm5j+rquo/7+7uPzMz8z93d/c/vLv7PwAAAEAiIgJAREQEQGZmBkCJiAhAq6oKQM3MDEDv7g5AERERQDMzE0BVVRVAd3cXQJqZGUC8uxtA3t0dQAAAIEAiIiJAREQkQGZmJkCJiChAq6oqQM3MLEDv7i5AERExQDMzM0BVVTVAd3c3QJqZOUC8uztA3t09QAAAQEDZubSiCtcjvdm5tCK6iSM4brgjvdm5tCKAoWw4cl4jvdm5tCLjx4U42csivdm5tCLAe4g4ZAMivdm5tCJ9eII42Achvdm5tCLFJmw499sfvdm5tCLgkkk4jYIevdm5tCIXQR84Tf4cvdm5tCKBUN03AVIbvdm5tCLvfWM3bYAZvdm5tCJuQJ20U4wXvdm5tCKG6nu3dngVvdm5tCLrqP+3p0cTvdm5tCJyVUO4j/wQvdm5tCILjoS4/ZkOvdm5tCLJa6i4tCIMvdm5tCLQH824eZkJvdm5tCLGi/K4DAEHvdm5tCLsSgy5MlwEvdm5tCLWkR+5va0Bvdm5tCKSEDO5ovD9vNm5tCL3vUa5gX34vNm5tCJekFq5nQfzvNm5tCJpf265fJTtvNm5tCJ7QoG5pSnovNm5tCLhTIu5u8zivNm5tCKsW5W5BoPdvNm5tCJwa5+5LFLYvNm5tCI0eqm5tT/TvNm5tCIAhLO5JVHOvNm5tCJeh725AYzJvNm5tCLBgMe50PXEvNm5tCIZb9G5MpTAvNm5tCKVTtu5eGy8vNm5tCLUHeW5RYS4vNm5tCJp2u65HOG0vNm5tCL1gfi5hIixvNm5tCIZCQG6BICuvNm5tCJwxAW6L82rvNm5tCLkcQq6bnWpvNm5tCJdEA+6Vn6nvNm5tCK+nhO6be2lvNm5tCLtGxi6OcikvNm5tCKEhhy6QRSkvNm5tCKi3SC6CtejvNm5tCLWHyW6QRSkvNm5tCLpSym6OcikvNm5tCKYYC26be2lvNm5tCKUXDG6Vn6nvNm5tCJAPjW6bnWpvNm5tCJlBDm6L82rvNm5tCJGrTy6BICuvNm5tCJKN0C6hIixvNm5tCLwoEO6HOG0vNm5tCJn6Ea6RYS4vNm5tCKQC0q6eGy8vNm5tCK3CE26MpTAvNm5tCJO3U+60PXEvNm5tCJ0h1K6AYzJvNm5tCKYBFW6JVHOvNm5tCL3UVe6tT/TvNm5tCLbbFm6LFLYvNm5tCIkUlu6BoPdvNm5tCJv/ly6u8zivNm5tCIdbl66pSnovNm5tCJBnV+6fJTtvNm5tCJeh2C6nQfzvNm5tCKsJ2G6gX34vNm5tCLHeGG6ovD9vNm5tCKzdGG6va0Bvdm5tCK4FGG6MlwEvdm5tCI7UWC6DAEHvdm5tCKzIV+6eZkJvdm5tCI+fF26tCIMvdm5tCKGVVu6/ZkOvdm5tCJcoFi6j/wQvdm5tCJHTVW6p0cTvdm5tCLpSVG6dngVvdm5tCIOgEy6U4wXvdm5tCLC1Ea6bYAZvdm5tCIYJkC6AVIbvdm5tCJ5STi6Tf4cvdm5tCLyBi+6jYIevdm5tCKCEyS699sfvdm5tCJZBRe62Achvdm5tCKGPwe6ZAMivdm5tCI9kOe52csivdm5tCLnu7W5cl4jvdm5tCKlqWK5brgjvdm5tCLZubSiCtcjvdm5tCI04z87a8cBNqkjLbq0/38/9PQwOwL+EjZhplS6vf9/P36mIjvWTyA2hlF8usX/fz/79RQ7h+MpNoz7kbrK/38/0uEHO/HqLzZ7tqW6z/9/P27Q9jpfnzI2G0W5utH/fz9uDt86W0AyNsCTzLrT/38/XHnIOpkSLzb/j9+61P9/PzsMszp+XSk2xSfyutT/fz8uwp46j2ohNngkArvT/38/s5aLOq2FFzZB8Qq70f9/PwoHczra+As2dXETu87/fz9HBlE6iB3+NZicG7vL/38/1h4xOv0f4jXlaSO7yP9/P15EEzolh8Q1vdAqu8T/fz/K0u45rNqlNTbIMbvB/38/1v+6OR+chjVSRzi7vf9/P8fzijm9jE41xUQ+u7n/fz8fIT05lJcQNfy2Q7u1/38/wOLWOKpdqDQblEi7sf9/P5NWAjjaj9AzydFMu67/fz/UvQ64vmXos05lULur/38/Z0LDuDQjobQ0Q1O7qf9/PxSEGbnT6P+0ol9Vu6f/fz/YnEu5ob8qtdytVrum/38/ZzJ4uQ2SULVsIFe7pf9/P5zFj7llG3G1+6dWu6X/fz8x+KC5Hh+GtQZNVbum/38/iNWvuUAFkbUEI1O7qP9/P9iCvLkhVpm1PTtQu6r/fz9bJce5PTKfteakTLut/38/i+LPuVzCorXRbUi7sP9/PzXg1rlDNaS1YKJDu7T/fz8LRNy5g72jtbBNPru4/38/3DPguYmQobVIeji7vP9/P1XV4rkZ5J21OzEyu8D/fz8KTuS5aO6YtX97K7vF/38/WcPkudrjkrXnYCS7yv9/P/It4blbBYq1WOkcu87/fz/DA9e5FXl6tbUbFbvT/38/kQbHuSk7W7WU/gy72P9/P6nVsbnHODi115gEu93/fz9495e52iQTtRLg97rh/38/XbtzuX4O27QTFea65v9/P9rSL7mFgZG0ZdvTuur/fz/Au8m4HUgYtNk+wbru/38/33mbt+mz07JBSq668f9/P/bphzjcnqQz9gibuvT/fz8j3CA5Y08qNAmFh7r3/38/OnCBOcYtajSPk2e6+f9/P9XRtDlbdIc06sU/uvv/fz8Maeo5ZOyKNO63F7r8/38/TwgROqqsfDQmAN+5/P9/P9rSLTp6ckE0SHOOufz/fz+sgks6dRfFM/Ps97j7/38/9glqOuEDBbMhfxE4+f9/PymthDr6wkq0ZJ1DOff/fz98s5Q62zLNtNuhsDn0/38/ZBGlOmkDJLU/Xf458f9/P+DAtTq0x2q1ClglOu3/fz9pvMY6IUydtfaeSjro/38/Wv7XOnl8ybUfzm464v9/P0eB6TqXm/m1pdOIOtz/fz/MP/s6n5gWtoNxmTrW/38/95kGO4/MMbYbFKk6z/9/P2OsDzvg/k222oW3Osf/fz8v1Bg7FaJqtnODxDrA/38/Kg4iO798g7botc86uP9/P2lXKzuNA5G2wqnYOrD/fz8lrDQ7gjSdtk6/3jqo/38/uAg+O6IPp7YcDeE6of9/P4loRzvxzK62PmjgOpr/fz+LxlA7WVu1trRg3jqT/38/zRxaO5RsurZAzto6jP9/P65jYzvTqL22coXVOoX/fz+rkWw7FbC+tkNZzjp+/38/uJp1OzUdvbb5HcU6d/9/P5Jtfjuoibi2I625OnH/fz/BeIM72JawtrPsqzpr/38/5X+HO6T4pLbJ1ps6Zf9/P0UqizvJgZW2xIKJOl//fz/8MI47gyiCtltVajpb/38/8L2POzzAVbYNVz46Wv9/Pzxpjzv7UCG20foPOl3/fz/9Oo47fvzVtSSTwDlh/38/+MuLO3bcUrXdEEE5Z/9/P8t+hztJwVSy9vtINnH/fz8OlYA7w8Y2NYbyNbl//38/Na1tO3dApzULJbS5kf9/P4HSVjt+q94126wEuqT/fz804z87a8cBNqkjLbq0/38/M7w+u+iO5qFwOTqguf9/PxUmALvAwMcgkieBoOD/fz9t7IO6W0XOH3GJBqD4/38/AguIuOp/eZyCtsOgAACAP76MYzpXBQ8gxMG/Hvr/fz/KtOo6miF5oLSv8iDl/38/ug0xO3b/q6C3D7igw/9/PxPeazuKF4Og8ThjIJP/fz9y2JI7NH9+oSMMoh1Y/38/zjKvO7P5kKFzqh8fEP9/PzHvyjsi3ZchCLMzoL7+fz/l/OU7vjTOodOxYyFj/n8/fCUAPFu69qFnIB4g//1/P7/kDDxGp5wiKPE0IZT9fz8TMxk899LzIcIxfiEj/X8/hwglPEYtAqJcwo+frfx/P1tcMDyh270hxPnsHzT8fz+7Jjs8AsWGoWgNkCG5+38/kl9FPOqb2CLobFQhP/t/P1f/Tjzmnj8iaQ/uIcX6fz8Y/1c8RWL7IrrwViFO+n8/WFhgPK1B/CHBVIWh2/l/P2oFaDwNSVwiIFVfoG75fz/+AG88A8ptIsoW/R8H+X8/3UZ1PHk6hqG5xXmhqPh/P2HTejw1Oggj3ib8oFL4fz8DpH880tUQI8doBSEG+H8/UtuBPDF3baKVeFShxPd/Py6FgzxEmBQiibCSII73fz9uz4Q8E5gVIjZXlKBj938/V7qFPGybcKKr4SOhRPd/P4xGhjwVW2ei6jzMoDL3fz8FdYY83+N6Ii8NDKAs938/PR+GPMUdWKKSBy6hN/d/Py4qhTxeY1Ci5ECwIFf3fz8LpoM8H/oRI5JsB6CJ938/p6CBPN7IECMLSk4hy/d/P65LfjyNfXGidolXoRv4fz+2f3g8Oog4IoYmm6F2+H8/me9xPLGs1yFPdUoh2/h/P/6qajyEMAkj/lJ/Hkf5fz9qwGI8/TQGIySzHCG5+X8/TTxaPOGcACOQ+IIgMPp/P0AqUTx0+UKiSuAVIKn6fz+mlEc8gysWomc1HiEj+38/DoU9PHKN4Z9flg+hnvt/PyYEMzyxcdIiWh/1nxf8fz8cGig85krHIghnjSCN/H8/f84cPPcTqaHyl+QgAP1/P1ooETwEqOyhLdELoG79fz+aLgU8HiUEolc/15/W/X8/ds7xOzFL06GCuR+gN/5/Pz+x2Dv3SNAhXJ73n5H+fz9rEb87G+MgoBS0YqDj/n8/GvqkO53jMyKQBl6fK/9/Py53ijtzTheh5s0BoGr/fz/4Jl87VODzICyQAKCf/38/H7YoO+wWMB5ggg+gyP9/P6Vi4zrcL5UfeGVqoOf/fz+LwGg62aELIIC3BKD5/38/298SOEVm4J3YQbKgAACAPwqRV7oKsvOfKzc5oPr/fz8Tgty6PYsjoJiozx7o/38/960mu+Ighx4lKq+gyv9/PycLX7tQqQQgtBpCoJ//fz9HmYu7ug0OoeClASFo/38/ln6nuwhfM6ENVZAgJf9/P4gbw7sG6uohSuLxH9f+fz9fVd671zEHIhrGCSB+/n8/nwj5u9VzWSGz//igG/5/P3yFCbxQdycidNyXH7H9fz89Exa8OoG5IRP/Jh1A/X8/egkivOSgxKHuJ9Sfy/x/P5M3LbxHfgEiUB+pHlb8fz/TVje898mwIUGvvKDm+38/uvk/vDe8maHUa4qegPt/PzFlRrxZviYgV49hoDL7fz+dIkm8CqE6IrqKUB4Q+38/qoVIvHfNCyHVYcUgF/t/P/qERrxZ9D4ir397oDD7fz/i1kK8VSeCH81C1aBe+38/1yI9vBC4tCHjaQ+govt/P40FNbwqF6WhKeVoHwD8fz+GISq8sjSpoUpaCaB3/H8/rkQcvHrdJCIrepefBf1/P/mhC7zEXzAiYpc/oJ/9fz/m9vG7iM8cIk0iC6A3/n8/ZvrKu893jSFjfJifvv5/PxigpLsQDIMhiuvYHiz/fz94gIC7R006H4TTyKB//38/M7w+u+iO5qFwOTqguf9/P7afYrxKyF45Vpt7PADyfz/7y0a8I8M5OfUrbzwx9H8/pCorvDx4FjkFA2E8PvZ/P/PGD7yaN+s4gGFRPCD4fz89V+m7E4WvOMeLQDzQ+X8/ZMqzu8WGdTiRyS48Sft/P2L6fbs5Kxs4V2UcPIb8fz+oDha7AGehNxmsCTyE/X8/1QRAuiBpsjbl2e07Qv5/P9z4TzoHP6O2BfHIO7/+fz9LwRU7kVhBt4BBpTv//n8/iRl1O3qve7f0b4M7BP9/PwLqqDueIoS3eUFIO9P+fz8v4dU7z4Rwt3LwDzty/n8/kK0APNNiP7ciX7466f1/P/6fFTyXIQG3Ve1cOj79fz+3uyk8jhuGtsBByjl6/H8/kfU8PNeImbU9/s84pPt/Pz9BTzzFX+Mi4kJSIcL6fz9tk2A89RUdtaYMMzjY+X8/1+BwPNRQJLa5nC456vh/P+cOgDx0yL+2JKy/Of33fz+EH4c86povt0dTJjoS938/zJyNPHdsjLca0H06LfZ/PyKBkzzrys23Oo2yOlH1fz8yx5g8AckNuMaJ7Tp/9H8/o2mdPE+POrgurBc7ufN/PzJjoTz/wmy4Z747OwPzfz+6rqQ87OWRuJzAYjtd8n8/LkenPHl5r7glPoY7yfF/P6YnqTy/r864fF2cO0jxfz9FS6o8IhDvuOelszva8H8/Xq2qPMIJCLkO/8s7gfB/P0wfqjyMbRi5hVHlO0Pwfz/Oiag8KUUoueWH/zsj8H8/sQemPOxJN7lvRQ08GvB/P9CvojxBOUW5uCIbPCPwfz++lZ48AtRRudFRKTw48H8/a8qZPJvcXLl9xzc8U/B/P+lclDzsGma51HlGPHHwfz8rWo48IVltuTNeVTyL8H8/L86HPHFmcrkkamQ8n/B/P0PDgDyaFXW5WJNzPKnwfz8AhnI8Vj11uTpngTyk8H8/+qtiPIy5crlgCIk8jvB/P0wIUjwZam25KKeQPGXwfz9UqUA8TjRludA9mDwm8H8/n5wuPHkCWrkGxp88z+9/P7LuGzwhxEu5BjmnPGDvfz82qwg8/246uQCQrjzW7n8/TLvpO1T/JblYw7U8M+5/P9ofwTvbdg65Asu8PHTtfz83mZc7WMDnuDSewzyc7H8/IXZaO6marLhCM8o8q+t/P5QvBDsiY1e4q3/QPKPqfz9uGDI60ECVt7d31jyG6X8/uIkxujGqmDcHDtw8Vuh/PztiBrvwhWw4rTPhPBjnfz/OVWG7B2TKOF/X5TzQ5X8/IIueuyXqEDk55ek8hOR/Pz+9zLt62D058UXtPDnjfz89Lfu7T3RrOaDd7zz54X8/LOEUvM+JjDnEivE8zOB/P24yLLxv9KI5BCTyPL7ffz81fUO8s+i4OaIF8jy53n8/urFavB6OzjnQqfE8ot1/P+S/cbwxweM5pgzxPHvcfz8fSYS8M1j4OQ8q8DxG238/WomPvPQTBjrv/e48CNp/P/qRmrzdfg86DoTtPMTYfz+HUaW86lAYOkC46zyA138/u7GvvDVqIDqDluk8QtZ/P8GUubzepCc68RrnPBTVfz9w0sK8m9MtOhtC5Dz/038/XDDLvPy7Mjr5COE8FdN/PwtU0ryiDDY6Jm3dPG3Sfz/gote8tUU3Oght2Twu0n8/TefZvHd0NToOCNU8oNJ/Py6u2bwwMTE6TT7QPKnTfz+V5ti8CyosOr8Qyzze1H8/olzXvKRCJjrigcU8R9Z/P/TH1Lw9Vh86V5W/PPLXfz9vx9C83DcXOgNQuTzt2X8/X+bKvGm5DTqFt7I8Sdx/P0q6wrxowwI6iNKrPA7ffz/3ILi8qPfsOR2opDwy4n8/pnOrvDy30jnuP508kOV/P+1onbxNE7g5fKGVPPbofz8iu468fDOeOabUjTw57H8/xt1/vLnXhTne4IU8Qe9/P7afYrxKyF45Vpt7PADyfz+2n2K8SsheuVabe7wA8n8//ctGvO+TObkt7268NPR/P6cqK7zjSha5Nb9gvEL2fz/zxg+8rwbruPU1Ubwi+H8/PVfpu21/r7iXhUC80Pl/P2PKs7vEsHW4d+cuvEf7fz9f+n27DGIbuJqcHLyE/H8/pg4Wu6myobeh7Am8gv1/P9UEQLpYvrK2gUvuu0D+fz/b+E86/najNuo1ybu+/n8/S8EVOzVfQTcuR6W7//5/P4oZdTvmN3s3gzGDuwT/fz8B6qg7dISDN8JRR7vT/n8/MOHVO+J+bjd/ug67c/5/P5CtADwF4zw3ueK7uun9fz/+nxU8eHP9NqvPWLo//X8/trspPKu9gjbfLcW5evx/P5H1PDxJjpQ1mT/JuKT7fz8/QU88la/jIq0eqSHC+n8/bZNgPNcqJTXNQjy42Pl/P9fgcDzxxCw2cJg3uer4fz/nDoA8UabJNouIybn9938/hB+HPLWjODfa4S66Evd/P8ycjTzopZM3dm+Fuiz2fz8igZM8SWHYN9O8u7pP9X8/MMeYPGAUFTg/wvm6fPR/P59pnTxgKEQ4xHkfu7Xzfz8tY6E8PvF4OA9nRbv88n8/s66kPHRnmTgFa267U/J/PyRHpzyIgLg4KiaNu7rxfz+YJ6k831HZON5opLs08X8/NEuqPKdc+zjr47y7wPB/P0etqjxpCQ85u33Wu1/wfz8vH6o8DEUgObEd8bsX8H8/rImoPE3tMDmiVga87e9/P4cHpjzZt0A5/IkUvNjvfz+fr6I8sF5POdodI7zT738/g5WePG6fXDm+BzK82e9/PynKmTxOOWg51DtBvOTvfz+fXJQ8WPFxObOvULzv738/11mOPOmOeTksWGC89e9/P9PNhzy83n45QCpwvPPvfz/gwoA8m9iAOYoNgLzl738/LoVyPHvtgDleD4i8x+9/Px+rYjwCNn85+BSQvJbvfz9rB1I8lqB5ORUYmLxR738/bqhAPKT+cDmmEqC89O5/P7mbLjxcOWU5Bf6nvH/ufz/Q7Rs8Zz9WORfTr7zv7X8/YKoIPAEGRDmxire8RO1/P7656TsyiS450Ry/vH/sfz93HsE70soVOQSBxrye638/DZiXO/ur8zgMrs28o+p/P1R0Wjtve7U4vJnUvJDpfz9sLgQ7N3diOAw527xl6H8/xxYyOg7unDemf+G8J+d/P/2HMbpUhKC3fV/nvNflfz/bYAa7MrB4uODI7Lx75H8/aFNhu9jM1LifqfG8F+N/P2GJnrstXhi50uz1vLLhfz/tusy7EJxHufx5+bxS4H8/VCr7u2GQd7nJM/y8At9/P2zfFLwuxJO5+/b9vMrdfz9lMCy88FWruRuY/ry43H8/53pDvNtnwrmyc/68tdt/PyyvWrxeHtm5tAX+vKPafz8avXG8tlHvufpJ/byG2X8/n0eEvNRpArpDPPy8X9h/P8KHj7xKugy6UNj6vDLXfz9QkJq8rn4WuuMZ+bwD1n8/z0+lvOKYH7ra/Pa82NR/P/qvr7zZ5ie6aX30vLfTfz//krm8k0AvuvaX8byo0n8/s9DCvNF2NbqPSe68ttF/P6kuy7wNTTq604/qvPLQfz9pUtK8qm89ujVp5rxy0H8/V6HXvL9bProg1eG8XNB/P+Tl2bzMGDy6RNTcvPnQfz/rrNm8jEk3uhto17wt0n8/eOXYvOSuMbovk9G8jtN/P6tb17zULSu6t1nLvCPVfz8ix9S8OaMjuiXBxLz31n8/w8bQvBvkGro90L28Gtl/P9flyrznxBC6e462vJvbfz/hucK80TEFumEEr7yD3n8/qiC4vHur8Lm1Oqe8x+F/P3Fzq7xhXtW5wDqfvELlfz/MaJ28ANO5uWoNl7zB6H8/ELuOvFQ1n7nRu468Gex/P7jdf7xFRYa5cU6GvDLvfz+2n2K8SsheuVabe7wA8n8/rLoTppEWfz/Gs6y9RGyTpNJqgyQwEn8/3E+uva/lcKQ49oEk4A1/Px3ir727W4ikmTiZpIEKfz/sGbG9E3eEpClipqVpCH8/TNqxvSRTYqTNV4IkSAd/P9tBsr1yVIKkVCCCJMIGfz/GcbK9uK6EpEn+l6SeBn8/tn6yvQSWaqQmooMkYQl/PzSBsb3kAGikppCDJHMOfz98rK+9mMVrpEhqpqVfFH8/74KtvfnWbaQVAoQktBp/PyQrq72o0mak40+EJD4hfz/ntqi91YFipNCLE6bgJ38/FzCmvcYPi6QgRKaliC5/P+aco73gAHSkwwuEJCo1fz+jAaG9qRNzpBv5paW8O38/S2GevWlOYaQ4B4MkOkJ/Pz2+m73fpIqkqloTpp1Ifz9JGpm96liKpAgbpqXjTn8/9XaWvXVqgKTKUxOmCVV/P4XVk71/Z4+kdMalpQxbfz8UN5G9bmlopC74paXqYH8/npyOvXFPgqSs8aWlo2Z/P9YGjL2EFISk+s+VpDVsfz/Zdom9BMp2pLMbE6aecX8/Nu2GvYdgiqSOGROm33Z/P6BqhL0QbY2k5L6FJPZ7fz8B8IG9mDpzpBd6paXkgH8/zvt+vRnDbqTOb6WlqIV/Py8qer1Tr2+kBQ2GJEGKfz9obHW9vWh2pEgYhiSvjn8/JsRwvZNfeaTzeaWl8pJ/PxczbL2f/4Ok9ziGJAmXfz99ume9Cf19pLBNhiT2mn8/K1xjvdCqf6S00hKmtp5/P/EZX71obIykwMwSpkuifz+h9Vq9r86MpDTbhiS0pX8/VfFWvfiOeKReK6Wl8Kh/P0gPU712PICkv8yUpP+rfz//UU+94oiSpFu5Eqbhrn8/27tLvdWxj6T7tBKmlrF/P8pPSL3oWJCkgK8Sphu0fz9LEUW9PhuQpJxbhyRxtn8/lQNCvQvDfKRtO4cklrh/P9cqP73R24Kkgo+TpIi6fz+uizy9UfOGpEaeEqZGvH8/ISs6vaXtj6SemxKmzb1/PzAPOL3VVpCkWEOHJBm/fz/WPja9rUSIpLOUhyQnwH8/5ME0vYElgqQoyYck8sB/Px+iM73mgnykMrCHJHPBfz/46jK9dPeApAbupKWgwX8/PqoyvUA9h6RkLZOkccF/Pw/tMr30y4Wk6o4SpufAfz9TsTO9FROMpKBOhyQGwH8/CvE0vek1iKTS3KSlz75/PzKmNr05Q3ukFH6TpEW9fz/Lyji9IVKIpMa8k6Rqu38/jFg7vU+4i6SF8aSlPrl/P5dJPr2CrHykX/+GJMK2fz/pl0G9k0GGpBD9pKX5s38/Xj1FvdILeaQFeZSk4rB/P/IzSb3bkJCkUCCUpICtfz+kdU29u4SGpIMblKTSqX8/cfxRvVkXg6RiHqWl26V/P1fCVr0+JHOkSiulpZ2hfz8xwVu9c4ZzpE2PoSUYnX8//PJgvXZdkKR2h6ElT5h/P9pRZr0rA5CkIn6FJEaTfz+l12u9IZGJpKd0oSX/jX8/On5xvcEMkKTdfoUkfoh/PwFAd71UiYOklXulpceCfz9sFn29hutxpM9thSTefH8/4X2BvZkBfqTvtYQkyXZ/PxN1hL3nboekFDKXpI5wfz+8bYe9b0WSpNFflqQzan8/yWSKvS7LgqQHSYUkvmN/Pz1Xjb0lWW6kg2yWpDddfz8pQpC99IJ7pFkhhSSnVn8/kCKTvdUBaqRqL5ekFFB/P2P1lb0J74KkcoiDJIpJfz+it5i9v3GGpIrumKQQQ38/UmabvbTnlKTGRZmksTx/P2P+nb3wq5akxOeEJHg2fz/7fKC9pe1dpB/4oCVuMH8//N6ivQHHjqSx+KAlnyp/P3ohpb2ra42kzHaDJBYlfz9WQae9r/14pHa2gyTeH38/uDupvfZVcKQVcJikAxt/P5MNq70dRICkrLoTppEWfz/Gs6y9RGyTpAAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAImICD2JiIg9zczMPYmICD6rqio+zcxMPu/ubj6JiIg+mpmZPquqqj68u7s+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AACgPqcWIz8K16O9AACgPhTSIj8K16O9AACgPlyPIj8K16O9AACgPk5BIj8K16O9AACgPj3gIT8K16O9AACgPj12IT8K16O9AACgPpsLIT8K16O9AACgPgioID8K16O9AACgPotTID8K16O9AACgPnwXID8K16O9AACgPgAAID8K16O9AACgPpAzID8K16O9AACgPuzHID8K16O9AACgPoSmIT8K16O9AACgPtmpIj8K16O9AACgPjeoIz8K16O9AACgPk2AJD8K16O9AACgPrgeJT8K16O9AACgPgmUJT8K16O9AACgPq/6JT8K16O9AACgPrhVJj8K16O9AACgPp2mJj8K16O9AACgPvztJj8K16O9AACgPtsrJz8K16O9AACgPq9fJz8K16O9AACgPlOIJz8K16O9AACgPq+jJz8K16O9AACgPhSuJz8K16O9AACgPu5mJz8K16O9AACgPqKhJj8K16O9AACgPoZ7JT8K16O9AACgPiIZJD8K16O9AACgPk6mIj8K16O9AACgPrNVIT8K16O9AACgPp1fID8K16O9AACgPgAAID8K16O9AACgPqUzID8K16O9AACgPp3DID8K16O9AACgPiaRIT8K16O9AACgPs1uIj8K16O9AACgPsotIz8K16O9AACgPqyrIz8K16O9AACgPgrXIz8K16O9AACgPkDRIz8K16O9AACgPqTCIz8K16O9AACgPpKtIz8K16O9AACgPj6TIz8K16O9AACgPkF0Iz8K16O9AACgPtlQIz8K16O9AACgPvUoIz8K16O9AACgPi38Ij8K16O9AACgPpvJIj8K16O9AACgPlyPIj8K16O9AACgPhs/Ij8K16O9AACgPjPPIT8K16O9AACgPoBKIT8K16O9AACgPu7FID8K16O9AACgPqdZID8K16O9AACgPhcWID8K16O9AACgPgAAID8K16O9AACgPgAAID/9H6K9AACgPgAAID+Cq5y9AACgPgAAID9G+JK9AACgPgAAID/4gIS9AACgPgAAID/qumG9AACgPgAAID8LMzC9AACgPgAAID/e2+28AACgPgAAID8zT3W8AACgPgAAID/1+oa7AACgPgAAID/hepQkAACgPgAAID/hepQkAACgPgAAID+F65EkAACgPgAAID8pXI8kAACgPgAAID/Xo5AkAACgPgAAID8pXI8k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+c8RvTMz870K1yM8CtcjvTMz870K1yM8EoAvvTMz870K1yM8vAc4vTMz870K1yM80mc+vTMz870K1yM8PC9DvTMz870K1yM8eLlGvTMz870K1yM8+UVJvTMz870K1yM87QNLvTMz870K1yM8/BhMvTMz870K1yM8KqZMvTMz870K1yM8zMxMvTMz87046R08MoFHvTMz8707eA084mY4vTMz873H6+k7jPYgvTMz870v2647rxUDvTMz873wGWI7CjbCvDMz872zzOM6fSx3vDMz873m+f45Bk7iuzMz870AAAAAAAAAADMz8719uAk6GJHMOzMz873SaAI7xaFSPDMz871Vt4U7K92fPDMz8724mc87AZHTPDMz8726oQc8Y9EAPTMz8723nBw81jsUPTMz870K1yM8CtcjPTMz870K1yM8EoAvPTMz870K1yM8vAc4PTMz870K1yM80mc+PTMz870K1yM8PC9DPTMz870K1yM8eLlGPTMz870K1yM8+UVJPTMz870K1yM87QNLPTMz870K1yM8/BhMPTMz870K1yM8KqZMPTMz870K1yM8zMxMPTMz87046R08MoFHPTMz8707eA084mY4PTMz873H6+k7jPYgPTMz870v2647rxUDPTMz873wGWI7CjbCPDMz872zzOM6fSx3PDMz873m+f45Bk7iOzMz870AAAAAAAAAADMz8719uAk6GJHMuzMz873SaAI7xaFSvDMz871Vt4U7K92fvDMz8724mc87AZHTvDMz8726oQc8Y9EAvTMz8723nBw81jsUvTMz870K1yM8CtcjvTMz870K1yM8F20vvTMz870K1yM8Wdo3vTMz870K1yM8FCk+vTMz870K1yM81epCvTMz870K1yM8r3lGvTMz870K1yM8XhJJvTMz870K1yM8xeBKvTMz870K1yM8vgZMvTMz870K1yM8FKFMvTMz870K1yM8zMxMvTMz871ktBw8PJVFvTMz872XQwg81H8wvTMz872Gz9I7C64PvTMz872Jb4o7L5bOvDMz873fyQo7BZ51vDMz872Rdhc6kF/HuzMz870AAAAAAAAAADMz870AAAAA8+V1OzMz870AAAAA5wjUOzMz870AAAAA5GcHPDMz870AAAAAgXwYPDMz870AAAAAx7EgPDMz870AAAAAxnkjPDMz870AAAAA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d3f3P7y7+z8AAABAIiICQEREBEBmZgZAiYgIQKuqCkDNzAxA7+4OQBEREUAzMxNAVVUVQHd3F0CamRlAvLsbQN7dHUAAACBAIiIiQEREJEBmZiZAiYgoQKuqKkDNzCxA7+4uQBERMUAzMzNAVVU1QHd3N0CamTlAvLs7QN7dPUAAAEBA2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi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AAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACA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AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAABAAD4AwH8+AMB/PgDAfz4AwH8+AEAAPgBAAD4AQAA+ACDAPgDAfz4A4P8+AMB/PgDg/z4AQAA+ACDAPgBAAD4A4L8+AID/PQAggD4AgP89ACCAPgAAgDkA4L8+AACAOQDAfz4AAIA5AEAAPgAAgDkAQAA+AID/PQDAfz4AgP89ACCAPgDAfz4A4L8+AMB/PgDgvz4AQAA+ACCAPgBAAD4AAIA5AMB/PgCA/z0AwH8+AID/PQBAAD4AAIA5AEAAPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAkD4AALw/AACQvgAAkL4AALw/AACQvgAAkL4AAAJAAACQvgAAkD4AAAJAAACQvgAAkL4AALw/AACQPgAAkD4AALw/AACQPgAAkD4AAAJAAACQPgAAkL4AAAJAAACQPgAAkL4AALw/AACQvgAAkD4AALw/AACQvgAAkD4AALw/AACQPgAAkL4AALw/AACQPgAAkL4AAAJAAACQPgAAkD4AAAJAAACQPgAAkD4AAAJAAACQvgAAkL4AAAJAAACQvgAAkL4AALw/AACQvgAAkL4AALw/AACQPgAAkL4AAAJAAACQPgAAkL4AAAJAAACQvgAAkD4AALw/AACQPgAAkD4AALw/AACQvgAAkD4AAAJAAACQvgAAkD4AAAJAAACQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQID8AwH8+APA/PwDAfz4A8D8/AEAAPgAQID8AQAA+ABBgPwDAfz4A8H8/AMB/PgDwfz8AQAA+ABBgPwBAAD4A8F8/AID/PQAQQD8AgP89ABBAPwAAgDkA8F8/AACAOQDwPz8AAIA5ABAgPwAAgDkAECA/AID/PQDwPz8AgP89ABBAPwDAfz4A8F8/AMB/PgDwXz8AQAA+ABBAPwBAAD4AEAA/AMB/PgDwHz8AwH8+APAfPwBAAD4AEAA/AEAAPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAAD8AAEA/AAAAvgAAgD4AAEA/AAAAvgAAgD4AAMA/AAAAvgAAAD8AAMA/AAAAvgAAgD4AAEA/AAAAPgAAAD8AAEA/AAAAPgAAAD8AAMA/AAAAPgAAgD4AAMA/AAAAPgAAgD4AAEA/AAAAvgAAAD8AAEA/AAAAvgAAAD8AAEA/AAAAPgAAgD4AAEA/AAAAPgAAgD4AAMA/AAAAPgAAAD8AAMA/AAAAPgAAAD8AAMA/AAAAvgAAgD4AAMA/AAAAvgAAgD4AAEA/AAAAvgAAgD4AAEA/AAAAPgAAgD4AAMA/AAAAPgAAgD4AAMA/AAAAvgAAAD8AAEA/AAAAPgAAAD8AAEA/AAAAvgAAAD8AAMA/AAAAvgAAAD8AAMA/AAAAPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQMD8A4P8+APA/PwDg/z4A8D8/ACCgPgAQMD8AIKA+ABBQPwDg/z4A8F8/AOD/PgDwXz8AIKA+ABBQPwAgoD4A8E8/AOCfPgAQQD8A4J8+ABBAPwAggD4A8E8/ACCAPgDwPz8AIIA+ABAwPwAggD4AEDA/AOCfPgDwPz8A4J8+ABBAPwDg/z4A8E8/AOD/PgDwTz8AIKA+ABBAPwAgoD4AECA/AOD/PgDwLz8A4P8+APAvPwAgoD4AECA/ACCgPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAABD8AADw/AAAQvgAAcD4AADw/AAAQvgAAcD4AAMI/AAAQvgAABD8AAMI/AAAQvgAAcD4AADw/AAAQPgAABD8AADw/AAAQPgAABD8AAMI/AAAQPgAAcD4AAMI/AAAQPgAAcD4AADw/AAAQvgAABD8AADw/AAAQvgAABD8AADw/AAAQPgAAcD4AADw/AAAQPgAAcD4AAMI/AAAQPgAABD8AAMI/AAAQPgAABD8AAMI/AAAQvgAAcD4AAMI/AAAQvgAAcD4AADw/AAAQvgAAcD4AADw/AAAQPgAAcD4AAMI/AAAQPgAAcD4AAMI/AAAQvgAABD8AADw/AAAQPgAABD8AADw/AAAQvgAABD8AAMI/AAAQvgAABD8AAMI/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQMD8A8D8/APA/PwDwPz8A8D8/ABAQPwAQMD8AEBA/ABBQPwDwPz8A8F8/APA/PwDwXz8AEBA/ABBQPwAQED8A8E8/APAPPwAQQD8A8A8/ABBAPwAQAD8A8E8/ABAAPwDwPz8AEAA/ABAwPwAQAD8AEDA/APAPPwDwPz8A8A8/ABBAPwDwPz8A8E8/APA/PwDwTz8AEBA/ABBAPwAQED8AECA/APA/PwDwLz8A8D8/APAvPwAQED8AECA/ABAQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAgL4AAEA/AAAAvgAAAL8AAEA/AAAAvgAAAL8AAMA/AAAAvgAAgL4AAMA/AAAAvgAAAL8AAEA/AAAAPgAAgL4AAEA/AAAAPgAAgL4AAMA/AAAAPgAAAL8AAMA/AAAAPgAAAL8AAEA/AAAAvgAAgL4AAEA/AAAAvgAAgL4AAEA/AAAAPgAAAL8AAEA/AAAAPgAAAL8AAMA/AAAAPgAAgL4AAMA/AAAAPgAAgL4AAMA/AAAAvgAAAL8AAMA/AAAAvgAAAL8AAEA/AAAAvgAAAL8AAEA/AAAAPgAAAL8AAMA/AAAAPgAAAL8AAMA/AAAAvgAAgL4AAEA/AAAAPgAAgL4AAEA/AAAAvgAAgL4AAMA/AAAAvgAAgL4AAMA/AAAAPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQED8A8H8/APAfPwDwfz8A8B8/ABBQPwAQED8AEFA/ABAwPwDwfz8A8D8/APB/PwDwPz8AEFA/ABAwPwAQUD8A8C8/APBPPwAQID8A8E8/ABAgPwAQQD8A8C8/ABBAPwDwHz8AEEA/ABAQPwAQQD8AEBA/APBPPwDwHz8A8E8/ABAgPwDwfz8A8C8/APB/PwDwLz8AEFA/ABAgPwAQUD8AEAA/APB/PwDwDz8A8H8/APAPPwAQUD8AEAA/ABBQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAcL4AADw/AAAQvgAABL8AADw/AAAQvgAABL8AAMI/AAAQvgAAcL4AAMI/AAAQvgAABL8AADw/AAAQPgAAcL4AADw/AAAQPgAAcL4AAMI/AAAQPgAABL8AAMI/AAAQPgAABL8AADw/AAAQvgAAcL4AADw/AAAQvgAAcL4AADw/AAAQPgAABL8AADw/AAAQPgAABL8AAMI/AAAQPgAAcL4AAMI/AAAQPgAAcL4AAMI/AAAQvgAABL8AAMI/AAAQvgAABL8AADw/AAAQvgAABL8AADw/AAAQPgAABL8AAMI/AAAQPgAABL8AAMI/AAAQvgAAcL4AADw/AAAQPgAAcL4AADw/AAAQvgAAcL4AAMI/AAAQvgAAcL4AAMI/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQUD8A8H8/APBfPwDwfz8A8F8/ABBQPwAQUD8AEFA/ABBwPwDwfz8A8H8/APB/PwDwfz8AEFA/ABBwPwAQUD8A8G8/APBPPwAQYD8A8E8/ABBgPwAQQD8A8G8/ABBAPwDwXz8AEEA/ABBQPwAQQD8AEFA/APBPPwDwXz8A8E8/ABBgPwDwfz8A8G8/APB/PwDwbz8AEFA/ABBgPwAQUD8AEEA/APB/PwDwTz8A8H8/APBPPwAQUD8AEEA/ABBQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAgD4AAEA/AAAAvgAAgL4AAEA/AAAAvgAAgL4AAMA/AAAAvgAAgD4AAMA/AAAAvgAAgL4AAEA/AAAAPgAAgD4AAEA/AAAAPgAAgD4AAMA/AAAAPgAAgL4AAMA/AAAAPgAAgL4AAEA/AAAAvgAAgD4AAEA/AAAAvgAAgD4AAEA/AAAAPgAAgL4AAEA/AAAAPgAAgL4AAMA/AAAAPgAAgD4AAMA/AAAAPgAAgD4AAMA/AAAAvgAAgL4AAMA/AAAAvgAAgL4AAEA/AAAAvgAAgL4AAEA/AAAAPgAAgL4AAMA/AAAAPgAAgL4AAMA/AAAAvgAAgD4AAEA/AAAAPgAAgD4AAEA/AAAAvgAAgD4AAMA/AAAAvgAAgD4AAMA/AAAAPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAgoD4A4P8+AODfPgDg/z4A4N8+ACCgPgAgoD4AIKA+ABAAPwDg/z4A8B8/AOD/PgDwHz8AIKA+ABAAPwAgoD4A8A8/AOCfPgAg4D4A4J8+ACDgPgAggD4A8A8/ACCAPgDg3z4AIIA+ACCgPgAggD4AIKA+AOCfPgDg3z4A4J8+ACDgPgDg/z4A4P8+AOD/PgDg/z4AIKA+ACDgPgAgoD4AIIA+AOD/PgDgnz4A4P8+AOCfPgAgoD4AIIA+ACCgPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAiD4AADw/AAAQvgAAiL4AADw/AAAQvgAAiL4AAMI/AAAQvgAAiD4AAMI/AAAQvgAAiL4AADw/AAAQPgAAiD4AADw/AAAQPgAAiD4AAMI/AAAQPgAAiL4AAMI/AAAQPgAAiL4AADw/AAAQvgAAiD4AADw/AAAQvgAAiD4AADw/AAAQPgAAiL4AADw/AAAQPgAAiL4AAMI/AAAQPgAAiD4AAMI/AAAQPgAAiD4AAMI/AAAQvgAAiL4AAMI/AAAQvgAAiL4AADw/AAAQvgAAiL4AADw/AAAQPgAAiL4AAMI/AAAQPgAAiL4AAMI/AAAQvgAAiD4AADw/AAAQPgAAiD4AADw/AAAQvgAAiD4AAMI/AAAQvgAAiD4AAMI/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAgoD4A8D8/AODfPgDwPz8A4N8+ABAQPwAgoD4AEBA/ABAAPwDwPz8A8B8/APA/PwDwHz8AEBA/ABAAPwAQED8A8A8/APAPPwAg4D4A8A8/ACDgPgAQAD8A8A8/ABAAPwDg3z4AEAA/ACCgPgAQAD8AIKA+APAPPwDg3z4A8A8/ACDgPgDwPz8A4P8+APA/PwDg/z4AEBA/ACDgPgAQED8AIIA+APA/PwDgnz4A8D8/AOCfPgAQED8AIIA+ABAQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAJmZeT4AAAAA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AAAQvs3MhL4AAIC8AAAQvs3MhL4AAIC8AAAQPs3MhL4AAEQ/AAAQPs3MhL4AAEQ/AAAQvjMzszwAAIC8AAAQPjMzszwAAIC8AAAQvjMzszwAAEQ/AAAQvjMzszwAAEQ/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAACAgD0A8H8/AID/PQDwfz8AgP89ABBQPwCAgD0AEFA/AEBAPgDwfz8AwH8+APB/PwDAfz4AEFA/AEBAPgAQUD8AwD8+APBPPwBAAD4A8E8/AEAAPgAQQD8AwD8+ABBAPwCA/z0AEEA/AICAPQAQQD8AgIA9APBPPwCA/z0A8E8/AEAAPgDwfz8AwD8+APB/PwDAPz4AEFA/AEAAPgAQUD8AAIA5APB/PwAAfz0A8H8/AAB/PQAQUD8AAIA5ABBQPwAAAQACAAAAAgADAAQABQAGAAUABAAHAAgACQAKAAkACAALAAwADQAOAAwADgAPABAAEQASABMAEgARABQAFQAWABcAFQAUAAAAoD4AAAAAAAAAPQAAoL4AAAAAAAAAPQAAoL4AAAAAAAAAvQAAoD4AAAAAAAAAvQAAoL4AAIA/AAAAPQAAoD4AAIA/AAAAvQAAoL4AAIA/AAAAvQAAoD4AAIA/AAAAPQAAoL4AAAAAAAAAvQAAoL4AAIA/AAAAPQAAoL4AAIA/AAAAvQAAoL4AAAAAAAAAPQAAoD4AAIA/AAAAPQAAoL4AAIA/AAAAPQAAoL4AAAAAAAAAPQAAoD4AAAAAAAAAPQAAoD4AAIA/AAAAPQAAoD4AAAAAAAAAPQAAoD4AAIA/AAAAvQAAoD4AAAAAAAAAvQAAoD4AAAAAAAAAvQAAoL4AAIA/AAAAvQAAoD4AAIA/AAAAvQAAoL4AAAAAAAAAvQAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAMD4AAAAAAACoPgAAAAAAAKg+AAAAPQAAMD4AAAA9AAAwPgAAAAAAAIA8AAAAPQAAMD4AAAA9AACAPAAAAAAAADA+AAAIPwAAQD4AAAA9AAAwPgAAAD0AAEA+AAAIPwAAsD4AAAA9AABAPgAAAD0AAEA+AAAIPwAAsD4AAAg/AAAAAAAAAD0AAAAAAAAIPwAAgDwAAAA9AACAPAAACD8AAIA8AAAIPwAAMD4AAAA9AACAPAAAAD0AADA+AAAIPw==" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteLength": 4, + "byteOffset": 0 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 4 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 368 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 1460 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 2916 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 4372 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 5828 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 7284 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 8740 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 10196 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 11652 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 12016 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 13108 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 14564 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 16020 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 17112 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 18568 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 19660 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 21116 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 22572 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 23664 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 25120 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 26212 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 27668 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 28032 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 29124 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 30580 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 32036 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 33492 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 34948 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 36404 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 37496 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 38952 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 40408 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 40772 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 41864 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 43320 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 44776 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 46232 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 47688 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 49144 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 50600 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 51692 + }, + { + "buffer": 0, + "byteLength": 104, + "byteOffset": 53148 + }, + { + "buffer": 0, + "byteLength": 312, + "byteOffset": 53252 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 53564 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 53980 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 54396 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 54812 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 55228 + }, + { + "buffer": 0, + "byteLength": 312, + "byteOffset": 55644 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 55956 + }, + { + "buffer": 0, + "byteLength": 312, + "byteOffset": 56372 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 56684 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 57100, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 57172, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 57460, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 57748, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 57940, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 58012, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 58300, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 58588, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 58780, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 58852, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 59140, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 59428, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 59620, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 59692, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 59980, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 60268, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 60460, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 60532, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 60820, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 61108, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 61300, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 61372, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 61660, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 61948, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 62140, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 62212, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 62500, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 62788, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 62980, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 63052, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 63340, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 63628, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 63820, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 63892, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 64180, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 64468, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 64660, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 64732, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 65020, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 65308, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 65500, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 65572, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 65860, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 66148, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 66340, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 66412, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 66700, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 66988, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 67180, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 67252, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 67540, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 67828, + "target": 34962 + } + ], + "scenes": [ + { + "name": "Root Scene", + "nodes": [0] + } + ], + "accessors": [ + { + "componentType": 5126, + "type": "SCALAR", + "count": 1, + "bufferView": 0, + "byteOffset": 0, + "min": [0.0], + "max": [0.0] + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 1, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 2, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 3, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 4, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 5, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 6, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 7, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 8, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 9, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 10, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 11, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 12, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 13, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 14, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 15, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 16, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 17, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 18, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 19, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 20, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 21, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 22, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 23, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 24, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 25, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 26, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 27, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 28, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 29, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 30, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 31, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 32, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 33, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 34, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 35, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 36, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 37, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 38, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 39, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 40, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 41, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 42, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 26, + "bufferView": 43, + "byteOffset": 0, + "min": [0.0], + "max": [0.833333313465118] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 26, + "bufferView": 44, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 45, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 46, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 47, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 48, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 49, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 26, + "bufferView": 50, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 51, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 26, + "bufferView": 52, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 53, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 54, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 55, + "byteOffset": 0, + "min": [-0.25, 1.5, -0.25], + "max": [0.25, 2.0, 0.25] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 56, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 57, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 58, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 59, + "byteOffset": 0, + "min": [-0.28125, 1.46875, -0.28125], + "max": [0.28125, 2.03125, 0.28125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 60, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 61, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 62, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 63, + "byteOffset": 0, + "min": [0.25, 0.75, -0.125], + "max": [0.5, 1.5, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 64, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 65, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 66, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 67, + "byteOffset": 0, + "min": [0.234375, 0.734375, -0.140625], + "max": [0.515625, 1.515625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 68, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 69, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 70, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 71, + "byteOffset": 0, + "min": [-0.5, 0.75, -0.125], + "max": [-0.25, 1.5, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 72, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 73, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 74, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 75, + "byteOffset": 0, + "min": [-0.515625, 0.734375, -0.140625], + "max": [-0.234375, 1.515625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 76, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 77, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 78, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 79, + "byteOffset": 0, + "min": [-0.25, 0.75, -0.125], + "max": [0.25, 1.5, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 80, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 81, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 82, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 83, + "byteOffset": 0, + "min": [-0.265625, 0.734375, -0.140625], + "max": [0.265625, 1.515625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 84, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 85, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 86, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 87, + "byteOffset": 0, + "min": [-0.00624999962747097, 0.0, -0.125], + "max": [0.243749991059303, 0.75, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 88, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 89, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 90, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 91, + "byteOffset": 0, + "min": [-0.021874999627471, -0.015625, -0.140625], + "max": [0.259375005960464, 0.765625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 92, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 93, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 94, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 95, + "byteOffset": 0, + "min": [-0.243749991059303, 0.0, -0.125], + "max": [0.00624999962747097, 0.75, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 96, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 97, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 98, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 99, + "byteOffset": 0, + "min": [-0.259375005960464, -0.015625, -0.140625], + "max": [0.021874999627471, 0.765625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 100, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 101, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 102, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 103, + "byteOffset": 0, + "min": [-0.3125, 0.0, -0.03125], + "max": [0.3125, 1.0, 0.03125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 104, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 105, + "byteOffset": 0 + } + ], + "images": [ + { + "name": "steve.png", + "uri": "steve.png" + }, + { + "name": "cape", + "uri": "cape" + } + ], + "samplers": [{}], + "textures": [ + { + "name": "steve", + "sampler": 0, + "source": 0 + }, + { + "name": "cape", + "sampler": 0, + "source": 1 + } + ], + "materials": [ + { + "name": "Mat", + "alphaMode": "BLEND", + "doubleSided": false, + "extras": { + "fromFBX": { + "shadingModel": "Lambert", + "isTruePBR": false + } + }, + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0, + "texCoord": 0 + }, + "baseColorFactor": [1.0, 1.0, 1.0, 1.0], + "metallicFactor": 0.200000002980232, + "roughnessFactor": 0.800000011920929 + } + }, + { + "name": "cape", + "alphaMode": "BLEND", + "doubleSided": false, + "extras": { + "fromFBX": { + "shadingModel": "Lambert", + "isTruePBR": false + } + }, + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 1, + "texCoord": 0 + }, + "baseColorFactor": [1.0, 1.0, 1.0, 1.0], + "metallicFactor": 0.200000002980232, + "roughnessFactor": 0.800000011920929 + } + } + ], + "meshes": [ + { + "name": "Head_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 56, + "POSITION": 55, + "TEXCOORD_0": 57 + }, + "indices": 54 + } + ] + }, + { + "name": "Hat_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 60, + "POSITION": 59, + "TEXCOORD_0": 61 + }, + "indices": 58 + } + ] + }, + { + "name": "Right_Arm_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 64, + "POSITION": 63, + "TEXCOORD_0": 65 + }, + "indices": 62 + } + ] + }, + { + "name": "Right_Arm_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 68, + "POSITION": 67, + "TEXCOORD_0": 69 + }, + "indices": 66 + } + ] + }, + { + "name": "Left_Arm_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 72, + "POSITION": 71, + "TEXCOORD_0": 73 + }, + "indices": 70 + } + ] + }, + { + "name": "Left_Arm_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 76, + "POSITION": 75, + "TEXCOORD_0": 77 + }, + "indices": 74 + } + ] + }, + { + "name": "Body_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 80, + "POSITION": 79, + "TEXCOORD_0": 81 + }, + "indices": 78 + } + ] + }, + { + "name": "Body_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 84, + "POSITION": 83, + "TEXCOORD_0": 85 + }, + "indices": 82 + } + ] + }, + { + "name": "Right_Leg_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 88, + "POSITION": 87, + "TEXCOORD_0": 89 + }, + "indices": 86 + } + ] + }, + { + "name": "Right_Leg_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 92, + "POSITION": 91, + "TEXCOORD_0": 93 + }, + "indices": 90 + } + ] + }, + { + "name": "Left_Leg_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 96, + "POSITION": 95, + "TEXCOORD_0": 97 + }, + "indices": 94 + } + ] + }, + { + "name": "Left_Leg_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 100, + "POSITION": 99, + "TEXCOORD_0": 101 + }, + "indices": 98 + } + ] + }, + { + "name": "Cape_2", + "primitives": [ + { + "material": 1, + "mode": 4, + "attributes": { + "NORMAL": 104, + "POSITION": 103, + "TEXCOORD_0": 105 + }, + "indices": 102 + } + ] + } + ], + "animations": [ + { + "name": "CINEMA_4D_Main", + "channels": [], + "samplers": [] + }, + { + "name": "idle", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 7, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 1, + "interpolation": "LINEAR", + "output": 2 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 3 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 4 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 5 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 6 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 7 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 8 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 9 + } + ] + }, + { + "name": "idle_sub_1", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "translation" + } + }, + { + "sampler": 4, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 12, + "path": "translation" + } + }, + { + "sampler": 6, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 7, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 8, + "target": { + "node": 19, + "path": "translation" + } + }, + { + "sampler": 9, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 10, + "target": { + "node": 22, + "path": "translation" + } + }, + { + "sampler": 11, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 10, + "interpolation": "LINEAR", + "output": 11 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 12 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 13 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 14 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 15 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 16 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 17 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 18 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 19 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 20 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 21 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 22 + } + ] + }, + { + "name": "idle_sub_2", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "translation" + } + }, + { + "sampler": 7, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 8, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 23, + "interpolation": "LINEAR", + "output": 24 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 25 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 26 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 27 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 28 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 29 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 30 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 31 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 32 + } + ] + }, + { + "name": "idle_sub_3", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 7, + "target": { + "node": 22, + "path": "translation" + } + }, + { + "sampler": 8, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 33, + "interpolation": "LINEAR", + "output": 34 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 35 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 36 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 37 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 38 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 39 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 40 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 41 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 42 + } + ] + }, + { + "name": "interact", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "translation" + } + }, + { + "sampler": 7, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 8, + "target": { + "node": 22, + "path": "translation" + } + }, + { + "sampler": 9, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 43, + "interpolation": "LINEAR", + "output": 44 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 45 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 46 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 47 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 48 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 49 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 50 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 51 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 52 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 53 + } + ] + } + ], + "cameras": [ + { + "name": "", + "type": "perspective", + "perspective": { + "znear": 0.00100000004749745, + "zfar": 10000.0, + "aspectRatio": 1.77777779102325, + "yfov": 0.563196837902069 + } + } + ], + "nodes": [ + { + "name": "RootNode", + "translation": [0.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [1, 2] + }, + { + "name": "CINEMA_4D_Editor", + "translation": [-0.982785880565643, 1.25229501724243, 4.61846590042114], + "rotation": [ + -0.00675715040415525, -0.121422827243805, -0.000826607691124082, 0.992577493190765 + ], + "scale": [1.0, 1.0, 1.0], + "camera": 0 + }, + { + "name": "blockbench_export", + "translation": [0.0, 0.0, 0.0], + "rotation": [6.12323426292584e-17, 1.0, -6.12323426292584e-17, 6.12323426292584e-17], + "scale": [1.0, 1.0, 1.0], + "children": [3] + }, + { + "name": "Node_18", + "translation": [0.0, 0.0, 0.0], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [4] + }, + { + "name": "Orbit", + "translation": [9.18485073264427e-17, 0.75, -9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [5, 19, 22] + }, + { + "name": "Body", + "translation": [0.0, 0.0, 0.0], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [6, 9, 12, 15, 17, 18] + }, + { + "name": "Head", + "translation": [9.18485073264427e-17, 0.75, -9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [7, 8] + }, + { + "name": "Head_2", + "translation": [-1.83697014652885e-16, -1.5, 1.83697014652885e-16], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 0 + }, + { + "name": "Hat_Layer", + "translation": [-1.83697014652885e-16, -1.5, 1.83697014652885e-16], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 1 + }, + { + "name": "Right_Arm", + "translation": [0.3125, 0.625, -7.65404183604056e-17], + "rotation": [ + 0.087155744433403, -1.26997617863757e-32, -3.73450661431204e-33, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [10, 11] + }, + { + "name": "Right_Arm_2", + "translation": [-0.3125, -1.375, -3.55271359939116e-17], + "rotation": [-1.38777878078145e-17, -2.46519032881566e-32, 1.54074395550979e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 2 + }, + { + "name": "Right_Arm_Layer", + "translation": [-0.3125, -1.375, -3.55271359939116e-17], + "rotation": [-1.38777878078145e-17, -2.46519032881566e-32, 1.54074395550979e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 3 + }, + { + "name": "Left_Arm", + "translation": [-0.3125, 0.625, -7.65404183604056e-17], + "rotation": [ + -0.087155744433403, 4.20714065851853e-34, -4.80878392203082e-33, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [13, 14] + }, + { + "name": "Left_Arm_2", + "translation": [0.3125, -1.375, 7.10542719878232e-17], + "rotation": [-2.77555756156289e-17, -1.23259516440783e-32, 7.70371977754894e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 4 + }, + { + "name": "Left_Arm_Layer", + "translation": [0.3125, -1.375, 7.10542719878232e-17], + "rotation": [-2.77555756156289e-17, -1.23259516440783e-32, 7.70371977754894e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 5 + }, + { + "name": "Cape", + "translation": [7.65404183604056e-17, 0.75, 0.125], + "rotation": [ + 5.27160649280493e-17, 0.991444885730743, -0.130526185035706, -6.87009100578854e-17 + ], + "scale": [1.0, 1.0, 1.0], + "children": [16] + }, + { + "name": "Cape_2", + "translation": [0.0, -1.0, -0.03125], + "rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 12 + }, + { + "name": "Body_2", + "translation": [-9.18485073264427e-17, -0.75, 9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 6 + }, + { + "name": "Body_Layer", + "translation": [-9.18485073264427e-17, -0.75, 9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 7 + }, + { + "name": "Right_Leg", + "translation": [0.118749998509884, 0.0, 0.0], + "rotation": [ + -0.087155744433403, 4.20714065851853e-34, -4.80878392203082e-33, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [20, 21] + }, + { + "name": "Right_Leg_2", + "translation": [-0.118749998509884, -0.75, 0.0], + "rotation": [-1.38777878078145e-17, -1.23259516440783e-32, 2.00296714216273e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 8 + }, + { + "name": "Right_Leg_Layer", + "translation": [-0.118749998509884, -0.75, 0.0], + "rotation": [-1.38777878078145e-17, -1.23259516440783e-32, 2.00296714216273e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 9 + }, + { + "name": "Left_Leg", + "translation": [-0.118749998509884, 0.0, 0.0], + "rotation": [ + 0.087155744433403, -1.26997617863757e-32, -3.73450661431204e-33, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [23, 24] + }, + { + "name": "Left_Leg_2", + "translation": [0.118749998509884, -0.75, 0.0], + "rotation": [1.38777878078145e-17, 0.0, 1.54074395550979e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 10 + }, + { + "name": "Left_Leg_Layer", + "translation": [0.118749998509884, -0.75, 0.0], + "rotation": [1.38777878078145e-17, 0.0, 1.54074395550979e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 11 + } + ] +} diff --git a/packages/assets/models/slim-player.fbx b/packages/assets/models/slim-player.fbx new file mode 100644 index 000000000..b26b521f2 Binary files /dev/null and b/packages/assets/models/slim-player.fbx differ diff --git a/packages/assets/models/slim-player.gltf b/packages/assets/models/slim-player.gltf new file mode 100644 index 000000000..3831ce835 --- /dev/null +++ b/packages/assets/models/slim-player.gltf @@ -0,0 +1,2464 @@ +{ + "asset": { + "generator": "FBX2glTF v0.13.1", + "version": "2.0" + }, + "scene": 0, + "buffers": [ + { + "byteLength": 68020, + "uri": "data:application/octet-stream;base64,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AAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAImICD2JiIg9zczMPYmICD6rqio+zcxMPu/ubj6JiIg+mpmZPquqqj68u7s+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AACgPqcWIz8K16O9AACgPhTSIj8K16O9AACgPlyPIj8K16O9AACgPk5BIj8K16O9AACgPj3gIT8K16O9AACgPj12IT8K16O9AACgPpsLIT8K16O9AACgPgioID8K16O9AACgPotTID8K16O9AACgPnwXID8K16O9AACgPgAAID8K16O9AACgPpAzID8K16O9AACgPuzHID8K16O9AACgPoSmIT8K16O9AACgPtmpIj8K16O9AACgPjeoIz8K16O9AACgPk2AJD8K16O9AACgPrgeJT8K16O9AACgPgmUJT8K16O9AACgPq/6JT8K16O9AACgPrhVJj8K16O9AACgPp2mJj8K16O9AACgPvztJj8K16O9AACgPtsrJz8K16O9AACgPq9fJz8K16O9AACgPlOIJz8K16O9AACgPq+jJz8K16O9AACgPhSuJz8K16O9AACgPu5mJz8K16O9AACgPqKhJj8K16O9AACgPoZ7JT8K16O9AACgPiIZJD8K16O9AACgPk6mIj8K16O9AACgPrNVIT8K16O9AACgPp1fID8K16O9AACgPgAAID8K16O9AACgPqUzID8K16O9AACgPp3DID8K16O9AACgPiaRIT8K16O9AACgPs1uIj8K16O9AACgPsotIz8K16O9AACgPqyrIz8K16O9AACgPgrXIz8K16O9AACgPkDRIz8K16O9AACgPqTCIz8K16O9AACgPpKtIz8K16O9AACgPj6TIz8K16O9AACgPkF0Iz8K16O9AACgPtlQIz8K16O9AACgPvUoIz8K16O9AACgPi38Ij8K16O9AACgPpvJIj8K16O9AACgPlyPIj8K16O9AACgPhs/Ij8K16O9AACgPjPPIT8K16O9AACgPoBKIT8K16O9AACgPu7FID8K16O9AACgPqdZID8K16O9AACgPhcWID8K16O9AACgPgAAID8K16O9AACgPgAAID/9H6K9AACgPgAAID+Cq5y9AACgPgAAID9G+JK9AACgPgAAID/4gIS9AACgPgAAID/qumG9AACgPgAAID8LMzC9AACgPgAAID/e2+28AACgPgAAID8zT3W8AACgPgAAID/1+oa7AACgPgAAID/hepQkAACgPgAAID/hepQkAACgPgAAID+F65EkAACgPgAAID8pXI8kAACgPgAAID/Xo5AkAACgPgAAID8pXI8k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+c8RvTMz870K1yM8CtcjvTMz870K1yM8EoAvvTMz870K1yM8vAc4vTMz870K1yM80mc+vTMz870K1yM8PC9DvTMz870K1yM8eLlGvTMz870K1yM8+UVJvTMz870K1yM87QNLvTMz870K1yM8/BhMvTMz870K1yM8KqZMvTMz870K1yM8zMxMvTMz87046R08MoFHvTMz8707eA084mY4vTMz873H6+k7jPYgvTMz870v2647rxUDvTMz873wGWI7CjbCvDMz872zzOM6fSx3vDMz873m+f45Bk7iuzMz870AAAAAAAAAADMz8719uAk6GJHMOzMz873SaAI7xaFSPDMz871Vt4U7K92fPDMz8724mc87AZHTPDMz8726oQc8Y9EAPTMz8723nBw81jsUPTMz870K1yM8CtcjPTMz870K1yM8EoAvPTMz870K1yM8vAc4PTMz870K1yM80mc+PTMz870K1yM8PC9DPTMz870K1yM8eLlGPTMz870K1yM8+UVJPTMz870K1yM87QNLPTMz870K1yM8/BhMPTMz870K1yM8KqZMPTMz870K1yM8zMxMPTMz87046R08MoFHPTMz8707eA084mY4PTMz873H6+k7jPYgPTMz870v2647rxUDPTMz873wGWI7CjbCPDMz872zzOM6fSx3PDMz873m+f45Bk7iOzMz870AAAAAAAAAADMz8719uAk6GJHMuzMz873SaAI7xaFSvDMz871Vt4U7K92fvDMz8724mc87AZHTvDMz8726oQc8Y9EAvTMz8723nBw81jsUvTMz870K1yM8CtcjvTMz870K1yM8F20vvTMz870K1yM8Wdo3vTMz870K1yM8FCk+vTMz870K1yM81epCvTMz870K1yM8r3lGvTMz870K1yM8XhJJvTMz870K1yM8xeBKvTMz870K1yM8vgZMvTMz870K1yM8FKFMvTMz870K1yM8zMxMvTMz871ktBw8PJVFvTMz872XQwg81H8wvTMz872Gz9I7C64PvTMz872Jb4o7L5bOvDMz873fyQo7BZ51vDMz872Rdhc6kF/HuzMz870AAAAAAAAAADMz870AAAAA8+V1OzMz870AAAAA5wjUOzMz870AAAAA5GcHPDMz870AAAAAgXwYPDMz870AAAAAx7EgPDMz870AAAAAxnkjPDMz870AAAAACtcjPDMz870AAAAAVQUdPDMz870AAAAAJyQMPDMz870AAAAAvIvqOzMz870AAAAA0+G2OzMz870AAAAAlD+COzMz870AAAAApDEiOzMz870AAAAAaSqfOjMz870AAAAAm6evOTMz870AAAAAAAAAAAAAAAAAAACAGIPIiQAAgD/Mu866lewhBQeDyInr/38/BU3BuyhnFwYzgsiJ3P5/P2zNRbzH7ZoGWn/IiTn7fz8xk5u8U7XzBtZ5yIku9H8/96TRvDw0JAdIcsiJiep/PyFz/rw+TEcHU2rIiWHgfz/G8g69ve1fB9NjyIkU2H8/AR4ZvbLbbwc3X8iJMdJ/PzaOIL2kgnsHpFvIiaLNfz9XHSa9/RuCB91YyIkVyn8/EkgqvYFfhQe3VsiJWMd/PzdeLb1iyocHGFXIiUXFfz8ely+9+oeJB+hTyInBw38/7BsxvYK4igcWU8iJtcJ/P3kNMr20dYsHklLIiQ3Cfz+PiDK9HNaLB09SyIm3wX8/PqoyvX7wiwc9UsiJoMF/P/bTLb2cJogH2VTIifXEfz+MFyC9wMh6B99byInszX8/5OYKvQeXWQeSZciJTtp/Pwk34LzQnS8H3W/IiXPnfz9U9aO8oGsAB894yInf8n8/ScZMvMZjoAYVf8iJ4vp/P4o1trsitw4GTILIif3+fz8AAAAAAAAAgBiDyIkAAIA/spSWO47i64WNgsiJT/9/P3soFTwDqGmG94DIiUn9fz/EMls86a+rhoB+yIki+n8/aIyNPFq83YZue8iJN/Z/PxSyqTwF6gSHFHjIifDxfz+h5ME86N0Xh7Z0yImk7X8/CnHWPCD2J4eBcciJi+l/P3IC5zw08DSHrm7IifDlfz/kC/Q8QyY/h09syInq4n8/+5H+PGhkR4dNasiJWeB/PwGTAz1yHE6HmmjIiS7efz87EAc9sJNThy5nyIlc3H8/ddwJPaj1V4cCZsiJ3tp/P5YIDD3VXFuHFmXIibDZfz/ynA09Q9Zdh2hkyInS2H8/Z5kOPbxhX4f6Y8iJRth/P8byDj297V+H02PIiRTYfz8Tbgs9ympah1hlyIkF2n8/jVgBPdWeSod+aciJUd9/P8wt4zwd8DGHWm/Iicvmfz+ipbo8+jASh8R1yIn97n8/1PyLPGlK24aZe8iJbvZ/P8dKNTxC/42G83/Iif37fz9YNqo7nlEFhmaCyIke/38/AAAAAAAAAIAYg8iJAACAP5/FpLvEDgEGcoLIiSz/fz/LEy68m1iIBjKAyIlN/H8/f+OGvJ1N0wYifMiJHfd/P5NNtbxyAQ4HhXbIifPvfz+GF9+8nrwuBw5wyImy538/XRoBvWo9SgeXaciJcN9/P8byDr297V8H02PIiRTYfz9zDRm9xMFvBz9fyIk70n8/o2YgvaVEewe4W8iJu81/P6PmJb0j8YEH+FjIiTnKfz9vDCq9yzCFB9ZWyImAx38/miYtvdOehwc1VciJa8V/PyBqL728ZIkHAFTIieDDfz9F/TC9gKCKByZTyInKwn8/kv0xvT9piwebUsiJGMJ/PyCEMr2j0osHUVLIibrBfz8+qjK9fvCLBz1SyImgwX8/a18svdEChweeVciJ8cV/Pwr9Gb0VOXEHzl7IiazRfz/Buvq8S2JEBwtryIlM4X8/LUS0vJMxDQeqdsiJIvB/P9xVVrzV4KcGs37IiWT6fz83/K27I0YIBl6CyIkU/38/AAAAAAAAAIAYg8iJAACAPxmWVjsmE6iF0YLIiab/fz/GCLk7m+0QhkaCyIn1/n8/YlPsOykaOYbCgciJTP5/P2cRBTx5c1CGZoHIidf9fz8MOww84atbhjaByImZ/X8/W6gOPCl5X4YmgciJhP1/P775Djyn+F+GI4HIiYH9fz9iBgk8TqZWhkyByIm1/X8/Hpf0O0+TP4aqgciJLf5/P7StzDuEUCCGF4LIibn+fz8ZmJ87GgH6hXyCyIk5/38/aVNjO5INsoXJgsiJm/9/P3CKDTtFuV2F+YLIidn/fz/y5Yo6i5XZhBCDyIn3/38/o0mZOQ0g8IMXg8iJ//9/PwAAAAAAAACAGIPIiQAAgD8AAAAAiYgIPYmIiD3NzMw9iYgIPquqKj7NzEw+7+5uPomIiD6amZk+q6qqPry7uz7NzMw+3t3dPu/u7j4AAAA/iYgIPxERET+amRk/IiIiP6uqKj8zMzM/vLs7P0RERD/NzEw/VVVVP97dXT9mZmY/7+5uP3d3dz8AAIA/RESEP4mIiD/NzIw/ERGRP1VVlT+amZk/3t2dPyIioj9mZqY/q6qqP+/urj8zM7M/d3e3P7y7uz8AAMA/RETEP4mIyD/NzMw/ERHRP1VV1T+amdk/3t3dPyIi4j9mZuY/q6rqP+/u7j8zM/M/d3f3P7y7+z8AAABAIiICQEREBEBmZgZAiYgIQKuqCkDNzAxA7+4OQBEREUAzMxNAVVUVQHd3F0CamRlAvLsbQN7dHUAAACBAIiIiQEREJEBmZiZAiYgoQKuqKkDNzCxA7+4uQBERMUAzMzNAVVU1QHd3N0CamTlAvLs7QN7dPUAAAEBA2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi2bm0ogrXI73ZubQi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AAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACAGIPIiQAAgD8AAAAAAAAAgBiDyIkAAIA/AAAAAAAAAIAYg8iJAACAPwAAAAAAAACA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AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAABAAD4AwH8+AMB/PgDAfz4AwH8+AEAAPgBAAD4AQAA+ACDAPgDAfz4A4P8+AMB/PgDg/z4AQAA+ACDAPgBAAD4A4L8+AID/PQAggD4AgP89ACCAPgAAgDkA4L8+AACAOQDAfz4AAIA5AEAAPgAAgDkAQAA+AID/PQDAfz4AgP89ACCAPgDAfz4A4L8+AMB/PgDgvz4AQAA+ACCAPgBAAD4AAIA5AMB/PgCA/z0AwH8+AID/PQBAAD4AAIA5AEAAPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAkD4AALw/AACQvgAAkL4AALw/AACQvgAAkL4AAAJAAACQvgAAkD4AAAJAAACQvgAAkL4AALw/AACQPgAAkD4AALw/AACQPgAAkD4AAAJAAACQPgAAkL4AAAJAAACQPgAAkL4AALw/AACQvgAAkD4AALw/AACQvgAAkD4AALw/AACQPgAAkL4AALw/AACQPgAAkL4AAAJAAACQPgAAkD4AAAJAAACQPgAAkD4AAAJAAACQvgAAkL4AAAJAAACQvgAAkL4AALw/AACQvgAAkL4AALw/AACQPgAAkL4AAAJAAACQPgAAkL4AAAJAAACQvgAAkD4AALw/AACQPgAAkD4AALw/AACQvgAAkD4AAAJAAACQvgAAkD4AAAJAAACQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQID8AwH8+APA/PwDAfz4A8D8/AEAAPgAQID8AQAA+ABBgPwDAfz4A8H8/AMB/PgDwfz8AQAA+ABBgPwBAAD4A8F8/AID/PQAQQD8AgP89ABBAPwAAgDkA8F8/AACAOQDwPz8AAIA5ABAgPwAAgDkAECA/AID/PQDwPz8AgP89ABBAPwDAfz4A8F8/AMB/PgDwXz8AQAA+ABBAPwBAAD4AEAA/AMB/PgDwHz8AwH8+APAfPwBAAD4AEAA/AEAAPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAA4D4AAEA/AAAAvgAAgD4AAEA/AAAAvgAAgD4AAMA/AAAAvgAA4D4AAMA/AAAAvgAAgD4AAEA/AAAAPgAA4D4AAEA/AAAAPgAA4D4AAMA/AAAAPgAAgD4AAMA/AAAAPgAAgD4AAEA/AAAAvgAA4D4AAEA/AAAAvgAA4D4AAEA/AAAAPgAAgD4AAEA/AAAAPgAAgD4AAMA/AAAAPgAA4D4AAMA/AAAAPgAA4D4AAMA/AAAAvgAAgD4AAMA/AAAAvgAAgD4AAEA/AAAAvgAAgD4AAEA/AAAAPgAAgD4AAMA/AAAAPgAAgD4AAMA/AAAAvgAA4D4AAEA/AAAAPgAA4D4AAEA/AAAAvgAA4D4AAMA/AAAAvgAA4D4AAMA/AAAAPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQMD8A4P8+APA7PwDg/z4A8Ds/ACCgPgAQMD8AIKA+ABBMPwDg/z4A8Fc/AOD/PgDwVz8AIKA+ABBMPwAgoD4A8Ec/AOCfPgAQPD8A4J8+ABA8PwAggD4A8Ec/ACCAPgDwOz8AIIA+ABAwPwAggD4AEDA/AOCfPgDwOz8A4J8+ABA8PwDg/z4A8Es/AOD/PgDwSz8AIKA+ABA8PwAgoD4AECA/AOD/PgDwLz8A4P8+APAvPwAgoD4AECA/ACCgPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAA6D4AADw/AAAQvgAAcD4AADw/AAAQvgAAcD4AAMI/AAAQvgAA6D4AAMI/AAAQvgAAcD4AADw/AAAQPgAA6D4AADw/AAAQPgAA6D4AAMI/AAAQPgAAcD4AAMI/AAAQPgAAcD4AADw/AAAQvgAA6D4AADw/AAAQvgAA6D4AADw/AAAQPgAAcD4AADw/AAAQPgAAcD4AAMI/AAAQPgAA6D4AAMI/AAAQPgAA6D4AAMI/AAAQvgAAcD4AAMI/AAAQvgAAcD4AADw/AAAQvgAAcD4AADw/AAAQPgAAcD4AAMI/AAAQPgAAcD4AAMI/AAAQvgAA6D4AADw/AAAQPgAA6D4AADw/AAAQvgAA6D4AAMI/AAAQvgAA6D4AAMI/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQMD8A8D8/APA7PwDwPz8A8Ds/ABAQPwAQMD8AEBA/ABBMPwDwPz8A8Fc/APA/PwDwVz8AEBA/ABBMPwAQED8A8Ec/APAPPwAQPD8A8A8/ABA8PwAQAD8A8Ec/ABAAPwDwOz8AEAA/ABAwPwAQAD8AEDA/APAPPwDwOz8A8A8/ABA8PwDwPz8A8Es/APA/PwDwSz8AEBA/ABA8PwAQED8AECA/APA/PwDwLz8A8D8/APAvPwAQED8AECA/ABAQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAgL4AAEA/AAAAvgAA4L4AAEA/AAAAvgAA4L4AAMA/AAAAvgAAgL4AAMA/AAAAvgAA4L4AAEA/AAAAPgAAgL4AAEA/AAAAPgAAgL4AAMA/AAAAPgAA4L4AAMA/AAAAPgAA4L4AAEA/AAAAvgAAgL4AAEA/AAAAvgAAgL4AAEA/AAAAPgAA4L4AAEA/AAAAPgAA4L4AAMA/AAAAPgAAgL4AAMA/AAAAPgAAgL4AAMA/AAAAvgAA4L4AAMA/AAAAvgAA4L4AAEA/AAAAvgAA4L4AAEA/AAAAPgAA4L4AAMA/AAAAPgAA4L4AAMA/AAAAvgAAgL4AAEA/AAAAPgAAgL4AAEA/AAAAvgAAgL4AAMA/AAAAvgAAgL4AAMA/AAAAPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQED8A8H8/APAbPwDwfz8A8Bs/ABBQPwAQED8AEFA/ABAsPwDwfz8A8Dc/APB/PwDwNz8AEFA/ABAsPwAQUD8A8Cc/APBPPwAQHD8A8E8/ABAcPwAQQD8A8Cc/ABBAPwDwGz8AEEA/ABAQPwAQQD8AEBA/APBPPwDwGz8A8E8/ABAcPwDwfz8A8Cs/APB/PwDwKz8AEFA/ABAcPwAQUD8AEAA/APB/PwDwDz8A8H8/APAPPwAQUD8AEAA/ABBQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAcL4AADw/AAAQvgAA6L4AADw/AAAQvgAA6L4AAMI/AAAQvgAAcL4AAMI/AAAQvgAA6L4AADw/AAAQPgAAcL4AADw/AAAQPgAAcL4AAMI/AAAQPgAA6L4AAMI/AAAQPgAA6L4AADw/AAAQvgAAcL4AADw/AAAQvgAAcL4AADw/AAAQPgAA6L4AADw/AAAQPgAA6L4AAMI/AAAQPgAAcL4AAMI/AAAQPgAAcL4AAMI/AAAQvgAA6L4AAMI/AAAQvgAA6L4AADw/AAAQvgAA6L4AADw/AAAQPgAA6L4AAMI/AAAQPgAA6L4AAMI/AAAQvgAAcL4AADw/AAAQPgAAcL4AADw/AAAQvgAAcL4AAMI/AAAQvgAAcL4AAMI/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAQUD8A8H8/APBbPwDwfz8A8Fs/ABBQPwAQUD8AEFA/ABBsPwDwfz8A8Hc/APB/PwDwdz8AEFA/ABBsPwAQUD8A8Gc/APBPPwAQXD8A8E8/ABBcPwAQQD8A8Gc/ABBAPwDwWz8AEEA/ABBQPwAQQD8AEFA/APBPPwDwWz8A8E8/ABBcPwDwfz8A8Gs/APB/PwDwaz8AEFA/ABBcPwAQUD8AEEA/APB/PwDwTz8A8H8/APBPPwAQUD8AEEA/ABBQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAgD4AAEA/AAAAvgAAgL4AAEA/AAAAvgAAgL4AAMA/AAAAvgAAgD4AAMA/AAAAvgAAgL4AAEA/AAAAPgAAgD4AAEA/AAAAPgAAgD4AAMA/AAAAPgAAgL4AAMA/AAAAPgAAgL4AAEA/AAAAvgAAgD4AAEA/AAAAvgAAgD4AAEA/AAAAPgAAgL4AAEA/AAAAPgAAgL4AAMA/AAAAPgAAgD4AAMA/AAAAPgAAgD4AAMA/AAAAvgAAgL4AAMA/AAAAvgAAgL4AAEA/AAAAvgAAgL4AAEA/AAAAPgAAgL4AAMA/AAAAPgAAgL4AAMA/AAAAvgAAgD4AAEA/AAAAPgAAgD4AAEA/AAAAvgAAgD4AAMA/AAAAvgAAgD4AAMA/AAAAPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAgoD4A4P8+AODfPgDg/z4A4N8+ACCgPgAgoD4AIKA+ABAAPwDg/z4A8B8/AOD/PgDwHz8AIKA+ABAAPwAgoD4A8A8/AOCfPgAg4D4A4J8+ACDgPgAggD4A8A8/ACCAPgDg3z4AIIA+ACCgPgAggD4AIKA+AOCfPgDg3z4A4J8+ACDgPgDg/z4A4P8+AOD/PgDg/z4AIKA+ACDgPgAgoD4AIIA+AOD/PgDgnz4A4P8+AOCfPgAgoD4AIIA+ACCgPgAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAAAAiD4AADw/AAAQvgAAiL4AADw/AAAQvgAAiL4AAMI/AAAQvgAAiD4AAMI/AAAQvgAAiL4AADw/AAAQPgAAiD4AADw/AAAQPgAAiD4AAMI/AAAQPgAAiL4AAMI/AAAQPgAAiL4AADw/AAAQvgAAiD4AADw/AAAQvgAAiD4AADw/AAAQPgAAiL4AADw/AAAQPgAAiL4AAMI/AAAQPgAAiD4AAMI/AAAQPgAAiD4AAMI/AAAQvgAAiL4AAMI/AAAQvgAAiL4AADw/AAAQvgAAiL4AADw/AAAQPgAAiL4AAMI/AAAQPgAAiL4AAMI/AAAQvgAAiD4AADw/AAAQPgAAiD4AADw/AAAQvgAAiD4AAMI/AAAQvgAAiD4AAMI/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAgoD4A8D8/AODfPgDwPz8A4N8+ABAQPwAgoD4AEBA/ABAAPwDwPz8A8B8/APA/PwDwHz8AEBA/ABAAPwAQED8A8A8/APAPPwAg4D4A8A8/ACDgPgAQAD8A8A8/ABAAPwDg3z4AEAA/ACCgPgAQAD8AIKA+APAPPwDg3z4A8A8/ACDgPgDwPz8A4P8+APA/PwDg/z4AEBA/ACDgPgAQED8AIIA+APA/PwDgnz4A8D8/AOCfPgAQED8AIIA+ABAQPwAAAQACAAMAAAACAAQABQAGAAcABAAGAAgACQAKAAsACAAKAAwADQAOAA8ADAAOABAAEQASABMAEAASABQAFQAWABcAFAAWAJmZeT4AAAAA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AAAQvs3MhL4AAIC8AAAQvs3MhL4AAIC8AAAQPs3MhL4AAEQ/AAAQPs3MhL4AAEQ/AAAQvjMzszwAAIC8AAAQPjMzszwAAIC8AAAQvjMzszwAAEQ/AAAQvjMzszwAAEQ/AAAQPgAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAACAgD0A8H8/AID/PQDwfz8AgP89ABBQPwCAgD0AEFA/AEBAPgDwfz8AwH8+APB/PwDAfz4AEFA/AEBAPgAQUD8AwD8+APBPPwBAAD4A8E8/AEAAPgAQQD8AwD8+ABBAPwCA/z0AEEA/AICAPQAQQD8AgIA9APBPPwCA/z0A8E8/AEAAPgDwfz8AwD8+APB/PwDAPz4AEFA/AEAAPgAQUD8AAIA5APB/PwAAfz0A8H8/AAB/PQAQUD8AAIA5ABBQPwAAAQACAAAAAgADAAQABQAGAAUABAAHAAgACQAKAAkACAALAAwADQAOAAwADgAPABAAEQASABMAEgARABQAFQAWABcAFQAUAAAAoD4AAAAAAAAAPQAAoL4AAAAAAAAAPQAAoL4AAAAAAAAAvQAAoD4AAAAAAAAAvQAAoL4AAIA/AAAAPQAAoD4AAIA/AAAAvQAAoL4AAIA/AAAAvQAAoD4AAIA/AAAAPQAAoL4AAAAAAAAAvQAAoL4AAIA/AAAAPQAAoL4AAIA/AAAAvQAAoL4AAAAAAAAAPQAAoD4AAIA/AAAAPQAAoL4AAIA/AAAAPQAAoL4AAAAAAAAAPQAAoD4AAAAAAAAAPQAAoD4AAIA/AAAAPQAAoD4AAAAAAAAAPQAAoD4AAIA/AAAAvQAAoD4AAAAAAAAAvQAAoD4AAAAAAAAAvQAAoL4AAIA/AAAAvQAAoD4AAIA/AAAAvQAAoL4AAAAAAAAAvQAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAMD4AAAAAAACoPgAAAAAAAKg+AAAAPQAAMD4AAAA9AAAwPgAAAAAAAIA8AAAAPQAAMD4AAAA9AACAPAAAAAAAADA+AAAIPwAAQD4AAAA9AAAwPgAAAD0AAEA+AAAIPwAAsD4AAAA9AABAPgAAAD0AAEA+AAAIPwAAsD4AAAg/AAAAAAAAAD0AAAAAAAAIPwAAgDwAAAA9AACAPAAACD8AAIA8AAAIPwAAMD4AAAA9AACAPAAAAD0AADA+AAAIPw==" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteLength": 4, + "byteOffset": 0 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 4 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 368 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 1460 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 2916 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 4372 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 5828 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 7284 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 8740 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 10196 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 11652 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 12016 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 13108 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 14564 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 16020 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 17112 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 18568 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 19660 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 21116 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 22572 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 23664 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 25120 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 26212 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 27668 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 28032 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 29124 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 30580 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 32036 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 33492 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 34948 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 36404 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 37496 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 38952 + }, + { + "buffer": 0, + "byteLength": 364, + "byteOffset": 40408 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 40772 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 41864 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 43320 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 44776 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 46232 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 47688 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 49144 + }, + { + "buffer": 0, + "byteLength": 1092, + "byteOffset": 50600 + }, + { + "buffer": 0, + "byteLength": 1456, + "byteOffset": 51692 + }, + { + "buffer": 0, + "byteLength": 104, + "byteOffset": 53148 + }, + { + "buffer": 0, + "byteLength": 312, + "byteOffset": 53252 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 53564 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 53980 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 54396 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 54812 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 55228 + }, + { + "buffer": 0, + "byteLength": 312, + "byteOffset": 55644 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 55956 + }, + { + "buffer": 0, + "byteLength": 312, + "byteOffset": 56372 + }, + { + "buffer": 0, + "byteLength": 416, + "byteOffset": 56684 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 57100, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 57172, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 57460, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 57748, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 57940, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 58012, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 58300, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 58588, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 58780, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 58852, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 59140, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 59428, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 59620, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 59692, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 59980, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 60268, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 60460, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 60532, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 60820, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 61108, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 61300, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 61372, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 61660, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 61948, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 62140, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 62212, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 62500, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 62788, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 62980, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 63052, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 63340, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 63628, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 63820, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 63892, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 64180, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 64468, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 64660, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 64732, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 65020, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 65308, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 65500, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 65572, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 65860, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 66148, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 66340, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 66412, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 66700, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 66988, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 72, + "byteOffset": 67180, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 67252, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 288, + "byteOffset": 67540, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 192, + "byteOffset": 67828, + "target": 34962 + } + ], + "scenes": [ + { + "name": "Root Scene", + "nodes": [0] + } + ], + "accessors": [ + { + "componentType": 5126, + "type": "SCALAR", + "count": 1, + "bufferView": 0, + "byteOffset": 0, + "min": [0.0], + "max": [0.0] + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 1, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 2, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 3, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 4, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 5, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 6, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 7, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 8, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 9, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 10, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 11, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 12, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 13, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 14, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 15, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 16, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 17, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 18, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 19, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 20, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 21, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 22, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 23, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 24, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 25, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 26, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 27, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 28, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 29, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 30, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 31, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 32, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 91, + "bufferView": 33, + "byteOffset": 0, + "min": [0.0], + "max": [3.0] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 34, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 35, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 36, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 37, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 38, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 39, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 40, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 91, + "bufferView": 41, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 91, + "bufferView": 42, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "SCALAR", + "count": 26, + "bufferView": 43, + "byteOffset": 0, + "min": [0.0], + "max": [0.833333313465118] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 26, + "bufferView": 44, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 45, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 46, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 47, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 48, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 49, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 26, + "bufferView": 50, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 51, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 26, + "bufferView": 52, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC4", + "count": 26, + "bufferView": 53, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 54, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 55, + "byteOffset": 0, + "min": [-0.25, 1.5, -0.25], + "max": [0.25, 2.0, 0.25] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 56, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 57, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 58, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 59, + "byteOffset": 0, + "min": [-0.28125, 1.46875, -0.28125], + "max": [0.28125, 2.03125, 0.28125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 60, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 61, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 62, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 63, + "byteOffset": 0, + "min": [0.25, 0.75, -0.125], + "max": [0.4375, 1.5, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 64, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 65, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 66, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 67, + "byteOffset": 0, + "min": [0.234375, 0.734375, -0.140625], + "max": [0.453125, 1.515625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 68, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 69, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 70, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 71, + "byteOffset": 0, + "min": [-0.4375, 0.75, -0.125], + "max": [-0.25, 1.5, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 72, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 73, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 74, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 75, + "byteOffset": 0, + "min": [-0.453125, 0.734375, -0.140625], + "max": [-0.234375, 1.515625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 76, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 77, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 78, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 79, + "byteOffset": 0, + "min": [-0.25, 0.75, -0.125], + "max": [0.25, 1.5, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 80, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 81, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 82, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 83, + "byteOffset": 0, + "min": [-0.265625, 0.734375, -0.140625], + "max": [0.265625, 1.515625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 84, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 85, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 86, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 87, + "byteOffset": 0, + "min": [-0.00624999962747097, 0.0, -0.125], + "max": [0.243749991059303, 0.75, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 88, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 89, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 90, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 91, + "byteOffset": 0, + "min": [-0.021874999627471, -0.015625, -0.140625], + "max": [0.259375005960464, 0.765625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 92, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 93, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 94, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 95, + "byteOffset": 0, + "min": [-0.243749991059303, 0.0, -0.125], + "max": [0.00624999962747097, 0.75, 0.125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 96, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 97, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 98, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 99, + "byteOffset": 0, + "min": [-0.259375005960464, -0.015625, -0.140625], + "max": [0.021874999627471, 0.765625, 0.140625] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 100, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 101, + "byteOffset": 0 + }, + { + "componentType": 5123, + "type": "SCALAR", + "count": 36, + "bufferView": 102, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 103, + "byteOffset": 0, + "min": [-0.3125, 0.0, -0.03125], + "max": [0.3125, 1.0, 0.03125] + }, + { + "componentType": 5126, + "type": "VEC3", + "count": 24, + "bufferView": 104, + "byteOffset": 0 + }, + { + "componentType": 5126, + "type": "VEC2", + "count": 24, + "bufferView": 105, + "byteOffset": 0 + } + ], + "images": [ + { + "name": "sunny.png", + "uri": "sunny.png" + }, + { + "name": "cape", + "uri": "cape" + } + ], + "samplers": [{}], + "textures": [ + { + "name": "sunny", + "sampler": 0, + "source": 0 + }, + { + "name": "cape", + "sampler": 0, + "source": 1 + } + ], + "materials": [ + { + "name": "Mat", + "alphaMode": "BLEND", + "doubleSided": false, + "extras": { + "fromFBX": { + "shadingModel": "Lambert", + "isTruePBR": false + } + }, + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 0, + "texCoord": 0 + }, + "baseColorFactor": [1.0, 1.0, 1.0, 1.0], + "metallicFactor": 0.200000002980232, + "roughnessFactor": 0.800000011920929 + } + }, + { + "name": "cape", + "alphaMode": "BLEND", + "doubleSided": false, + "extras": { + "fromFBX": { + "shadingModel": "Lambert", + "isTruePBR": false + } + }, + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 1, + "texCoord": 0 + }, + "baseColorFactor": [1.0, 1.0, 1.0, 1.0], + "metallicFactor": 0.200000002980232, + "roughnessFactor": 0.800000011920929 + } + } + ], + "meshes": [ + { + "name": "Head_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 56, + "POSITION": 55, + "TEXCOORD_0": 57 + }, + "indices": 54 + } + ] + }, + { + "name": "Hat_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 60, + "POSITION": 59, + "TEXCOORD_0": 61 + }, + "indices": 58 + } + ] + }, + { + "name": "Right_Arm_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 64, + "POSITION": 63, + "TEXCOORD_0": 65 + }, + "indices": 62 + } + ] + }, + { + "name": "Right_Arm_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 68, + "POSITION": 67, + "TEXCOORD_0": 69 + }, + "indices": 66 + } + ] + }, + { + "name": "Left_Arm_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 72, + "POSITION": 71, + "TEXCOORD_0": 73 + }, + "indices": 70 + } + ] + }, + { + "name": "Left_Arm_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 76, + "POSITION": 75, + "TEXCOORD_0": 77 + }, + "indices": 74 + } + ] + }, + { + "name": "Body_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 80, + "POSITION": 79, + "TEXCOORD_0": 81 + }, + "indices": 78 + } + ] + }, + { + "name": "Body_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 84, + "POSITION": 83, + "TEXCOORD_0": 85 + }, + "indices": 82 + } + ] + }, + { + "name": "Right_Leg_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 88, + "POSITION": 87, + "TEXCOORD_0": 89 + }, + "indices": 86 + } + ] + }, + { + "name": "Right_Leg_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 92, + "POSITION": 91, + "TEXCOORD_0": 93 + }, + "indices": 90 + } + ] + }, + { + "name": "Left_Leg_2", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 96, + "POSITION": 95, + "TEXCOORD_0": 97 + }, + "indices": 94 + } + ] + }, + { + "name": "Left_Leg_Layer", + "primitives": [ + { + "material": 0, + "mode": 4, + "attributes": { + "NORMAL": 100, + "POSITION": 99, + "TEXCOORD_0": 101 + }, + "indices": 98 + } + ] + }, + { + "name": "Cape_2", + "primitives": [ + { + "material": 1, + "mode": 4, + "attributes": { + "NORMAL": 104, + "POSITION": 103, + "TEXCOORD_0": 105 + }, + "indices": 102 + } + ] + } + ], + "animations": [ + { + "name": "CINEMA_4D_Main", + "channels": [], + "samplers": [] + }, + { + "name": "idle", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 7, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 1, + "interpolation": "LINEAR", + "output": 2 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 3 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 4 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 5 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 6 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 7 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 8 + }, + { + "input": 1, + "interpolation": "LINEAR", + "output": 9 + } + ] + }, + { + "name": "idle_sub_1", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "translation" + } + }, + { + "sampler": 4, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 12, + "path": "translation" + } + }, + { + "sampler": 6, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 7, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 8, + "target": { + "node": 19, + "path": "translation" + } + }, + { + "sampler": 9, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 10, + "target": { + "node": 22, + "path": "translation" + } + }, + { + "sampler": 11, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 10, + "interpolation": "LINEAR", + "output": 11 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 12 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 13 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 14 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 15 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 16 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 17 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 18 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 19 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 20 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 21 + }, + { + "input": 10, + "interpolation": "LINEAR", + "output": 22 + } + ] + }, + { + "name": "idle_sub_2", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "translation" + } + }, + { + "sampler": 7, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 8, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 23, + "interpolation": "LINEAR", + "output": 24 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 25 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 26 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 27 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 28 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 29 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 30 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 31 + }, + { + "input": 23, + "interpolation": "LINEAR", + "output": 32 + } + ] + }, + { + "name": "idle_sub_3", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 7, + "target": { + "node": 22, + "path": "translation" + } + }, + { + "sampler": 8, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 33, + "interpolation": "LINEAR", + "output": 34 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 35 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 36 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 37 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 38 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 39 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 40 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 41 + }, + { + "input": 33, + "interpolation": "LINEAR", + "output": 42 + } + ] + }, + { + "name": "interact", + "channels": [ + { + "sampler": 0, + "target": { + "node": 5, + "path": "translation" + } + }, + { + "sampler": 1, + "target": { + "node": 5, + "path": "rotation" + } + }, + { + "sampler": 2, + "target": { + "node": 6, + "path": "rotation" + } + }, + { + "sampler": 3, + "target": { + "node": 9, + "path": "rotation" + } + }, + { + "sampler": 4, + "target": { + "node": 12, + "path": "rotation" + } + }, + { + "sampler": 5, + "target": { + "node": 15, + "path": "rotation" + } + }, + { + "sampler": 6, + "target": { + "node": 19, + "path": "translation" + } + }, + { + "sampler": 7, + "target": { + "node": 19, + "path": "rotation" + } + }, + { + "sampler": 8, + "target": { + "node": 22, + "path": "translation" + } + }, + { + "sampler": 9, + "target": { + "node": 22, + "path": "rotation" + } + } + ], + "samplers": [ + { + "input": 43, + "interpolation": "LINEAR", + "output": 44 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 45 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 46 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 47 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 48 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 49 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 50 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 51 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 52 + }, + { + "input": 43, + "interpolation": "LINEAR", + "output": 53 + } + ] + } + ], + "cameras": [ + { + "name": "", + "type": "perspective", + "perspective": { + "znear": 0.00100000004749745, + "zfar": 10000.0, + "aspectRatio": 1.77777779102325, + "yfov": 0.563196837902069 + } + } + ], + "nodes": [ + { + "name": "RootNode", + "translation": [0.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [1, 2] + }, + { + "name": "CINEMA_4D_Editor", + "translation": [-1.95770621299744, 1.77333533763885, 4.95558166503906], + "rotation": [-0.0569104515016079, -0.199310034513474, -0.0115954466164112, 0.978213787078857], + "scale": [1.0, 1.0, 1.0], + "camera": 0 + }, + { + "name": "blockbench_export", + "translation": [0.0, 0.0, 0.0], + "rotation": [6.12323426292584e-17, 1.0, -6.12323426292584e-17, 6.12323426292584e-17], + "scale": [1.0, 1.0, 1.0], + "children": [3] + }, + { + "name": "Node_18", + "translation": [0.0, 0.0, 0.0], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [4] + }, + { + "name": "Orbit", + "translation": [9.18485073264427e-17, 0.75, -9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [5, 19, 22] + }, + { + "name": "Body", + "translation": [0.0, 0.0, 0.0], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [6, 9, 12, 15, 17, 18] + }, + { + "name": "Head", + "translation": [9.18485073264427e-17, 0.75, -9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "children": [7, 8] + }, + { + "name": "Head_2", + "translation": [-1.83697014652885e-16, -1.5, 1.83697014652885e-16], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 0 + }, + { + "name": "Hat_Layer", + "translation": [-1.83697014652885e-16, -1.5, 1.83697014652885e-16], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 1 + }, + { + "name": "Right_Arm", + "translation": [0.3125, 0.625, -7.65404183604056e-17], + "rotation": [ + 0.087155744433403, 2.30259769030936e-17, -2.01451194149889e-18, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [10, 11] + }, + { + "name": "Right_Arm_2", + "translation": [-0.3125, -1.375, -1.42108543975646e-16], + "rotation": [-1.38777878078145e-17, 1.18771912776064e-16, -2.0942694406238e-17, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 2 + }, + { + "name": "Right_Arm_Layer", + "translation": [-0.3125, -1.375, -1.42108543975646e-16], + "rotation": [-1.38777878078145e-17, 1.18771912776064e-16, -2.0942694406238e-17, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 3 + }, + { + "name": "Left_Arm", + "translation": [-0.3125, 0.625, -7.65404183604056e-17], + "rotation": [ + -0.087155744433403, 4.20714065851853e-34, -4.80878392203082e-33, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [13, 14] + }, + { + "name": "Left_Arm_2", + "translation": [0.3125, -1.375, 1.06581407981735e-16], + "rotation": [-2.77555756156289e-17, 1.40037680768508e-16, -9.96614730574674e-17, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 4 + }, + { + "name": "Left_Arm_Layer", + "translation": [0.3125, -1.375, 1.06581407981735e-16], + "rotation": [-2.77555756156289e-17, 1.40037680768508e-16, -9.96614730574674e-17, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 5 + }, + { + "name": "Cape", + "translation": [7.65404183604056e-17, 0.75, 0.125], + "rotation": [ + 5.27160649280493e-17, 0.991444885730743, -0.130526185035706, -6.87009100578854e-17 + ], + "scale": [1.0, 1.0, 1.0], + "children": [16] + }, + { + "name": "Cape_2", + "translation": [0.0, -1.0, -0.03125], + "rotation": [0.0, 0.0, 0.0, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 12 + }, + { + "name": "Body_2", + "translation": [-9.18485073264427e-17, -0.75, 9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 6 + }, + { + "name": "Body_Layer", + "translation": [-9.18485073264427e-17, -0.75, 9.18485073264427e-17], + "rotation": [0.0, -0.0, -4.82715285797234e-33, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 7 + }, + { + "name": "Right_Leg", + "translation": [0.118749998509884, 0.0, 0.0], + "rotation": [ + -0.087155744433403, 4.20714065851853e-34, -4.80878392203082e-33, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [20, 21] + }, + { + "name": "Right_Leg_2", + "translation": [-0.118749998509884, -0.75, 0.0], + "rotation": [-1.38777878078145e-17, -1.23259516440783e-32, 2.00296714216273e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 8 + }, + { + "name": "Right_Leg_Layer", + "translation": [-0.118749998509884, -0.75, 0.0], + "rotation": [-1.38777878078145e-17, -1.23259516440783e-32, 2.00296714216273e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 9 + }, + { + "name": "Left_Leg", + "translation": [-0.118749998509884, 0.0, 0.0], + "rotation": [ + 0.087155744433403, -1.26997617863757e-32, -3.73450661431204e-33, 0.99619472026825 + ], + "scale": [1.0, 1.0, 1.0], + "children": [23, 24] + }, + { + "name": "Left_Leg_2", + "translation": [0.118749998509884, -0.75, 0.0], + "rotation": [1.38777878078145e-17, 0.0, 1.54074395550979e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 10 + }, + { + "name": "Left_Leg_Layer", + "translation": [0.118749998509884, -0.75, 0.0], + "rotation": [1.38777878078145e-17, 0.0, 1.54074395550979e-32, 1.0], + "scale": [1.0, 1.0, 1.0], + "mesh": 11 + } + ] +} diff --git a/packages/assets/package.json b/packages/assets/package.json index 96b19404d..3142a78e5 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -5,12 +5,16 @@ "main": "./index.ts", "types": "./index.ts", "scripts": { - "lint": "eslint . && prettier --check .", - "fix": "eslint . --fix && prettier --write ." + "lint": "pnpm run icons:validate && eslint . && prettier --check .", + "fix": "pnpm run icons:generate && eslint . --fix && prettier --write .", + "icons:test": "jiti build/generate-exports.ts --test", + "icons:validate": "jiti build/generate-exports.ts --validate", + "icons:generate": "jiti build/generate-exports.ts" }, "devDependencies": { "eslint": "^8.57.0", "eslint-config-custom": "workspace:*", + "jiti": "^2.4.2", "tsconfig": "workspace:*", "vue": "^3.5.13" } diff --git a/packages/assets/styles/classes.scss b/packages/assets/styles/classes.scss index 9b22f853d..59eaf9652 100644 --- a/packages/assets/styles/classes.scss +++ b/packages/assets/styles/classes.scss @@ -822,6 +822,65 @@ a, // TOOLTIPS +.v-popper--theme-dropdown, +.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { + .v-popper__inner { + border: 1px solid var(--color-button-bg) !important; + padding: var(--gap-sm) !important; + width: fit-content !important; + border-radius: var(--radius-md) !important; + background-color: var(--color-raised-bg) !important; + box-shadow: var(--shadow-floating) !important; + } + + .v-popper__arrow-outer { + border-color: var(--color-button-bg) !important; + } + + .v-popper__arrow-inner { + border-color: var(--color-raised-bg) !important; + } +} + +.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper { + transform-origin: top right; +} + +.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper { + transform-origin: bottom right; +} + +.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper { + transform-origin: top left; +} + +.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper { + transform-origin: bottom left; +} + +.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper { + transform: scale(0.85); + opacity: 0; +} + +.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper { + transform: scale(1); + opacity: 1; + transition: + transform 0.125s ease-in-out, + opacity 0.125s ease-in-out; +} + +.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper { + transform: none; + opacity: 1; + transition: transform 0.0625s; +} + +.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper { + //transform: scale(.9); +} + .v-popper--theme-tooltip { .v-popper__inner { background: var(--color-tooltip-bg) !important; @@ -840,6 +899,30 @@ a, } } +.v-popper--theme-dismissable-prompt { + z-index: 10; + + .v-popper__inner { + background: var(--color-raised-bg) !important; + border: 1px solid var(--color-button-border); + color: var(--color-tooltip-text) !important; + padding: 0.75rem 1rem !important; + border-radius: 0.75rem !important; + filter: drop-shadow(5px 5px 0.8rem rgba(0, 0, 0, 0.35)); + font-size: 0.9rem; + font-weight: bold; + line-height: 1; + } + + .v-popper__arrow-outer { + border-color: var(--color-button-border); + } + + .v-popper__arrow-inner { + border-color: var(--color-raised-bg); + } +} + // MARKDOWN .markdown-body { @@ -1205,65 +1288,6 @@ select { border-top-right-radius: var(--radius-md) !important; } -.v-popper--theme-dropdown, -.v-popper--theme-dropdown.v-popper--theme-ribbit-popout { - .v-popper__inner { - border: 1px solid var(--color-button-bg) !important; - padding: var(--gap-sm) !important; - width: fit-content !important; - border-radius: var(--radius-md) !important; - background-color: var(--color-raised-bg) !important; - box-shadow: var(--shadow-floating) !important; - } - - .v-popper__arrow-outer { - border-color: var(--color-button-bg) !important; - } - - .v-popper__arrow-inner { - border-color: var(--color-raised-bg) !important; - } -} - -.v-popper__popper[data-popper-placement='bottom-end'] .v-popper__wrapper { - transform-origin: top right; -} - -.v-popper__popper[data-popper-placement='top-end'] .v-popper__wrapper { - transform-origin: bottom right; -} - -.v-popper__popper[data-popper-placement='bottom-start'] .v-popper__wrapper { - transform-origin: top left; -} - -.v-popper__popper[data-popper-placement='top-start'] .v-popper__wrapper { - transform-origin: bottom left; -} - -.v-popper__popper.v-popper__popper--show-from .v-popper__wrapper { - transform: scale(0.85); - opacity: 0; -} - -.v-popper__popper.v-popper__popper--show-to .v-popper__wrapper { - transform: scale(1); - opacity: 1; - transition: - transform 0.125s ease-in-out, - opacity 0.125s ease-in-out; -} - -.v-popper__popper.v-popper__popper--hide-from .v-popper__wrapper { - transform: none; - opacity: 1; - transition: transform 0.0625s; -} - -.v-popper__popper.v-popper__popper--hide-to .v-popper__wrapper { - //transform: scale(.9); -} - .preview-radio { width: 100% !important; border-radius: var(--radius-md); diff --git a/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index f46f24e37..863e9b151 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -68,6 +68,8 @@ --color-button-bg-selected: var(--color-brand); --color-button-text-selected: var(--color-accent-contrast); + --color-gradient-button-bg: linear-gradient(180deg, #f8f9fa 0%, #dce0e6 100%); + --loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #00af5c 100%); --color-platform-fabric: #8a7b71; @@ -84,6 +86,10 @@ --color-platform-velocity: #4b98b0; --color-platform-waterfall: #5f83cb; --color-platform-sponge: #c49528; + --color-platform-ornithe: #6097ca; + --color-platform-bta-babric: #5ba938; + --color-platform-legacy-fabric: #6879f6; + --color-platform-nilloader: #dd5088; --hover-brightness: 0.9; } @@ -182,6 +188,8 @@ html { --color-button-bg-selected: var(--color-brand-highlight); --color-button-text-selected: var(--color-brand); + --color-gradient-button-bg: linear-gradient(180deg, #3a3d47 0%, #33363d 100%); + --loading-bar-gradient: linear-gradient(to right, var(--color-brand) 0%, #1ffa9a 100%); --color-platform-fabric: #dbb69b; @@ -198,6 +206,10 @@ html { --color-platform-velocity: #83d5ef; --color-platform-waterfall: #78a4fb; --color-platform-sponge: #f9e580; + --color-platform-ornithe: #87c7ff; + --color-platform-bta-babric: #72cc4a; + --color-platform-legacy-fabric: #6879f6; + --color-platform-nilloader: #f45e9a; --hover-brightness: 1.25; @@ -222,6 +234,8 @@ html { rgba(9, 18, 14, 0.6) 10%, rgba(19, 31, 23, 0.5) 100% ); + + --color-gradient-button-bg: linear-gradient(180deg, #1b1b20 0%, #25262b 100%); } .retro-mode { diff --git a/packages/blog/.eslintrc.js b/packages/blog/.eslintrc.js new file mode 100644 index 000000000..7c8979a43 --- /dev/null +++ b/packages/blog/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ['custom/library'], + env: { + node: true, + }, +} diff --git a/packages/blog/LICENSE b/packages/blog/LICENSE new file mode 100644 index 000000000..e72bfddab --- /dev/null +++ b/packages/blog/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/packages/blog/README.md b/packages/blog/README.md new file mode 100644 index 000000000..b0aa4461b --- /dev/null +++ b/packages/blog/README.md @@ -0,0 +1,23 @@ +# Modrinth Blog Articles + +This package contains the articles for the Modrinth blog. The articles are written in Markdown and are rendered on the Modrinth website. + +## How to add a new article + +Write your article in the `articles` directory. The filename should be the slug of the article, and the file should have a `.md` extension. The first line of the file should be the frontmatter, which contains metadata about the article such as the title, summary and date of writing. + +### Example Frontmatter + +```md +--- +title: Quintupling Creator Revenue and Becoming Sustainable +short_title: Becoming Sustainable +summary: Announcing an update to our monetization program, creator split, and more! +short_summary: Announcing 5x creator revenue and updates to the monetization program. +date: 2024-09-13T12:00:00-08:00 +--- +``` + +You **can** link other articles in the frontmatter, but it's recommended you're explicit about it, for example: `https://modrinth.com/news/article/...` instead of `/news/article/...`. It's not a requirement though, you just have to be careful about it. + +You can place images in the `public/{slug}/...` directory, the thumbnail must be a `.webp` file named `thumbnail.webp` in the same public directory. diff --git a/packages/blog/articles/a-new-chapter-for-modrinth-servers.md b/packages/blog/articles/a-new-chapter-for-modrinth-servers.md new file mode 100644 index 000000000..4a8762f02 --- /dev/null +++ b/packages/blog/articles/a-new-chapter-for-modrinth-servers.md @@ -0,0 +1,36 @@ +--- +title: A New Chapter for Modrinth Servers +summary: Modrinth Servers is now fully operated in-house by the Modrinth Team. +date: 2025-03-13T00:00:00+00:00 +authors: ['MpxzqsyW', 'Dc7EYhxG'] +--- + +Over the few months, Modrinth has seen incredible interest towards our Servers product, and with significant growth, our vision for what Modrinth Servers can be has evolved alongside it. To continue striving towards our goal of providing the best place to get your own Minecraft multiplayer server, we’ve made the decision to bring our server hosting fully in-house. + +### Why We're Making This Change + +Modrinth has some ambitious goals for the next year. We want to create the best possible way for all Java players play Minecraft, and to host and play their favorite modpacks and custom servers. To achieve this, it’s clear that Modrinth Servers needs to be built and scaled on our own infrastructure. + +By running every aspect of our hosting platform, we gain the flexibility to tailor the experience to our community’s needs—whether that means deeper integrations with Modrinth’s ecosystem, better performance, or more innovative features. This also allows us to invest in the long-term sustainability of Modrinth Servers, ensuring that we can scale seamlessly and avoid running out of available servers stock. + +### A Thank You to Pyro + +This change is purely a logistical step forward and does not reflect negatively on our partnership with [Pyro](https://pyro.host). In fact, Pyro has been an incredible partner in getting Modrinth Servers off the ground and we are very grateful for their collaboration. We completely support Pyro and their future, and we know they’re working on some exciting new products of their own, which we can’t wait to check out! + +### What This Means for You + +We know you may have questions, and we want to make this transition as smooth as possible. + +- **What part of my server was being run by Pyro?** + + Until this point, Pyro has been responsible for the physical server machines that run your Modrinth servers. This means that they have been responsible for the hardware that powers your server, as well as the files and data for them. Moving forward, all of this will exist under Modrinth. + +- **What happens to my running servers?** + + Your current servers will continue running, and we’ll provide a clear migration path if any action is needed on your part. You can expect a follow up soon, however our goal is to do this with 0 downtime or impact to you if possible. + +- **Will anything else change that impacts me?** + + Modrinth Servers will remain the same great experience its has been, you likely won’t notice any changes right away. Long term, this means we’ll be able to improve both the stability of servers as well as the features that make managing your server a breeze. + +This is an exciting step toward a future where Modrinth is the go-to destination for Java Minecraft players—not just for mods and mod-packs, but for hosting and playing too. We appreciate your support and can’t wait to share more soon! diff --git a/packages/blog/articles/accelerating-development.md b/packages/blog/articles/accelerating-development.md new file mode 100644 index 000000000..aa848569f --- /dev/null +++ b/packages/blog/articles/accelerating-development.md @@ -0,0 +1,61 @@ +--- +title: Accelerating Modrinth's Development +summary: Our fundraiser and the future of Modrinth! +date: 2023-02-01T12:00:00-08:00 +authors: ['MpxzqsyW', 'Dc7EYhxG', '6plzAzU4'] +--- + +**Update: On [April 4, 2024](/news/article/capital-return) we announced that we had returned the remaining $800k in investor capital back to our investors to take a different path. [Read that announcement here](/news/article/capital-return). This article remains here for archival purposes.** + +--- + +There are over 3 billion gamers worldwide, but only a small fraction ever go further and mod the games they play. Modrinth is here to bring modding to every player on the planet—all the while allowing mod creators to make a living off of it. + +Since our founding in 2020 and up until a few months ago, Modrinth has been a purely volunteer project. In the past couple months, the Modrinth team has been more productive than ever. We've released the [Anniversary Update](../two-years-of-modrinth), we're actively working on the launcher once more, and we're laying out plans for how to multiply Modrinth creator payouts. + +The vision we have for the future of Modrinth is great, and right alongside that is the need for an amazing team to build out this vision. That's why we [recently announced](https://x.com/modrinth/status/1615416957905342472) that [we're hiring](https://careers.modrinth.com)—we've already come so far on just volunteer work, but for Modrinth to be sustainable and for its growth to be sustainable, we need to pick up the pace. + +That's why we're excited to announce that we've raised a pre-seed round of funding led by [Makers Fund](https://www.makersfund.com/), with investors including [Ryan Johnson](https://x.com/ryanmjohnson), [Stephen Cole](https://x.com/sthenc), [Pim de Witte](https://x.com/PimDeWitte), [Chris Lee](https://www.linkedin.com/in/leechris1/), and [Andreas Thorstensson](https://x.com/andreas) to accelerate development and expand to new horizons for Modrinth. + +## What's next? + +We're thrilled to keep on building and iterating on Modrinth over the next few years. Here's a look into what we have in store over the next few months for Modrinth: + +- A feature-packed launcher +- Creator organizations (like GitHub), wikis, graphs (with playtime, views, downloads, etc) +- More creator payouts, through the growth of Adrinth +- Discovery/recommendation of mods (especially up-and-coming content) +- Comments (with built-in moderation and spam protection) +- \[Redacted] + +Support for new games! + +We are excited that we are able to build a product that will manage to grow us to sustainability and create the best modding experience for creators and users. Being able to pay ourselves and bring on new people is a big step in making that happen. There is still a lot to do, so let's get to it! + +## Q&A: + +We know there might be some concerns so we included a short Q&A section below for some common ones. Feel free to ask in our [Discord](https://discord.modrinth.com) if you have any more questions! + +### Why does Modrinth need funding? + +Our main expense is and will continue to be salaries. The labor cost has always been the main bottleneck for Modrinth. Having paid employees will allow us to develop Modrinth faster, bringing Modrinth to a point of sustainability and growing the platform. For example, we're planning to release our launcher this year, and eventually we're hoping to expand into more games. Those won't be possible without having paid employees. + +### Is Modrinth still community-first? + +We started and always will have the goal of creating a community-oriented, open-source modding platform. Simply put, there isn't any reason for us not to be. It's clear that the previous impersonal, corporate approaches to video game modding have not worked, and Modrinth is excited to change that. + +### Will Modrinth still be open-source? + +Yes! We are committed to having all (when possible) our current code and future code we write to be open-source. Copyright is held by the contributors as we have no [CLA](https://en.wikipedia.org/wiki/Contributor_License_Agreement), so we cannot make it closed-source (even if we wanted to) without, well, violating the law. + +### Who's behind Modrinth? + +The Modrinth team (currently consisting of Prospector, Emma, and Geometrically) is behind Modrinth. We've been modding Minecraft for years, with connections extending back to grade school. Investors have a minority stake in the company, and have no control or say in our decisions. + +### Is Modrinth going to adopt web3/cryptocurrency? + +No. We have no plans to adopt or explore web3 for Modrinth. + +### Will investment money be used to fund creator payouts? + +Not directly. Hiring more people will allow us to build up the infrastructure that can increase payouts, but the money we pay out to creators will always come from sustainable sources such as advertising and never from investment funds. diff --git a/packages/blog/articles/becoming-sustainable.md b/packages/blog/articles/becoming-sustainable.md new file mode 100644 index 000000000..05fc03364 --- /dev/null +++ b/packages/blog/articles/becoming-sustainable.md @@ -0,0 +1,38 @@ +--- +title: Quintupling Creator Revenue and Becoming Sustainable +short_title: Becoming Sustainable +summary: Announcing an update to our monetization program, creator split, and more! +short_summary: Announcing 5x creator revenue and updates to the monetization program. +date: 2024-09-13T12:00:00-08:00 +authors: ['MpxzqsyW', 'Dc7EYhxG'] +--- + +Just over 3 weeks ago, we [launched](/news/article/introducing-modrinth-refreshed-site-look-new-advertising-system) our new ads powered by [Aditude](https://www.aditude.com/). These ads have allowed us to improve creator revenue drastically and become sustainable. Read on for more info! + +## Creator Revenue + +We’re excited to share we have been able to increase creator revenue by 5-8x what it was before! + +There’s a couple changes to how revenue is distributed out to creators coming with this increase. + +First, revenue is no longer entirely paid out the day they are earned. Previously, we used our own in-house advertisement deal which paid us in advance for the entire month, and we divided that among each day in the month, as the month progressed. With the switch to a more traditional ad network, we are paid on a NET 60 basis, which is fairly standard with ad networks. What this means is that some of your revenue may be pending until the ad network pays us out. Exactly how this works is explained further [here](legal/cmp-info#pending). + +Second, the revenue split between Modrinth and Creators has changed. See the next section on sustainability for more on this. + +![Some creators have wondered if the new revenue is a bug because it’s gone up so much!](./abnormally-high-revenue.webp) + +## Becoming Sustainable + +We have updated the Modrinth creator revenue split from 90/10 to 75/25. However, all of the increases listed above are with the new rate included, so while the percentage is lower, the overall revenue is much, much higher. + +While 90% is a more remarkable figure, we changed it in order to ensure we can keep running Modrinth and continue to grow creator revenue without having to worry about losing money on operational costs. + +Through these changes, we are proud to announce Modrinth is now fully sustainable with the new income, with all hosting and operational costs accounted for (including paying our developers, moderators, and support staff!) With the new revenue, users will see reduced support times and we will be able to ship bigger and better updates quicker to you all! + +In an effort to be more transparent with our community than ever before, we are opening up as many of our finances as possible so you all can know how we’re doing and where all the money is going. We’re working to develop a transparency page on our website for you to view all the graphs and numbers, but it wasn’t ready in time for this blog post (for now, you can view our site-wide ad revenue in the API [here](https://api.modrinth.com/v3/payout/platform_revenue). We also plan to publish monthly transparency reports with more details about our revenue and expenses, the first of which should be available in early October, so keep an eye out for that. + +For now, we can tell you that creators on Modrinth have earned a total of $160,868 on Modrinth to date (as of September 13, 2024), and here’s a graph of our revenue from the past 30 days: + +![Modrinth Advertising Revenue (last 30 days)](./revenue.webp) + +We have a lot of exciting things coming up still, and of course, we greatly appreciate all of your support! diff --git a/packages/blog/articles/capital-return.md b/packages/blog/articles/capital-return.md new file mode 100644 index 000000000..5b9efafb4 --- /dev/null +++ b/packages/blog/articles/capital-return.md @@ -0,0 +1,49 @@ +--- +title: A Sustainable Path Forward for Modrinth +summary: Our capital return and what’s next. +date: 2024-04-04T12:00:00-08:00 +authors: ['MpxzqsyW'] +--- + +Over three years ago, I started Modrinth: a new Minecraft modding platform built on community principles, a fully open-source codebase, and a focus on creators. + +What started as a hobby project quickly grew into something much bigger, with over twelve thousand creators and millions of players modding their game with Modrinth! Running Modrinth quickly evolved into a full-time job as we worked to scale the platform, develop new features, and fix bugs. + +As our small project that originated in the Fabric Discord server started to get more and more serious, we decided to seek funding to accelerate growth and keep up with the competition. A year and a half ago, we raised a $1.2 million [pre-seed round](/news/article/accelerating-development) of investor capital. With the money, we hired full-time developers and part-time community members, some of whom have supported Modrinth since the very beginning. With all this support, we launched creator monetization, authentication, analytics, organizations, collections, the Modrinth App, and support for more project types, growing Modrinth’s user base fifteen-fold! + +But, this rapid growth came at some costs. We let sustainable infrastructure for moderation slip to the back-burner since we could just hire extra moderators to compensate, and more and more of my time as the founder was taken up by things that didn’t make Modrinth better. Bugs and technical debt also gradually infected our codebase as we focused on hyper-growth over maintenance. + +Alongside this, as we looked more into the future, we saw that the venture-backed cycle wouldn’t be the right path for Modrinth. Every investor invests in a company with the expectation of a return on their investment, and while all of our backers have been incredibly supportive, we wanted to be able to work on Modrinth at our own pace and terms. We’ve seen other companies in this space prioritize profits and growth at the expense of the community and creators, and we didn’t want this to happen to Modrinth. + +In short, forgoing the venture route would allow us to build Modrinth independently at a sustainable pace and put our creators, community, open-source nature, and values first, without having to worry about expectations of profit or growth. + +In the end, as of February 1st, 2024, I decided to return $800k in remaining investor capital back to our investors. + +This decision was not an easy one, as without this funding, we would be unable to support the Modrinth team as it previously existed. With this reality, I made the difficult decision to significantly reduce the size of our team to match our goals of sustainable growth. + +I also owe a huge debt of gratitude to everyone on the team affected by all of this–Emma, Wyatt, Maya, Coolbot, Jade, Carter, and Prospector–for everything they have done to help make Modrinth what it is today. + +I want to take a moment to highlight each of their contributions: + +- Emma was our lead moderator, social media manager, overall marketing lead, blog post writer, documentation maintainer, Minotaur maintainer, and support manager since joining the team in April 2021 +- Wyatt was a full-time backend developer that worked on our authentication system, analytics, collections, organizations, and tons of work on API v3, and more, since joining the team in February 2023 +- Maya was our first exclusive moderator hire, and despite a rough onboarding due to a lack of internal documentation and procedures on our side, had reviewed thousands of projects since joining the team in April 2023 +- Coolbot was another one of our moderators who especially helped us establish new procedures and improved internal documentation for moderators and had also reviewed thousands of projects since they joined the team in August 2023 +- Jade was also a moderator and had reviewed thousands of projects since joining the team in August 2023 +- Carter was a full-time frontend developer that worked on OAuth, analytics, collections, organizations, and more, since joining the team in October 2023 +- Prospector is our frontend developer and lead designer, who has been with us since September 2020 and has spearheaded multiple site redesigns, developed the frontend for core parts of the site, and more + +This transition was challenging, causing significant delays in project reviews and support ticket resolution, not to mention the stress for the former team. While project review and support times have returned to normal, this was not the experience we wanted for our creators or users to have. I sincerely apologize that you all had to experience this transition, and I wish that it had been executed more smoothly. + +I would also like to apologize for how long this post has taken to come out. It took longer than I expected to do all the legal work and coordination necessary to return the remaining money to the investors, but it has finally been finished. + +Going forward, we will be continuing to build a platform that is sustainable for both the creators and all the people who work on making the platform what it is. Hosting Modrinth is already sustainable, and we are working to make developing Modrinth sustainable as well. + +We’ve made great strides in this already with new moderation infrastructure including AutoMod and a built-in moderator checklist, greatly reducing moderator time per project. We’re also focused on increased transparency, through providing consistent updates on Modrinth’s development and making it easier to contribute to Modrinth with better documentation and contribution cycle. + +We started Modrinth to serve the community, and are taking this path so we can continue to. We hope you all will continue to support us as the newly independent Modrinth. + +— + +**Jai (aka Geometrically)** +Founder of Modrinth diff --git a/packages/blog/articles/carbon-ads.md b/packages/blog/articles/carbon-ads.md new file mode 100644 index 000000000..191c657ce --- /dev/null +++ b/packages/blog/articles/carbon-ads.md @@ -0,0 +1,70 @@ +--- +title: Modrinth's Carbon Ads experiment +summary: 'Experimenting with a different ad providers to find one which one works for us.' +date: 2022-09-08 +authors: ['6plzAzU4'] +--- + +**Update 10/24:** A month and a half ago we began testing Carbon Ads on Modrinth, and in the end, using Carbon did not work out. After disabling ads with tracking in them, the revenue was about equal to or worse than what we were generating previously with EthicalAds. Effective today, we are switching our ads provider back to EthicalAds for the time being. + +As a step towards implementing author payouts, we're experimenting with a couple different ad providers to see which one works the best for us. We've been using [EthicalAds](https://www.ethicalads.io/) for a long time now, but we'd like to now try out [CarbonAds](https://www.carbonads.net/). + +In most respects, this is a temporary experiment, but we're hoping that Carbon will become our primary ad provider in the near future. + +Over the past week and a half, we've garnered a lot of useful feedback in the `#devlog` channel of [our Discord](https://discord.gg/EUHuJHt). Over those 1,300 or so messages of open discussion and debate, there were also a lot of questions, concerns, and misconceptions. This blog post aims to address the most frequent of those. + +## FAQ + +### Is Carbon GDPR and CCPA compliant? + +Yes. This was confirmed to us via email by a Carbon representative. + +### Are the ads intrusive? + +No. They fall under the [Acceptable Ads Standard](https://acceptableads.com/standard/); that is, there is only ever one per page, they are less than 120 pixels tall, and they are separate and distinguishable from actual site content. + +### Where did the privacy settings go? + +Alongside the introduction of Carbon, we have removed the privacy settings that we previously had. These privacy settings controlled whether PII would be sent in our internal analytics and whether you wanted personalized ads to show up. Our analytics do not contain PII and Modrinth does not show personalized ads. Both of those would be intense breaches of your privacy, opt-in or not, and Modrinth intends to respect your privacy. + +### Why are you switching before you've released payouts? + +We have been using [ariadne](https://github.com/modrinth/ariadne) to take note of page views and ad revenue since August 1st, 2022. While creator payouts cannot yet be claimed, all ad revenue from this date forward will be claimable once payouts are released! + +Payouts are not yet done, but this switch is one of the largest things that needs to be done prior to its release. + +### Why does Modrinth need to switch away from Ethical? + +There are quite a number of reasons why it's not feasible for us to continue using Ethical. In order to be fully transparent, let's go into detail about each of them. + +#### In-house ads + +Over half of the ads shown by Ethical are their so-called "in-house ads". That is, Ethical does not have enough inventory to always be showing an ad, so instead it shows an advertisement for itself. These self-advertisements make a whopping $0 for Modrinth. + +Ethical does provide an option to replace these self-advertisements with our own fallback ads, which we've done for the past month or so. However, negotiating those sorts of deals takes an excruciating amount of time, time that we would rather be spending on developing Modrinth to make it better. + +Carbon allows us to have a more hands-off approach with advertising, which is most ideal for us right now. + +#### Poor CPM + +Ethical gives us an average of $0.24 for every thousand page views (also known as CPM) after taking into account the aforementioned in-house ads. Anyone who knows anything about the advertising business knows that this figure is abysmally low. With Modrinth getting over four million page views in a month's timespan, we make an average of less than $1000 per month with Ethical. This simply isn't sustainable for the thousands of creators on Modrinth. + +While we can't quite be sure what our CPM with Carbon will be -- again, this is only a temporary experiment for now -- we have reason to believe that it will be considerably greater than what Ethical can provide. + +#### Network in decline + +Over the time that Modrinth has used Ethical, we have found that the diversity of the advertisers shown has declined at a rate greater than is sustainable. The vast majority of the ads shown by Ethical, excluding its in-house ads, are for DigitalOcean. If DigitalOcean decided to withdraw from Ethical, that would end up toppling our entire system. Modrinth's payouts simply cannot rest on this house of cards if we wish to grow in any capacity. + +### Can I still use my adblocker? + +You are still able to access Modrinth using an adblocker, and Modrinth will not force you to disable it to access the site. However, Modrinth's ads are unintrusive and take up no more space than it would otherwise. + +When you turn off your adblocker for Modrinth, you are supporting both Modrinth and its creators in the process. 100% of the ad revenue from creator pages, including projects, versions, and users, go directly to creators. The ad revenue from other pages, including search, pay for Modrinth's upkeep costs and allow us to continue to exist. + +For the benefit of everyone involved, we humbly request that you turn off your adblocker for Modrinth. We have a full guide for how to turn off your adblocker located [on our docs site](https://docs.modrinth.com/docs/details/carbon/). + +## Conclusion + +In conclusion, we hope you're as excited about our upcoming release of payouts as we are. Exploring our options for ad providers is quintessential if we wish to be sustainable for payouts, and the best time to do this is now. As always, though, no release ETAs! + +Please note that this blog post was not editorialized or reviewed by Carbon prior to publishing. These are the findings and words of Modrinth and Modrinth alone. What's said here about CPMs and other statistics will not be true of other sites, but they are true for Modrinth. diff --git a/packages/blog/articles/creator-monetization.md b/packages/blog/articles/creator-monetization.md new file mode 100644 index 000000000..a68ac03ff --- /dev/null +++ b/packages/blog/articles/creator-monetization.md @@ -0,0 +1,62 @@ +--- +title: Creators can now make money on Modrinth! +summary: 'Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.' +date: 2022-11-12 +authors: ['6plzAzU4'] +--- + +Yes, you read the title correctly: Modrinth's Creator Monetization Program, also known as payouts, is now in an open beta phase. All of the money that project owners have earned since August 1st is available to claim **right now**! + +This includes even projects other than mods! Modpacks, plugins, and resource packs also generate payouts for creators to claim. + +Alongside this, the frontend also got a facelift across the entire site, most notably on the settings, notifications, and user profile pages. + +## Motivation + +Since the start, Modrinth has been a platform created by Minecraft content creators for Minecraft content creators. Allowing creators to earn a bit of money for their hard work and dedication to their content has been a goal since we started, and we are so incredibly ecstatic to finally be able to release this for everyone. + +Whether it's used for buying coffee, paying for server costs, or to get that luxury pair of socks, we hope that creators will be able to use their payouts on whatever keeps them running. We want to encourage creators to keep making content for everyone to enjoy, with the hope that everyone will eventually be able to call Modrinth their go-to destination for Minecraft modded content. + +## How it works + +For every project uploaded to Modrinth, we keep track of its page views and downloads through an internal system we call [ariadne](https://github.com/modrinth/ariadne). Through our payouts algorithm ([source code](https://github.com/modrinth/labrinth/blob/master/src/routes/admin.rs#L95)), we distribute 100% of ad revenue earned from creator pages to the creators behind these projects. Project owners can decide how to split it (or how not to split it) between their team members. + +Modpacks are a bit different, with revenue split 80% to the Modrinth dependencies on the pack and 20% to the modpack author. This split is subject to change and will be evaluated periodically to ensure the split is reasonably fair. + +After taking the search pages into account, around 10% of the site's ad revenue ends up going to us, mainly to cover hosting and personnel expenses, and 90% to creators. + +While payouts will be small at first, we're working on improving our ads system to better fund the program. We've also got big projects coming soon to continue our trajectory of making the monetization program and the site better! + +## How do I earn money? + +When a project of yours on Modrinth gets approved, you are automatically enrolled into the program. You will start to incur a balance, which you can view from the [Monetization dashboard](https://modrinth.com/dashboard). You can claim your first payout via PayPal or Venmo as soon as you enter your credentials and have the minimum balance of 0.26 USD. + +Even though the minimum is low, you will want to wait some time to allow your balance to build up before claiming. Each payment processor has its own fees which depend upon whether you're within the United States, which are detailed on the dashboard's [revenue tab](https://modrinth.com/dashboard/revenue). + +Once you request a transfer, you may have to confirm the transfer via email if you don't already have a PayPal account. If you do not confirm using the link in the email within 30 days, or the transfer fails for whatever reason, the amount requested will be returned to your Modrinth balance, though the processor's fees may already have been deducted by that point. + +### For residents outside the United States + +Since Modrinth is a US-based company, all amounts are stored, displayed, and paid out in US dollars. PayPal will convert the amount to your local currency once you begin the process of transferring from your Modrinth balance to your PayPal account. + +We're aware of some extenuating circumstances for creators living in areas affected by geopolitical conflict. As such, we are looking into alternative ways to allow payouts to continue in these regions. + +At the moment, there are no mechanisms in place to make your Modrinth balance expire after some time, though this is likely to be added in the future for creators who do not claim their balance after several years. Rest assured, we will have processes in place to make sure that your money doesn't go poof just because you weren't able to claim it in time. + +## Frontend facelift + +The website frontend has had some "small" changes of around 12,322 lines of code to accommodate payouts and many other changes. Many of these changes were inspired by the experiments done on the SvelteKit Rewrite, progress on which is paused for the time being. Navigate around the main site for a bit to discover some of these changes! Highlights include: + +- Improved project creation and report filing workflow via modals +- Improved 404 page +- Deduplicate identical version changelogs +- Cleaner user profile pages +- Easier to navigate settings and notifications +- Spacing, font, and accessibility tweaks +- And plenty more! + +## Conclusion + +This is a jam-packed update, and it would be impossible to list all the changes in this post. Feel free to explore the site, claim your funds, and give us feedback on [Discord](https://discord.modrinth.com). If you suspect you've found any critical bugs or exploits, please email us immediately at [support@modrinth.com](mailto:support@modrinth.com) - otherwise, for non-critical bugs, report them [on GitHub](https://github.com/modrinth). + +👑 diff --git a/packages/blog/articles/creator-update.md b/packages/blog/articles/creator-update.md new file mode 100644 index 000000000..78846ca4a --- /dev/null +++ b/packages/blog/articles/creator-update.md @@ -0,0 +1,99 @@ +--- +title: 'Creator Update: Analytics, Organizations, Collections, and more' +short_title: The Creator Update +summary: December may be over, but we’re not done giving gifts. +short_summary: Adding analytics, orgs, collections, and more! +date: 2024-01-06T12:00:00-08:00 +authors: ['6plzAzU4'] +--- + +December may be over, but that doesn’t mean we’re done giving gifts here at Modrinth. Over the past few months, we’ve been cooking up a whole bunch of new features for everyone to enjoy. Now seems like as good of a time as ever to bring you our Creator Update! Buckle up, because this is a big one. + +The headlining features include: + +- **Analytics** - Allowing Modrinth creators to see statistics from their projects. +- **Organizations** - Better tools to manage shared ownership over multiple projects. +- **Collections** - A system for putting together shared sets of projects, similar to Spotify playlists. +- **New payouts system** - Updates to the existing Creator Monetization Program to better serve creators around the world. +- **New Markdown editor** - Explore a complete reworking of our text editor, making it easy even for those unfamiliar with Markdown. +- **OAuth integrations** - Our own implementation of the OAuth specification, allowing external applications to “log in with Modrinth”. + +## Analytics + +The long-awaited addition of **analytics** is here for creators! You can view analytics over time for your projects, including downloads, page views, and revenue, all in an effortlessly easy-to-use dashboard. + +![The analytics for a project, showing downloads, page views, and revenue, with a breakdown by country.](./project-analytics.jpg) + +![A screenshot of the analytics for a user, showing multiple different projects.](./user-analytics.jpg) + +The data for analytics have been collected over the course of many months. In fact, the data for revenue goes all the way back to August 2022, and the data for downloads and views back to February 2023. + +You can view the analytics for an individual project by going to the settings and clicking “Analytics”. You can view analytics for all of your projects in [the analytics dashboard](/dashboard/analytics). + +## Organizations + +Isn’t managing permissions across a bunch of different projects pretty tedious? We sure thought so. Just like on GitHub, you can now create organizations on Modrinth to manage permissions across multiple projects. + +![A screenshot of the organizations section of the Modrinth dashboard.](./organizations.jpg) + +You can create organizations from the [organizations dashboard](/dashboard/organizations). Each organization has a name, a brief summary, and an icon. Just like project members, organization members have a role, a monetization weight, and project permissions, plus permissions for the organization as a whole. Roles, monetization weights, and project permissions can be overridden on a per-project basis. + +![A screenshot of a user page, with two organizations shown at the very bottom.](./user-orgs.jpg) + +Unlike GitHub, usernames and organization names on Modrinth do not conflict with one another. If you want to have an organization named after yourself, feel free to do so! + +## Collections + +Just like how Spotify has playlists or how Goodreads has shelves, Modrinth now has collections! Collections are lists of Modrinth projects put together for a common purpose. You can then share these collections with others to view. + +![A screenshot of the Project Odyssey suite of mods as a collection.](./collections.jpg) + +Your [followed projects](/collection/following) now make up an automatically generated private collection, which you can access from the [“Your collections” section of the dashboard](/dashboard/collections). + +### Wait… aren’t those just modpacks? + +Not quite! Modpacks are much more complex than collections. Collections are simply lists of projects. Here’s a quick comparison: + +| Modpacks | Collections | +| --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Created through a launcher, such as the [Modrinth App](/app). | Created on the [Modrinth website](/dashboard/collections). | +| Contains options files, configuration files, and optionally files from outside of Modrinth, wrapped together in a `.mrpack` file. | Contains a list of Modrinth projects (mods, plugins, data packs, resource packs, shaders, and modpacks). | +| Has individual releases with version history. | Instantly updates whenever a project is added or removed. | +| Must be reviewed by Modrinth’s staff and approved per [Modrinth’s rules](/legal/rules) before it can be published. | Does **not** need to be reviewed by Modrinth’s staff. Can go public at any time. | +| After approval, can be **listed** in search, **archived**, **unlisted**, or **private**. | Can be **public** (shows up on your Modrinth profile), **unlisted** (only accessible by direct URL), or **private** (only you can access it). | + +All in all, collections are handy for easily grouping together and sharing Modrinth projects. If you’re bored on the subway heading home, you can look for new mods on your phone and quickly add them to a Modrinth collection. However, for many use cases, spending the time to create a modpack might make more sense. Collections and modpacks are both here to stay—one is not going to replace the other. + +## New payouts system + +PayPal and Venmo are so 2023. To enter 2024, we are adding support for a bunch of different new payout methods, including ACH (available for direct transfer to a United States bank account) and a couple thousand gift cards. You know, just “a few”. + +![The withdrawal screen, with PayPal, Venmo, ACH, Visa, and a preview of two of the available options for the United States (AMC and Airbnb)](./payouts.jpg) + +Whether you want Applebee’s in America, Boek & Bladkado in Belgium, or Cineplex in Canada, we’ve got them all and plenty more. Prepaid Visa cards, Amazon gift cards, and Steam gift cards are among the available options. Does anyone want a Home Depot gift card? We’ve got those, too. + +## New Markdown editor + +For the longest time, Modrinth’s text editor for descriptions, changelogs, reports, and more has just been a box to enter [Markdown syntax](https://en.wikipedia.org/wiki/Markdown). What about people who don’t know Markdown, though? Even for those who do, writing it out by hand gets tedious after a while. That’s why we rebuilt it from the ground up to make it far easier to use. + + + +Among its features are standard shortcuts (like `Ctrl+B` for **bold**), a monospace font in the editor itself, and buttons for inserting headers, basic formatting, lists, spoilers, block quotes, links, images, and YouTube videos. + +Using the image button, you can also now upload images directly, instead of having to use an external host or the Gallery tab of a project. You can still insert images from outside sources, though certain sources (such as the Discord CDN) are blocked. We will notify authors using these blocked sources to replace the images. + +## OAuth integrations + +Wouldn’t it be nice if other websites or apps could add a “Sign in with Modrinth” feature? We asked ourselves this and thought, yes, it would be nice to add. So we added it. + +The [OAuth2 protocol](https://en.wikipedia.org/wiki/OAuth) allows other services to gain a limited amount of access to your Modrinth account without compromising your login information. Maybe you want to create your own analytics dashboard? Or maybe you want to make your own way to add content to collections? How about connecting organization permissions to roles in a Discord server? The possibilities are endless. + +![A screenshot of an OAuth app requesting permission to your user profile.](./oauth.jpg) + +You can create a new OAuth application in the [Applications](/settings/applications) section of your settings. You can see which applications you’ve granted access to in the [Authorizations](/settings/authorizations) section. + +## Conclusion + +Want to hear more from us on a regular basis? Check us out on our social media pages; we post often on both [Mastodon](https://floss.social/@modrinth) and [X/Twitter](https://twitter.com/modrinth). You can also chat with us on [Discord](https://discord.modrinth.com) if you like that. + +Thanks to [intergrav](https://github.com/intergrav) for making the banner image. diff --git a/packages/blog/articles/creator-updates-july-2025.md b/packages/blog/articles/creator-updates-july-2025.md new file mode 100644 index 000000000..cf0c914ec --- /dev/null +++ b/packages/blog/articles/creator-updates-july-2025.md @@ -0,0 +1,38 @@ +--- +title: Creator Updates, July 2025 +summary: Addressing recent growth and growing pains that have been affecting creators. +date: 2025-07-01T21:20:00-07:00 +authors: ['MpxzqsyW'] +--- + +Hey all, + +The last few months have been quite hectic for Modrinth. We've experienced all-time highs in both traffic and new creators and have outgrown a lot of our existing systems, which has led to a lot of issues plaguing creators, especially. + +The team has been super hard at work at this, and I'm really glad to announce that we've fixed most of these issues long term. + +1. **Upload issues (inputs not showing up, instability, etc)** + + We've tracked these issues down to conflicting code between our ad provider and Modrinth's. For now, we've **disabled ads for all logged in users across the site** while we work on resolving these long term. Both web users and logged-in web users make a very small percentage of our ad revenue (7% for web and 0.05% for logged-in web users) so creators should see a very minimal revenue drop from this, and have a much better experience navigating and uploading to the site. + +2. **Moderation and report response times** + + Creators have had to wait, in some cases, weeks to get their projects reviewed. This is unacceptable on our part and we are actively overhauling our moderation tooling to improve the moderation experience (and lowering time spent per project). We've also hired 3 additional moderators/support staff (**bringing our total to 7 and the total team to 17 people!**). We're hoping to see a significant reduction in queue times over the coming weeks. + +3. **Ad revenue instability** + + While ad revenue is generally out of our control and tends to fluctuate a lot, on June 4th we noticed a sharp decrease in creator revenue (~35% less than normal levels). While our ad provider initially thought this was a display issue, after further inquiry there were 2 causes: 1) Google AdExchange falsely flagging our traffic as invalid 2) Amazon banning many gaming publishers from their network [due to panic in the gaming ads space](https://www.adweek.com/media/exclusive-ads-from-verizon-shell-and-others-ran-next-to-explicit-videos-on-top-android-app/). While the Amazon ban is now resolved, we no longer are running Google AdExchange in the desktop app due to invalid traffic issues. This will lead to a permanent revenue decrease (AdX contributed to ~20% of our ad revenue). We also updated our prebid version (the underlying tech used to run ad auctions) which has shown a measurable increase, bringing revenue back to "normal" levels. Overall, we are closely monitoring and will keep you all posted. However, despite all the issues, due to some end-of-quarter campaigns, **revenue in June was an all time high, at $227k ($170k paid to creators)**! + +4. **Payout outages** + + Creators should be able to withdraw their revenue at all times, but due to slow PayPal clearing times and poor planning by us, we've had multiple week long outages in withdrawals. While we do store funds 1:1, these "outages" happen because we primarily store creator funds in an FDIC-insured bank account, as we wouldn't want a PayPal/Tremendous account suspension to cause creators to lose funds. We've now set up internal reporting which should never cause this to happen again (or, if it does, drastically reduce the time payout outages happen) + +5. **Platform Revenue Route** + + Due to some unannounced breaking changes in Aditude's API, the platform revenue API was broken. It is now [working](https://api.modrinth.com/v3/payout/platform_revenue). You can also use `start` and `end` fields to filter any date range! + +6. **API and Uptime** + + We've migrated our infrastructure for the website, app, and servers to OVH over our existing non-redundant AWS system. We've hit 99.96% uptime on our API and 99.98% on Modrinth Servers! + +Thank you all for your patience! If you are having any more issues or have any questions about all of this, feel free to DM @geometrically on Discord or [start a support chat](https://support.modrinth.com) and we will be happy to help! diff --git a/packages/blog/articles/design-refresh.md b/packages/blog/articles/design-refresh.md new file mode 100644 index 000000000..ac9ff877f --- /dev/null +++ b/packages/blog/articles/design-refresh.md @@ -0,0 +1,78 @@ +--- +title: Introducing Modrinth+, a refreshed site look, and a new advertising system! +short_title: Modrinth+ and New Ads +summary: Learn about this major update to Modrinth. +short_summary: Introducing a new ad system, a subscription to remove ads, and a redesign of the website! +date: 2024-08-21T12:00:00-08:00 +authors: ['MpxzqsyW', 'Dc7EYhxG'] +--- + +We’ve got a big launch with tons of new stuff today and some important updates about Modrinth. Read on, because we have a lot to cover! + +## Modrinth+ + +First off, we’re launching [Modrinth+](/plus), a monthly subscription to help support Modrinth and all of the creators on it directly! + +As a Modrinth+ subscriber, you will get: + +- Ad-free browsing on the Modrinth App and website +- An exclusive badge on your profile +- Half of your subscription will go to creators on the site! +- …and more coming soon! + +Pricing starts at $5/month, with discounts depending on what region you live in and if you opt for an annual plan. + +We created Modrinth+ so people could help support Modrinth and creators on the site. We have no plans to paywall any content on Modrinth, and creator features will never cost money. We started Modrinth as a free and open-source platform, and we intend to keep it that way. + +If you do have a few extra dollars a month and want to help support Modrinth, this is a great way to do it! + +## New Site Design: Stage One + +We’re launching Stage One of Modrinth’s refreshed look to Modrinth.com today as well. I want to stress that it’s not fully complete and we’re going to be continuing to refine and finish updating the rest of the pages over the coming weeks. However, it has enough significant usability improvements and new features that we’re launching it broadly now. Please bear with us while we work to complete it promptly! + +![A screenshot of the new project page](./project-page.webp) + +Key new features include: + +- **New download interface** to ensure users get the correct version for the Minecraft version and mod loader they’re using +- **New versions list** page built from the ground up with a clean new look and tons of shortcuts to make navigation easier +- **New “compatibility” widget** on project pages to see what game versions, platforms, and environments each mod supports at a glance +- **Exclusion filters** in search pages +- Improved support for **vertical desktop displays** + +We know there will be some minor hiccups and disruptions of workflows, but we’d really appreciate it if you could gently let us know how a particular change has affected you on GitHub [here](https://github.com/modrinth/code/issues) (or upvote/comment on an existing issue) rather than declaring it’s the end of the world. + +## New Advertising + +In the last few months, Modrinth has grown an incredible amount. We are now serving over a petabyte of data per month (that is, 1,000 terabytes!) to over 20 million unique IP addresses. It’s almost unfathomable how large we have become since we started from nothing just four years ago. + +However, with growth like this, our costs have also grown drastically—primarily in bandwidth. This, unfortunately, means that we’ve grown well beyond what a single advertiser could support. + +Our original plan was to build out our own ad network (Adrinth) where we could cut out the middleman and provide highly targeted ads without the need for tracking to our gaming-specific audience. Unfortunately, we’ve grown too quickly (a very good problem to have!) and don’t have the immediate resources to do this at this time. + +This leaves us with no choice but to switch to a more traditional programmatic ads setup powered by [Aditude](https://www.aditude.com/) for the time being. We're not making this decision lightly, and we understand that some folks will not be happy about this change. Rest assured, we've made sure that our new ad network partner meets our requirements, such as compliance with all local regulations such as GDPR and CCPA, and that the new ads remain as unobstructive as possible with this format. + +These changes bring Modrinth back to sustainability as well as conservatively increasing creator revenue by three-fold! Along with paying hosting bills, the new income will also be used for more support staff and paid team members, decreasing ticket time and speeding up our development. + +We also want to thank our friends over at [BisectHosting](https://www.bisecthosting.com/) for supporting us with our ad deal for the past year. + +## Modrinth App 0.8.1 + +Over the last few months, we’ve been overhauling the internals of the Modrinth App to drastically improve performance and stability. Over one hundred issues have been closed with this update alone! Here’s a short list of the major changes: + +- Newer versions of Forge and NeoForge now work! +- Migrated internal launcher data to use SQLite. The app now loads in <40ms on average (compared to ~2.5s before)! +- Fixed issues where profiles could disappear in the UI +- Fixed random cases of the UI freezing up during actions +- Fixed directory changes being very inconsistent +- Drastically improved offline mode +- Fix freezing and include crash reports logs tab +- And over one hundred more fixes! + +Don’t have the Modrinth App? Check it out [here](/app)! + +## Conclusion + +Want to hear more from us on a regular basis? Check us out on our social media pages; we post often on both [Mastodon](https://floss.social/@modrinth) and [X/Twitter](https://twitter.com/modrinth). You can also chat with us on [Discord](https://discord.modrinth.com) if you like that. + +Thanks to [intergrav](https://github.com/intergrav) for making the banner image. diff --git a/packages/blog/articles/download-adjustment.md b/packages/blog/articles/download-adjustment.md new file mode 100644 index 000000000..9c3393ca6 --- /dev/null +++ b/packages/blog/articles/download-adjustment.md @@ -0,0 +1,43 @@ +--- +title: Correcting Inflated Download Counts due to Rate Limiting Issue +short_title: Correcting Inflated Download Counts +summary: A rate limiting issue caused inflated download counts in certain countries. +date: 2023-11-10T12:00:00-08:00 +authors: ['6plzAzU4', 'MpxzqsyW'] +--- + +While working on the upcoming analytics update for Modrinth, our team found an issue leading to higher download counts from specific countries. This was caused by an oversight with regards to rate limiting, or in other words, certain files being downloaded over and over again. **Importantly, this did not affect creator payouts**; only our analytics. Approximately 15.4% of Modrinth downloads were found to be over-counted. These duplicates have been identified and are being removed from project download counts and analytics. Read on to learn about the cause of this error and how we fixed it. + +A graph of many Modrinth projects and their download counts, showing a disproportionate amount of downloads from China. + +![Notice anything out of the ordinary?](./country-download-counts.jpg) + +More specifically, the issue we encountered is that the download counts from China were through the roof compared to the page view statistics. + +![A graph of many Modrinth projects and their page views, showing a relatively even distribution across countries.](./country-page-views.jpg) + +Upon further investigation, there was one specific launcher that was repeatedly downloading the same files from Modrinth over and over again within a very short time span. + +![A table of downloads split into several parts.](./downloads-table.jpg) + +Notice how the downloads in each section (delineated by the bold line) have the same path and were created within the same second. + +This, to say the least, baffled us. We already had code called [Sisyphus](https://github.com/modrinth/sisyphus) in place to limit the number of downloads that a single source can make over a given span of time. So what gives? + +As it turns out, the issue lay in the underlying technology used by Sisyphus. It uses [Cloudflare Workers](https://workers.cloudflare.com/) in order to intercept the request each time that a file is requested to be downloaded. Essentially, it acted like so: + +1. A source (whether this be a launcher, someone clicking the download button on the website, etc.) would request a file from Modrinth. +2. Sisyphus would take note of this source’s information, including what it requested, its IP address, and its request headers, and write it to a small database. If this source had not requested this path before, it would add one download to this file. If it had already requested it, it would not. +3. Sisyphus would then give the file that the source requested. It gives the file regardless of whether the download counted or not. + +For the most part, this system works fairly well. The main issue comes in step 2: it takes a little while for different Sisyphus instances to sync up with each other. One of the benefits of Cloudflare Workers is that the code is deployed to hundreds of different servers around the world. When multiple requests come in at the same time, they can get routed to different servers in order to allow each request to be handled faster. Cloudflare Workers, however, takes [up to 60 seconds](https://developers.cloudflare.com/kv/concepts/how-kv-works/#consistency) for each server’s information to sync up with each other. A server in Australia might know that a given source has already downloaded something, but a server in Turkey might not. As a result, multiple downloads from the same source might all get counted if they are handled by different servers. + +In order to fix this, we entirely rewrote Sisyphus. It still uses Cloudflare Workers, but all of the processing of step 2 has been offloaded to the main Modrinth backend. This not only speeds up downloads (even if only briefly), but also makes download counts more reliable. Over the past few days, we've already implemented the necessary adjustments. Our observations have shown that the results are significantly more consistent in their accuracy. Instead of having strange spikes in activity, the graph of new downloads now follows the expected pattern. + +![A graph that is split up into two parts: on the left, a spiky graph with the text "old sisyphus". On the right, a graph with consistent dips and peaks.](./new-sisyphus.jpg) + +Notice the spikes on the left? Compare that to the silky-smooth sinusoidal satisfaction on the right! + +To reiterate, the issue is now resolved and **payouts were not affected**. Payouts do not take into account downloads from launchers other than the [Modrinth App](/app); therefore, this adjustment has no bearing on payouts. + +P.S. Are you curious about why our download counter is called Sisyphus? In Greek mythology, Sisyphus rolls a boulder up a hill for the rest of eternity. Like Sisyphus, our download counter has no point other than to keep increasing for as long as Modrinth exists. diff --git a/packages/blog/articles/knossos-v2.1.0.md b/packages/blog/articles/knossos-v2.1.0.md new file mode 100644 index 000000000..fcc8c99ba --- /dev/null +++ b/packages/blog/articles/knossos-v2.1.0.md @@ -0,0 +1,64 @@ +--- +title: 'This week in Modrinth development: Filters and Fixes' +summary: 'Continuing to improve the user interface after a great first week since Modrinth launched out of beta.' +date: 2022-03-09 +authors: ['Dc7EYhxG'] +--- + +It's officially been a bit over a week since Modrinth launched out of beta. We have continued to make improvements to the user experience on [the website](https://modrinth.com). + +## New features + +We've added a number of new features to improve your experience. + +### Click to expand gallery images + +![The new expanding gallery images](./expand-gallery.jpg) + +In the gallery page of a project, you can now click on the images to expand the image and view it more closely. You can also use the left arrow, right arrow, and Escape keyboard keys to aid navigation. + +### Filters for the 'Changelog' and 'Versions' pages + +![The new changelog and versions filtering options](./version-filters.jpg) + +Versions on the Changelog and Versions page can now be filtered by mod loader and Minecraft version. + +### More easily access the list of projects you follow + +![The new 'Following' button in the profile dropdown](./following.jpg) + +The link to the list of your followed projects is now listed in your profile dropdown. + +## Fixes and Changes + +While new features are great, we've also been working on a bunch of bugfixes. Below is a list of some of the notable fixes, but it is not a comprehensive list. + +- Improved the layout of the search page's search bar and options card to more dynamically adjust to screen size +- Changed the tab indicator to be rounded +- Changed the download icon to be more recognizable +- Changed the profile dropdown caret to use an SVG instead of a text symbol for better font support +- Changed the styling on text fields to be more consistent with the design language of the site +- Changed the styling on disabled buttons to use an outline to reduce confusion +- Changed the styling on links to be more consistent and obvious +- Changed the wording of the options that move the sidebars to the right +- Changed the green syntax highlighting in code blocks to match the brand color +- Fixed the styling on various buttons and links that were missing hover or active states +- Fixed the inconsistent rounding of the information card on the home page +- [[GH-370]](https://github.com/modrinth/knossos/issues/370) Fixed download buttons in the changelog page +- [[GH-384]](https://github.com/modrinth/knossos/issues/384) Fixed selecting too many Minecraft versions in the search page covering the license dropdown +- [[GH-390]](https://github.com/modrinth/knossos/issues/390) Fixed the hover state of checkboxes not updating when clicking on the label +- [[GH-393]](https://github.com/modrinth/knossos/issues/393) Fixed the padding of the donation link area when creating or editing a project +- [[GH-394]](https://github.com/modrinth/knossos/issues/394) Fixed the rounding radius of dropdowns when opening upwards + +## Minotaur fixes + +[Minotaur](https://github.com/modrinth/minotaur), our Gradle plugin, has also received a few fixes. This isn't going to be relevant to most people, but is relevant to some developers using this tool to deploy their mods. + +- Debug mode (enabled through `debugMode = true`) allows previewing the data to be uploaded before uploading +- Fix edge case with ForgeGradle due to broken publishing metadata +- Fix game version detection on Fabric Loom 0.11 +- Fix `doLast` and related methods not being usable because the task was registered in `afterEvaluate` + +These fixes should have been automatically pulled in, assuming you're using Minotaur `2.+`. If not, you should be upgrading to `2.0.2`. + +Need a guide to migrate from Minotaur v1 to v2? Check the migration guide on the [redesign post](../redesign/#minotaur). diff --git a/packages/blog/articles/licensing-guide.md b/packages/blog/articles/licensing-guide.md new file mode 100644 index 000000000..cfb9830a8 --- /dev/null +++ b/packages/blog/articles/licensing-guide.md @@ -0,0 +1,68 @@ +--- +title: Beginner's Guide to Licensing your Mods +summary: Software licenses; the nitty-gritty legal aspect of software development. They're more important than you think. +date: 2021-05-16 +authors: ['6plzAzU4', 'aNd6VJql'] +--- + +Why do you need to license your software? What are those licenses for anyway? These questions are more important than you think + +## What is a software license? + +To summarise the [Wikipedia article](https://en.wikipedia.org/wiki/Software_license) on the matter, it's essentially a legal contract between you (the mod developer) and anyone who uses, copies, modifies, etc the mod or any code having to do with it. License has the power to allow people to do whatever they want, or only permit the usage of the mod in-game. However, the majority of cases lie in-between these opposites. + +## So which software license should I choose? + +First and foremost, the choice of the software license is not entirely up to you, because you have to have the legal ability to do so. For instance, not all licenses are compatible with Minecraft's EULA (End-User License Agreement). Besides, if you are not the only one working on the project, you must get permission from all other contributors to your code before changing or adding a license. Please, ensure you have done so before implementing a license. + +Before we can decide which one to use, however, we must establish some additional definitions. Open software licenses can be split into three main categories: **public domain**, **permissive**, and **copyleft**. + +### Permissive license + +A permissive license is a type of license that usually gives the abilities to use, copy, modify, distribute, sell, and relicense a piece of software. + +The most popular license on Modrinth, the [MIT License](https://cdn.modrinth.com/licenses/mit.txt), is a permissive license. It is an easy-to-read license designed to be used for developers, which is why it is used extensively in the Minecraft open source community. + +The [Apache License 2.0](https://cdn.modrinth.com/licenses/apache.txt) is also a very good permissive license to use. The main difference between it and the MIT License is that the Apache License gives an explicit patent grant, whereas patents must be registered manually with the MIT. There is also an additional clause with the Apache License, stating that any modified files must "carry prominent notices" of it being modified. + +### Copyleft license + +A copyleft license gives to the other party specific rights usually only given to the copyright owner, under the condition that those same rights are applied to all variations of that software. These are also sometimes called "viral" or "infectious" licenses, because of the requirement to pass those rights on to derivatives. + +The second most common license on Modrinth is a copyleft license: the [GNU Lesser General Public License Version 3](https://cdn.modrinth.com/licenses/lgpl-3.txt) (usually [shortened to](https://spdx.org/licenses/LGPL-3.0-only.html) LGPL-3.0). + +Typically, when a copyleft license is wanted, the [GPL-3.0](https://spdx.org/licenses/GPL-3.0-only.html) or [AGPL-3.0](https://spdx.org/licenses/AGPL-3.0-only.html) would be used. However, these licenses are **incompatible** if linking into Minecraft, due to an issue with the difference between proprietary and free software outlined by these licenses (more information [here](https://www.gnu.org/licenses/gpl-faq.html#GPLPlugins)). An exception can be added to allow linking, such as that found [here](https://gist.github.com/wafflecoffee/588f353802a3b0ea649e4fc85f75e583), but it is recommended to just use the LGPL-3.0 instead if possible. + +### Public domain dedication + +A public domain dedication gives all rights to everyone who gets a copy of the software. This includes but is not limited to the ability to use, copy, modify, distribute, sell, or relicense that software. Software with a public domain dedication has no copyright holder. + +The third most common license used on Modrinth is the [Creative Commons Zero 1.0 Universal](https://cdn.modrinth.com/licenses/cc0.txt), which is a public domain dedication with a strong international legal basis, while still retaining trademark and patent rights. + +Creative Commons licenses as a whole are not recommended for software, but rather for other creative works: use this license with caution. If you wish to have the simplest public domain dedication possible, the [Unlicense](https://cdn.modrinth.com/licenses/unlicense.txt) is also an option. + +### What if I don't want to choose a license? + +Without a license software is considered proprietary and all rights reserved. This means that people may only use it in the ways the copyright owner specifies, which, in the Minecraft world (no pun intended), typically just means downloading and using it; no modifications, unauthorized distributions: basically nothing. + +This is why picking a proper software license is so important. It tells everyone what they can and cannot do with your software, making the difference between software anyone can contribute to and change however they want, and software that only you have the code behind. + +That being said, All Rights Reserved and not using a license are options, if you don't want to choose a public domain, permissive, _or_ copyleft license. This can be useful in some cases, but as with any license, be aware of the effects: contributions will be difficult or impossible, and users may be inclined not to use your software. Also, in case of Minecraft, all mods, including the All Rights Reserved mods, are affected by Minecraft's EULA, which states: + +> Any Mods you create for the Game from scratch belong to you (including pre-run Mods and in-memory Mods) and you can do whatever you want with them, as long as you don't sell them for money / try to make money from them and so long as you don't distribute Modded Versions of the Game. + +What this means is you are not allowed to sell your mods even if you reserve all rights to them. There are plenty more examples of such details in licenses and other legal agreements in the modding world. All in all, be aware that you cannot decide all of your and other's rights with your license. + +## Conclusion + +To conclude, the importance of a software license cannot be overstated. You can choose whatever license you want (assuming you have the legal ability, of course), but be aware of the differences and consequences of choosing one over another. The licenses we've specified are what we recommend, as they are common and easy to understand. Hopefully, you will make your decision based on what you want to use and what your goals and purposes are. + +A massive thank you goes to Alexander Ryckeboer (Progryck) for the cover image! + +## Disclaimers + +We are not lawyers, and thus, **this is not legal advice.** No warranty is given regarding this information, and we (Modrinth) disclaim liability for damages resulting in using this information given on an "as-is" basis. For more information on the legal aspect to software licensing, please refer to "[The Legal Side of Open Source](https://opensource.guide/legal/)". + +No matter your choice of license, by uploading any Content (including but not limited to text, software, and graphics) to Modrinth, you give us certain rights to your Content, including but not limited to the ability to use, reproduce, or distribute. For more information, please see the [Modrinth Terms of Use](https://modrinth.com/legal/terms). + +Measurements for "most popular license", "second most common license", and "third most common license", were taken 2021-04-30. Custom licenses were not taken into account. diff --git a/packages/blog/articles/modpack-changes.md b/packages/blog/articles/modpack-changes.md new file mode 100644 index 000000000..600d35dea --- /dev/null +++ b/packages/blog/articles/modpack-changes.md @@ -0,0 +1,53 @@ +--- +title: 'Changes to Modrinth Modpacks' +summary: 'CurseForge CDN links requested to be removed by the end of the month' +date: 2022-05-28 +authors: ['MpxzqsyW', 'Dc7EYhxG'] +--- + +CurseForge CDN links requested to be removed by the end of the month + +Modrinth's alpha launch of modpacks has been highly successful in the nearly two weeks it has been live, with over forty +packs launched to the platform. However, a number of these packs include links to download mods from CurseForge's CDN, +which has caught the attention of CurseForge. On May 24th, 2022, a representative from Overwolf sent email correspondence +to us demanding us to remove all modpacks and documentation that contain references to CurseForge CDN links by the end +of the month. The message was vague, and didn't specify whether or not they were making a legal threat against us or not, +so we responded in attempt to clarify what would happen if we chose not to comply. In response, they told us that they +would "consider next steps." + +Modrinth has every intention of complying with their demands, despite our belief that this is a huge loss for the +community. However, CurseForge's immediate "next steps" were to message launcher developers, requesting that they break +support for Modrinth packs that contain CurseForge CDN links, and claiming to them that we outright refused to remove the +packs containing the links from our platform ourselves when we did not refuse. + +To be clear, Modrinth condemns the anti-competitive behaviors that CurseForge are engaging in, however, we do not wish +for CurseForge or authors who have elected to opt-out of third party downloads from their platform to be our enemies. +Modrinth is and will always remain a project in support of open source software, with open and free APIs for all to use, +and encouraging of much needed competition and diversity in the mod hosting space. + +Unfortunately, in order to comply with their request, all Modrinth modpacks must now use override JARs in place of any +links to CurseForge's CDN. Specifically, CDN links to `edge.forgecdn.net` and `media.forgecdn.net` will no longer be part +of the `.mrpack` [specification](https://docs.modrinth.com/docs/modpacks/format_definition/#downloads), effective today. +Of course, modpack authors must ensure that they are properly licensed to redistribute any mods that are not hosted on +the Modrinth platform. While this is a huge blow to modpack creators and users of our platform for now, relying on +CurseForge CDN links has always been unreliable as a long-term solution, because they could choose to change the links +at any time, and it leaves variables outside of our control. In the long run, packs containing mostly mods hosted on +Modrinth will be better for the growth of our platform and for the stability of modpacks. + +In order to use mods exclusively hosted on CurseForge as override JARs, pack creators must ensure that either of the +following conditions must be met: + +1. The mod is licensed under terms that allow for redistribution. The pack author is responsible for following the terms of the license. +2. General or individual permission is granted from the mod author. This can be in the form of a message from the author or a statement made on a mod's project description granting permission to use it in modpacks. + +In order to aid in this process, Modrinth will be building a third party mod license database and automated tools that +will help pack creators with the hassle that will be ensuring all of the mods in their packs are properly licensed. +In addition, packs will continue to be hand-reviewed by Modrinth moderation staff and verified. Do note that in this +transition time, the review process for modpack projects may experience significant delays. Authors of existing modpacks +on the platform will be reached out to in order to help them convert their existing packs to compliant packs. + +For those wondering, our next steps as a company are: + +1. Mod license database for Modpack authors +2. Creator monetization +3. The Modrinth launcher for downloading and creating modpacks. diff --git a/packages/blog/articles/modpacks-alpha.md b/packages/blog/articles/modpacks-alpha.md new file mode 100644 index 000000000..28b932b30 --- /dev/null +++ b/packages/blog/articles/modpacks-alpha.md @@ -0,0 +1,54 @@ +--- +title: 'Modrinth Modpacks: Now in alpha testing' +summary: After over a year of development, we're happy to announce that modpack support is now in alpha testing. +date: 2022-05-15 +authors: ['6plzAzU4'] +--- + +After over a year of development, Modrinth is happy to announce that modpack support is now in alpha testing! + +What does alpha mean, exactly? Principally, it means that **modpack support is still unstable** and that not everything is perfect yet. However, we believe it to be complete enough that it can be released for general use and testing. + +From this point forward, Modrinth has shifted development effort from modpacks to creator payouts. This long-anticipated feature means that mod developers, modpack creators, and anyone else who uploads content to Modrinth will be eligible to get the ad revenue generated from their project pages. + +## Where can I find them? + +Right next to mods on the site! URLs to modpacks are the same as mods, just with `mod` replaced with `modpacks`, so you can find the search at https://modrinth.com/modpacks. + +Over a dozen modpacks have already been created by our early pack adopters, and those are available for download right now! + +## Wait, so how do I download them? + +At this point in time, the only stable way to download modpacks and use them is through [ATLauncher]. You can also install Modrinth packs if you switch to the development branch of [MultiMC]. We're hoping to be supported by more launchers in the future, including our own launcher, which is still in development. Our [documentation for playing modpacks] will always have an up-to-date listing of the most popular ways to play packs. + +## How do I create packs? + +You can either use [ATLauncher] or [packwiz] to create modpacks. The [Modrinth format] is unique for our purposes, which is specifically in order to allow mods from multiple platforms to be in a pack. Our [documentation for creating modpacks] will always have an up-to-date listing of the most popular ways to create packs. + +## Can I use CurseForge mods in my modpack? + +Yes! The [Modrinth format] uses a link-based approach, meaning that theoretically, mods from any platform are usable. In practice, we are only allowing links from **Modrinth**, **CurseForge**, and **GitHub**. In the future, we may allow other sites. + +## What happened to Theseus? + +For a while, we've been teasing Theseus, our own launcher. While lots of progress has been made on it, we haven't yet gotten it to a usable state even for alpha testing. Once we think it's usable, we will provide alpha builds -- however, for now, our main focus will be shifting to payouts, with Theseus development ramping up once that is out. + +Remember: Modrinth only has a small team, and we have a lot of real-life responsibilities too. If you have experience in Rust or Svelte and would like to help out in developing it, please feel free to shoot a message in the `#launcher` channel in our [Discord]. + +## Conclusion + +All in all, this update is quite exciting for everyone involved. Just like with [the redesign](/packages/blog/articles/redesign.md), this is the culmination of months upon months of work, and modpack support is really a big stepping stone for what's still yet to come. + +Remember: alpha means that it's still unstable! We are not expecting this release to go perfectly smoothly, but we still hope to provide the best modding experience possible. As always, the fastest and best way to get support is through our [Discord]. + +Next stop: creator payouts! + +[ATLauncher]: https://atlauncher.com +[MultiMC]: https://multimc.org +[packwiz]: https://github.com/packwiz/packwiz +[Modrinth format]: https://docs.modrinth.com/docs/modpacks/format_definition/ +[documentation for creating modpacks]: https://docs.modrinth.com/docs/modpacks/creating_modpacks/ +[documentation for playing modpacks]: https://docs.modrinth.com/docs/modpacks/playing_modpacks/ +[`packwiz cf import`]: https://packwiz.infra.link/reference/commands/packwiz_curseforge_import/ +[`packwiz mr export`]: https://packwiz.infra.link/reference/commands/packwiz_modrinth_export/ +[Discord]: https://discord.gg/EUHuJHt diff --git a/packages/blog/articles/modrinth-app-beta.md b/packages/blog/articles/modrinth-app-beta.md new file mode 100644 index 000000000..7af4a3ea7 --- /dev/null +++ b/packages/blog/articles/modrinth-app-beta.md @@ -0,0 +1,41 @@ +--- +title: Introducing Modrinth App Beta +short_title: Modrinth App Beta and Upgraded Authentication +summary: Changing the modded Minecraft landscape with the new Modrinth App, alongside several other major features. +short_summary: Launching Modrinth App Beta and upgrading authentication. +date: 2023-08-05T12:00:00-08:00 +authors: ['6plzAzU4'] +--- + +The past few months have been a bit quiet on our part, but that doesn’t mean we haven’t been working on anything. In fact, this is quite possibly our biggest update yet, bringing the much-anticipated Modrinth App to general availability, alongside several other major features. Let’s get right into it! + +## Modrinth App Beta + +Most of our time has been spent working on [Modrinth App](/app). This launcher integrates tightly with the website, bringing you the same bank of mods, modpacks, data packs, shaders, and resource packs already available for download on Modrinth. + +Alongside that, there are a wealth of other features for you to find, including: + +- Full support for vanilla, Forge, Fabric, and Quilt +- Full support for Windows, macOS, and Linux +- Modrinth modpack importing, either through the website or through a .mrpack file +- Modrinth modpack exporting to the .mrpack format to upload to the website or share with friends +- Importing of instances from a variety of different launchers, including MultiMC, GDLauncher, ATLauncher, CurseForge, and Prism Launcher +- The ability to update, add, and remove individual mods in a modpack +- The ability to run different modded instances in parallel +- The ability to view and share current and historical logs +- An auto-updater to ensure the app is always up-to-date +- An interactive tutorial to show you through the core features of the app +- Performance through the roof, backed by Rust and Tauri (not Electron!) +- Fully open-source under the GNU GPLv3 license + +More features will, of course, be coming in the future. This is being considered a **beta release**. Nonetheless, we’re still very proud of what we’ve already created, and we’re pleased to say that it’s available for download on our website **right now** at [https://modrinth.app](/app). Check it out, play around with it, and have fun! + +## Authentication, scoped tokens, and security + +The second major thing we’re releasing today is a wide range of changes to our authentication system. Security is a top concern at Modrinth, especially following recent events in the modded Minecraft community when several individuals were compromised due to [a virus](https://github.com/trigram-mrp/fractureiser/tree/main#readme). While Modrinth was not affected directly by this attack, it provided a harrowing reminder of what we’re working with. That’s why we’re pleased to announce three major features today that will strengthen Modrinth’s security significantly: in-house authentication, two-factor authentication, and scoped personal access tokens. + +### In-house authentication and two-factor authentication + +![A screenshot of the new Modrinth sign-in page, showing options to sign in with Discord, GitHub, Microsoft, Google, Steam, GitLab, or with an email and password.](./auth.jpg) + +Until today, Modrinth has always used GitHub accounts exclusively for authentication. That changes now. Starting today, you can now connect your Discord, Microsoft, Google, Steam, and/or GitLab accounts to your Modrinth account. You may also forgo all six of those options and elect to use a good ol’ fashioned email and password. No problems with that! (If you’re curious, we store passwords hashed with the Argon2id method, meaning we couldn't read them even if we wanted to.) diff --git a/packages/blog/articles/modrinth-beta.md b/packages/blog/articles/modrinth-beta.md new file mode 100644 index 000000000..1ebc9221b --- /dev/null +++ b/packages/blog/articles/modrinth-beta.md @@ -0,0 +1,31 @@ +--- +title: Welcome to Modrinth Beta +summary: 'After six months of work, Modrinth enters Beta, helping modders host their mods with ease!' +date: 2020-12-01 +authors: ['Dc7EYhxG'] +--- + +After six months of work, Modrinth enters Beta, helping modders host their mods with ease! + +Six months ago, in order to fill a void in the modding community, the project that would eventually become Modrinth was founded. Modrinth was created to bring change to an otherwise stagnant landscape of hosts. Today, Modrinth enters Beta, a huge step forward for Modrinth! + +![Modrinth's brand new design, rolling out with the launch of Beta](./new-design.jpg) + +> Modrinth's brand new design, rolling out with the launch of Beta + +## What's new? + +If you've checked out Modrinth in the past, here's the main things you'll notice that have changed: + +- All new clean and modern design in both light and dark modes +- Mods now display download counts correctly +- Mod information can now be edited in the author Dashboard +- More information can be added to mods + +## What's next? + +Modrinth is still in beta, of course, so there will be bugs. In the coming weeks and months, we will be prioritizing fixing the issues that currently exist and continue refining the design in areas that are rough. + +If you find any, please report them to the issue tracker: https://github.com/modrinth/code/issues + +If you would like to chat about Modrinth, our discord is open to all here: https://discord.modrinth.com diff --git a/packages/blog/articles/modrinth-servers-beta.md b/packages/blog/articles/modrinth-servers-beta.md new file mode 100644 index 000000000..8a500aa17 --- /dev/null +++ b/packages/blog/articles/modrinth-servers-beta.md @@ -0,0 +1,57 @@ +--- +title: Host your own server with Modrinth Servers — now in beta +short_title: Introducing Modrinth Servers +summary: Fast, simple, reliable servers directly integrated into Modrinth. +short_summary: Host your next Minecraft server with Modrinth. +date: 2024-11-02T22:00:00-08:00 +authors: ['MpxzqsyW', 'Dc7EYhxG'] +--- + +It's been almost _four_ years since we publicly launched Modrinth Beta. Today, we're thrilled to unveil a new beta release of a product we've been eagerly developing: Modrinth Servers. + +Modrinth Servers aims to provide the most seamless experience for running and playing on modded servers. To make this possible, we have partnered with our friends at the server hosting provider [Pyro](https://pyro.host). Together, we've developed fully custom software that gives us a unique advantage in scaling, offering new features and integrations that other hosts couldn't dream of. + +For this beta launch, **all servers are US-only**. Please be aware of this if you are looking to purchase a server, as it may not be optimal for users outside of North America. + +![A screenshot of the fully-custom Modrinth Servers panel integrated into Modrinth](./panel.jpg) + +## What makes Modrinth Servers unique? + +We understand that entering the server hosting industry might come as a surprise given the number of existing providers. Here's what sets Modrinth Servers apart: + +### The most modern hardware + +Your modpack shouldn't have to run slow. All our servers are powered by cutting-edge 2023 Ryzen 7 and Ryzen 9 CPUs with DDR5 memory. From our research, we couldn't find any other Minecraft server host offering such modern hardware at any price point, much less at our affordably low one. This ensures smooth performance even with the most demanding modpacks. + +### Seamless integration with Modrinth content + +Download mods and modpacks directly from Modrinth without any hassle. This deep integration simplifies server setup and management like never before. With just a few clicks, you can have your server up and running with your favorite mods. + +### Fully custom panel and backend + +Unlike most other server hosts that rely on off-the-shelf software like Multicraft or Pterodactyl, Modrinth Servers is fully custom-built from front to back. This enables higher performance and much deeper integration than is otherwise possible. Our intuitive interface makes server management a breeze, even for newcomers. + +### Dedicated support + +Our team is committed to providing exceptional support. Whether you're experiencing technical issues or have questions, we're here to ensure your experience with Modrinth Servers is top-notch. + +### No tricky fees or up-charges + +Modrinth Servers are offered in a very simple Small, Medium, and Large pricing model, and are priced based on the amount of RAM at $3/GB. Custom URLs, port configuration, off-site backups, and plenty of storage is included in every Modrinth Server purchase at no additional cost. + +## What’s next? + +As this is a beta release, there's much more to come for Modrinth Servers: + +- **Global availability:** We plan to expand to more worldwide regions and offer the ability to select a region for your server, ensuring optimal performance no matter where you are. +- **Support more types of content:** We'll be adding support for plugin loaders and improving support for data packs, giving you more flexibility and functionality +- **Social features:** A friends system to make sharing invites to servers easier, streamlining sharing custom-built modpacks and servers with your community. +- **App integration:** Full integration with Modrinth App, including the ability to sync an instance with a server or friends, making collaboration seamless. +- **Collaborative management:** Give other Modrinth users access to your server panel so you can manage your server with your team. +- **Automatic creator commissions:** Creators will automatically earn a portion of server proceeds when content is installed on a Modrinth Server. + +And so much more... stay tuned! + +We can't wait for you to try out [Modrinth Servers](https://modrinth.gg) and share your feedback. This is just the beginning, and we're excited to continue improving and expanding our services to better serve the Minecraft community. + +**From the teams at Modrinth and Pyro, with <3** diff --git a/packages/blog/articles/new-site-beta.md b/packages/blog/articles/new-site-beta.md new file mode 100644 index 000000000..889f9a5b6 --- /dev/null +++ b/packages/blog/articles/new-site-beta.md @@ -0,0 +1,25 @@ +--- +title: '(April Fools 2023) Powering up your experience: Modrinth Technologies™️ beta launch!' +short_title: '(April Fools 2023) Modrinth Technologies™️ beta launch!' +summary: Welcome to the new era of Modrinth. We can't wait to hear your feedback. +short_summary: Power up your experience. +date: 2023-04-01T00:00:00-08:00 +--- + +**Update 04/02: Due to a number of (ridiculous) complaints we received such as “not being able to use the site on mobile” and “the ads are a bit much”, we have decided to halt the rollout of the beta site. Happy April 1st, everyone.** + +--- + +OwO hewwo evewyone! I'm super exdited to announth that Modwinth is getting a bwand new update! WOOHOO! But, befowe I dwelve into the detaiws, I want to apologize in advance because my grammar and spwelling might not be the best (teehee). Anyway, this new update is going to be sow awesome and is going to make Modwinth the best modding website evew! + +But first, a quick message to all of our mobile users: We don't cawe about you anymore! _insert evil laughter here_ We've decided to focus all of our efforts on desktop users, so we won't be supporting mobile devices anymore. If you want to use Modwinth, you'll have to buy a desktop or laptop computer! (LOL) + +And that's not aww, we've also added a bunch mowe ads! Because, wet's face it, who doesn't wove ads? Am I wight? (hehe) We've added ads that will pop up evewy five seconds, so you won't miss them. And to make suwe you don't get bowed of seeing the same ad over and over again, we've made suwe to wotate them frequently. You'we welcome! UwU + +Oh, and did I mention the nyew wayout and design? We've made it weally cool! YAY! We wanted to make suwe that UwU aww feel a sense of nyostalgia and weminisce about the good owd days of the intewnet. Wemembew those days when websites wooked wike they wewe made by a 5-yeaw-owd? Well, we've bwought that back! (WINKY WINK) + +The Fitneshgwam Pacew Test is a multistage aewobic capabiwity test that pwogwessively gets mowe difficult as it continyues. The 20 metew pacer test will begin in 30 seconds. Nyya~ Meowmeow! Win the pacer test you must weach the othew end befowe the beep. Each time you hear the beep meow, uwu wun nyya to the othew end nya. The wun nya must be in wine with the beep. Meowmeow! The eawwier nya you wun, the mowe time you have to westa nya. If you faiw to weach the othew end befowe the beep nya meow, the test will end. So twy youw best and Gud wuck! + +In suwummawy, we'we weally excited about the nyew changes and we hope UwU awe too! Modwinth is no wonger a pwace for everyone, but wather, for deskto-p users onwy. We've also added mowe ads than evew befowe and made suwe to make the wayout and design as tewwible as possibwe! + +Enjoy! UwU diff --git a/packages/blog/articles/plugins-resource-packs.md b/packages/blog/articles/plugins-resource-packs.md new file mode 100644 index 000000000..86ecc3dd9 --- /dev/null +++ b/packages/blog/articles/plugins-resource-packs.md @@ -0,0 +1,95 @@ +--- +title: Plugins and Resource Packs now have a home on Modrinth +summary: 'A small update with a big impact: plugins and resource packs are now available on Modrinth!' +date: 2022-08-27 +authors: ['6plzAzU4'] +--- + +With the addition of modpacks, creating new project types has become a lot easier. Our first additions to our new system are plugins and resource packs. We'll also be working on adding datapacks, shader packs, and worlds after payouts are released. + +Don't worry - this hasn't taken away an awful lot of development time from author payouts. Those are still being worked on! + +## Plugins + +With plugins, we're supporting five loaders and three proxies: Bukkit, Spigot, Paper, Purpur, Sponge, BungeeCord, Waterfall, and Velocity. + +Several new categories have specifically been added for plugins, though mod categories can be used for plugins and vice versa. + +[Go browse our plugin section!](https://modrinth.com/plugins) + +### Why add plugins? + +This is a question we've received quite often since we first announced our intention to host plugins, so let's break it down a bit. + +Currently, there are three main platforms on which plugins can be downloaded from: Bukkit, Spigot, and Sponge's Ore. Notice the main issue there? These sites are bound to a specific loader. This isn't inherently _bad_ - however, as forks and new projects spawn, there is a noticeable lack of flexibility in what can be hosted on a given platform. For example, Spigot is unable to host plugins which specifically depend on the exclusive features provided by Paper's API. Paper's solution to this is to build their own platform, but this simply perpetuates the same problem. + +The best solution here is to create a separate platform which is unbiased and flexible enough to adapt to a changing ecosystem. Modrinth is the perfect candidate for this - after all, plugins are mods under a different name, and likewise mods are plugins under a different name. + +No matter the situation, authors are always allowed to upload their plugins to multiple sites. Build automation is incredibly easy to set up, especially with "set it and forget it" build tools such as [Minotaur](https://github.com/modrinth/minotaur). + +### Will paid plugins be supported? + +No. Modrinth does not have the infrastructure to support this, and it's not currently planned. Author payouts are still being worked on. + +### What about mods that have plugin versions and vice versa? + +Modrinth is taking a unique approach to this. While the search pages are separate, in reality, the backend is the same. You can select plugin loaders when creating a mod and you can select mod loaders when creating a plugin. The split only exists on the frontend so that projects like [Chunky](https://modrinth.com/mod/chunky) can share a single page across their versions. + +Plugins which also have versions for mod loaders will be displayed under the `/mod/` URL on the frontend. Plugins without mod loader versions are displayed under `/plugin/`. + +## Resource packs + +The other thing we've added support for is resource packs! + +Previously we hinted at Bedrock resource packs being supported in addition to Java resource packs. We've decided not to add Bedrock resource packs until we also add support for other Bedrock resources for various technical reasons. + +[Go browse our resource pack section!](https://modrinth.com/resourcepacks) + +### Secondary categories + +Resource packs are capable of adding a wide range of different things, like fonts, sounds, and core shaders. We found that the current category system was inadequate to account for all of these, especially with the three maximum limit. Thus, we've introduced a "secondary category" system, for categories which don't display by default but can still be searched. These secondary categories have a limit of 255 instead of three. Please add as many secondary categories as are relevant! + +On search pages, "Features" have been split into their own header. Where categories for resource packs can be accurately described as themes, features instead show what exactly a resource pack adds. Resolutions have also been split into their own header, though selecting a pack resolution is optional. + +### What about resource packs that require a mod to function? + +Resource packs are able to set dependencies on other projects (even those which aren't resource packs), just like how modpacks are able to set dependencies on mods. It's worth noting that OptiFine is not on the platform, and thus you cannot set a dependency on that; however, you can set a dependency on any of the other alternative mods which _are_ available on Modrinth, including [Entity Texture Features](https://modrinth.com/mod/entitytexturefeatures), [OptiGUI](https://modrinth.com/mod/optigui), [Continuity](https://modrinth.com/mod/continuity), [CIT Resewn](https://modrinth.com/mod/cit-resewn), [Animatica](https://modrinth.com/mod/animatica), or [Custom Entity Models](https://modrinth.com/mod/cem). + +## Other miscellaneous changes + +### Version number changes + +For a long time, version numbers have had a requirement to be unique within the same project. Alongside this update, we found it necessary to remove this restriction on version numbers. Thus, you'll no longer have to use something like `1.2.3+forge` and `1.2.3+fabric` if you have a project on multiple loaders - instead, you can just use `1.2.3`. + +To accommodate this, the frontend now appends the loaders and game versions onto the end of a URL if there are duplicates, and the [Modrinth Maven] now supports version IDs. + +We do not recommend retroactively changing version numbers to remove this additional metadata, though. If you change your version numbers, the following will break: + +- URLs to specific versions +- Buildscripts depending on your project via the [Modrinth Maven] +- Download counters (see labrinth issue [#351](https://github.com/modrinth/labrinth/issues/351)) + +### LiteLoader support + +Modrinth now supports LiteLoader for mods. It's nothing special, but it should help with some archival efforts. + +### Misc category deletion + +We've also deleted the `Misc` category as no one is going to want to filter by `Misc` in search. If you have any other suggestions for categories, feel free to suggest them in [our Discord][Discord] or [Tweet at us](https://twitter.com/modrinth)! + +## Developer/API changes + +The changes in this update are rather minimal when it comes to API-related stuff. Two new fields have been added to the [project struct](https://docs.modrinth.com/api-spec/#tag/project_model) - `approved`, which is the timestamp of when the project was approved (null if it's not approved or unlisted), and `additional_categories`, another set of categories which are to be seen as less important than normal categories. You can read the [secondary categories](#secondary-categories) section for more info on it. If you wish to implement the headers in your API integration, the [category list](https://docs.modrinth.com/api-spec/#tag/tags/operation/categoryList) now has a `header` field. + +As for the [search result struct](https://docs.modrinth.com/api-spec/#tag/project_result_model), `created` now matches the `approved` date rather than the `published` project field, and `categories` now also includes secondary categories. A new field, `display_categories`, matches only primary categories. + +Differences between mod loaders and plugins will need to be hardcoded within your API integration for the time being if you wish to have them shown separately. This will be cleaned up in API v3 alongside a general cleanup of a lot of other small aspects of the API. If you have any suggestions for breaking API v3 changes, feel free to suggest them in [our Discord][Discord]. Development on API v3 is likely to begin before the end of the year. + +## Conclusion + +We're very happy to be announcing this feature, even if it is minor in comparison to some of our other past and future announcements. Don't worry - author payouts are still being worked on, and will most likely be our next major announcement! We saw this as an opportunity to get a feature out with relatively little new code (since we'd already done everything needed alongside modpacks), so we ran with it. + +As always, feel free to provide feedback on [our Discord][Discord], and please report any bugs you come across on [our GitHub](https://github.com/modrinth). + +[Discord]: https://discord.modrinth.com +[Modrinth Maven]: https://support.modrinth.com/en/articles/8801191-modrinth-maven diff --git a/packages/blog/articles/pride-campaign-2025.md b/packages/blog/articles/pride-campaign-2025.md new file mode 100644 index 000000000..c7e6eca8b --- /dev/null +++ b/packages/blog/articles/pride-campaign-2025.md @@ -0,0 +1,22 @@ +--- +title: 'A Pride Month Success: Over $8,400 Raised for The Trevor Project!' +short_title: Pride Month Fundraiser 2025 +summary: Reflecting on our Pride Month fundraiser campaign for LGBTQ+ youth. +short_summary: A reflection on our Pride Month fundraiser campaign. +date: 2025-07-01T14:00:00-04:00 +authors: ['6plzAzU4', 'bOHH0P9Z', '2cqK8Q5p', 'vNcGR3Fd'] +--- + +What an incredible Pride Month! This June, we came together to support [The Trevor Project](https://www.thetrevorproject.org/), an essential organization providing crisis support and life-saving resources for LGBTQ+ young people. We are absolutely thrilled to announce that our community raised a stellar **$2,395**. That's not all, though — during the campaign, some donations were matched by H&M and the Trevor Project's Board of Directors up to **six times**, meaning the final impact of Modrinth's donations is a whopping **$8,464**. Our team was also in the top ten for most funds raised this year! + +To put your generosity into perspective, a donation this size can fund hundreds of hours on The Trevor Project's 24/7 crisis hotline. It gives young people a lifeline in their hardest moments and supports important educational services for many others. + +We couldn't have done it without you. To every person who donated, shared the post, or simply cheered us on from the sidelines — thank you. We are incredibly proud of what we've achieved together as a community. + +While Pride Month provides a special opportunity to focus our efforts, the need for these critical resources continues all year long. The challenges faced by LGBTQ+ young people do not end on July 1st, and organizations like The Trevor Project require ongoing support to continue their life-saving work. If you are able, we encourage you to consider making a contribution at any time. + +As part of this campaign, we also added the option to donate part of a Modrinth rewards balance to a variety of charities. This means Modrinth creators can directly use the revenue they earned from ads and [Modrinth+](/plus) to donate to dozens of causes close to their heart. The Trevor Project is one option, alongside other prominent non-profits such as the American Cancer Society, St. Jude's Children's Research Hospital, Doctors Without Borders, and the Southern Poverty Law Center. You can donate your Modrinth balance to these groups and many more by clicking the "Withdraw" button on [your revenue dashboard](/dashboard/revenue). + +Modrinth's June 2025 campaign will be kept for posterity at [this link](https://modrinth.com/pride). + +**[You can donate to The Trevor Project at any time at this link.](https://www.thetrevorproject.org/)** diff --git a/packages/blog/articles/redesign.md b/packages/blog/articles/redesign.md new file mode 100644 index 000000000..1a83e1e0f --- /dev/null +++ b/packages/blog/articles/redesign.md @@ -0,0 +1,141 @@ +--- +title: 'Now showing on Modrinth: A new look!' +summary: 'Releasing many new features and improvements, including a redesign!' +date: 2022-02-27 +authors: ['6plzAzU4'] +--- + +After months of relatively quiet development, Modrinth has released many new features and improvements, including a redesign. While we've been a bit silent recently on the website and blog, our [Discord server][Discord] has activity on the daily. Join us there and follow along with the development channels for the very latest information! + +For both those who aren't in the Discord and for those who are, this serves as a status update for what exactly has been going on in our silence. There have been an unparalleled amount of changes, improvements, bug fixes, and new features being worked on since April 2021, and we are incredibly excited to share them with everyone. There are still many things we're still working on, such as modpacks, but we've decided to hold back on that as there is still some fine-tuning that needs to be done on that front. + +## New and improved design + +The [frontend](https://github.com/modrinth/knossos) has received a considerable facelift. With designs made in part by [falseresync](https://modrinth.com/user/falseresync) (and a sprinkle of bikeshedding), we present to you, the redesign! + +As they say, a picture tells somewhere around nine-hundred odd words. As such, this section will be heavily focused on screenshots of the pages rather than long descriptions. + +### Project pages + +![The new page design, shown for Iris](./iris.jpg) + +_A beautiful project page for Iris to match its beautiful shaders_ + +On project pages, much of the focus has been shifted to the extended description rather than the metadata, which has been put over on the side. We've also added an option to switch this from the left side to the right side in your user settings, if you so desire. + +### Gallery + +![A preview of the gallery functionality](./consistency.jpg) + +_Pictures... pretty!_ + +Developers can now add a Gallery section on each project page! Each uploaded image or GIF can have a title and description associated with them. + +### Changelog + +![The changelog page](./adorn.jpg) + +_A changelog page for showing the difference between updates!_ + +Version changelogs are automatically compiled together into a large changelog list. These are put in reverse chronological order, and are separated for Fabric and Forge versions. + +### Version creation and dependencies + +![The version creation page](./version-creation.jpg) + +_Version creation has gotten an overhaul!_ + +While dependencies have existed in the backend for a while, their implementation was a bit haphazard and was never widely used due to never being in the frontend. Thus, all previous dependencies have been wiped, and they have been redone better(TM). And hey, now you can add and see dependencies in the frontend! + +### Profile settings & dashboard + +![The profile settings page](./profile-settings.jpg) + +_The new settings panel for managing your profile and other visual settings_ + +The dashboard has been reworked and reorganized: the "My mods" section has been merged into the profile page itself, and the "Settings" page has been split into "Profile" and "Security". There are also options for switching the project and search information from the left side of the screen to the right. + +![A user's profile](./jellysquid.jpg) + +The notifications page is also now its own page separate from the dashboard, accessible only from the header. The notifications page also has a highly-requested "Clear all" button. + +![The notifications page](./notifications.jpg) + +## Backend changes and API v2 + +There have been a number of breaking changes in this update, and as such, the API number has been bumped. The `/api/` prefix has also been removed, as it's redundant when the base API URL is `api.modrinth.com`. This means the production URL is now `api.modrinth.com/v2` instead of `api.modrinth.com/api/v1`. + +The major changes include the universal rename of `mod` to `project`, as well as the move of the `mod` endpoint to `search`. While version 1 will be supported until January 2024 and won't be removed until July 2024, we still highly recommend that applications migrate as soon as possible. For full migration instructions, see the migration guide [on the docs site](https://docs.modrinth.com/docs/migrations/v1-to-v2/). + +## Minotaur + +[Minotaur](https://github.com/modrinth/minotaur) is the tool for mod developers to upload their mod directly to Modrinth automated through Gradle. Minotaur received a considerable facelift and is now a lot more user-friendly. Previously, an example buildscript might look like this: + +```groovy +task publishModrinth(type: com.modrinth.minotaur.TaskModrinthUpload) { + onlyIf { + System.getenv().MODRINTH_TOKEN + } + token = System.getenv().MODRINTH_TOKEN + projectId = 'AABBCCDD' + versionNumber = version + versionName = "[$project.minecraft_version] Mod Name $project.version" + releaseType = 'alpha' + changelog = project.changelog + uploadFile = remapJar + addGameVersion('1.18') + addGameVersion('1.18.1') + addGameVersion('1.18.2') + addLoader('fabric') +} +``` + +This exact same buildscript snippet, in Minotaur 2.0.0, can be written as the following: + +```groovy +modrinth { + projectId = 'AABBCCDD' + versionName = "[$project.minecraft_version] Mod Name $project.version" + releaseType = 'alpha' + changelog = project.changelog + uploadFile = remapJar + gameVersions = ['1.18', '1.18.1', '1.18.2'] + loaders = ['fabric'] + dependencies = [ + new ModDependency('P7dR8mSH', 'required') // Creates a new required dependency on Fabric API + ] +} +``` + +Notice how it's now in a `modrinth {...}` block instead of creating a new task. The `modrinth` task is automatically created. + +The `loaders` declaration in the new version isn't even needed if you're using Fabric Loom or ForgeGradle. The project version can be detected automatically, and the token uses the `MODRINTH_TOKEN` environment variable by default now. The game version and loader listings actually make sense now, and dependencies are possible! + +## More miscellanea + +Along with the major headlining features, there are also a number of smaller features, fixes, and improvements coming with the big update. Most of these need no more than a bullet to describe, so here's a bullet list of the smaller things! + +- If you are the owner of a project, you can now transfer the ownership to another user, as long as they have already accepted an invitation to be a member. In the frontend, this can be done on the Settings page, under the "Team members" section. +- iframes for YouTube videos are now allowed. Any iframes from elsewhere are still not allowed. +- Files are now validated to ensure they contain a valid Forge or Fabric mod, or are in the correct modpack format. +- When changing the status of a project, file moderators are now able to add a message (heading and body separate) to be seen by project team members. +- Versions must now always have a file attached. +- Projects will only be able to have `draft` status if they contain no versions. Additionally, a new `archived` status has been added. +- Donation URLs have been re-enabled. +- Fix: Markdown checkboxes will no longer render strangely ([knossos#291](https://github.com/modrinth/knossos/pull/291)) +- Fix: [Maven](https://docs.modrinth.com/docs/tutorials/maven/) will no longer randomly break ([labrinth#264](https://github.com/modrinth/labrinth/pull/264) and [labrinth#252](https://github.com/modrinth/labrinth/pull/252)) +- ...and many other smaller things! + +## What happened to modpacks? + +We've been teasing modpacks for a long time now. While they're done for the most part, we've decided to hold back on their release for the time being. We're working hard to get those done some time soon, and there'll be another post when those are ready for general consumption. + +## Conclusion and a call for developers + +In conclusion, we hope that you're excited about this update as much as we are. We believe that, with how much work has been put into this update, it has definitely been worth the wait. + +On a separate note, are you looking to contribute to Modrinth? Have you got experience with Rust or Svelte? We're hiring! Please reach out to `Geometrically#8387` on Discord to apply for a position. + +Thank you for reading, and may your dreams be filled with pineapples, tiny potatoes, and squirrels. + +[Discord]: https://discord.gg/EUHuJHt diff --git a/packages/blog/articles/skins-now-in-modrinth-app.md b/packages/blog/articles/skins-now-in-modrinth-app.md new file mode 100644 index 000000000..6c6970a6d --- /dev/null +++ b/packages/blog/articles/skins-now-in-modrinth-app.md @@ -0,0 +1,24 @@ +--- +title: 'Skins — Now in Modrinth App!' +summary: 'Customize your look, save your favorite skins, and swap them out in a flash, all within Modrinth App.' +date: 2025-07-06T16:45:00-07:00 +authors: [bOHH0P9Z, Dc7EYhxG] +--- + +We're thrilled to roll out Modrinth App **v0.10** with a beta release of one of our most highly requested features, the **Skins page**. The Skins page allows you to manage all of your Minecraft skins directly within Modrinth App. You can see all your saved custom skins and the default Minecraft skins in one convenient place. + +![The new skins page, featuring a cute animated player model, your custom skins & default skins.](./skins-page.webp) + +Adding a new skin is simple, even Herobrine could do it! When you add or edit a skin, you can **upload** your custom texture file directly from your computer, **choose** between the wide or slim arm style to match your preferred character model, and even **assign** a specific cape to that look for the perfect finishing touch. + +The interface makes it easy to preview your changes in real-time with the animated player model, so you can see exactly how your skin will look in-game before saving it. + +![The edit skin modal that shows when you go to add or edit a skin.](./edit-skin.webp) + +## Fixes and More! + +Alongside this major new feature, **v0.10** includes a host of improvements and bug fixes to make your experience smoother. We've updated the news feed to use our new system, fixed issues with project descriptions, and tidied up how data is handled. For a full breakdown of all the changes, you can [check out the complete changelog here.](https://modrinth.com/news/changelog?filter=app) + +As the skins feature is in _beta_, we're eager to hear your feedback! **Jump in, give it a try**, and let us know what you think. You can share your thoughts on our [Discord server](https://discord.modrinth.com/) or [start a support chat](https://support.modrinth.com) if you're running into issues. + +Thank you! We can't wait to see your skins in action. Happy customizing! diff --git a/packages/blog/articles/two-years-of-modrinth-history.md b/packages/blog/articles/two-years-of-modrinth-history.md new file mode 100644 index 000000000..cdfca3f78 --- /dev/null +++ b/packages/blog/articles/two-years-of-modrinth-history.md @@ -0,0 +1,90 @@ +--- +title: 'Two years of Modrinth: a retrospective' +summary: The history of Modrinth as we know it from December 2020 to December 2022. +date: 2023-01-07 +authors: ['6plzAzU4'] +--- + +Let's rewind a bit and take a look at the past two years of Modrinth's history. We've come so far from our pre-beta HexFabric days to today. A good portion of our pre-beta history can be found in the [What is Modrinth](../what-is-modrinth) blog post, but Modrinth obviously is not the same platform it was two years ago. + +## December 2020: Modrinth Beta begins + +![Modrinth's brand new design, rolling out with the launch of Beta](../modrinth-beta/new-design.jpg) + +> Modrinth's brand new design, rolling out with the launch of Beta + +December was the release of the initial [Modrinth Beta](../modrinth-beta), bringing a completely different interface and the ability for mods to be created for the first time. This interface has since been completely discarded, but this is what Modrinth looked like for well over a year. It's hard to believe! + +December also brought the introduction of the [Minotaur](https://github.com/modrinth/minotaur) Gradle plugin for the first time for upload automation. Minotaur today also looks nothing like it did when it was introduced, but it still accomplishes the same exact thing. + +## January 2021: Improvements to mod uploading + +An announcement in mid-January brought several essential additions and improvements to Modrinth which we consider commonplace today. Among these were: + +- A separate version creation page +- The ability to edit and delete existing versions +- The ability to delete existing mods and users + +January also brought the introduction of Google AdSense onto Modrinth. The eventual results, including our switch after to EthicalAds, solidified Modrinth's stance that ads should be unobtrusive and generally friendly to users. + +## February-March 2021: Follows, reports, notifications, oh my! + +March brought the first introduction of the abilities to follow and report projects, as well as the automatic featuring of some versions depending on loader and Minecraft version. These systems have largely remained unchanged since their introduction, though the notification system will likely be getting a refresh come 2023. + +## April-December 2021: Season of silence + +After follows, reports, and all that jazz, Modrinth largely went silent for a good while. This time period had some of the largest growth Modrinth had ever seen, and yet it seemed Modrinth's development had ground to a halt. Modrinth Team members were dropping like flies until there was a point where there was a single person on the team. What happened? + +For various reasons, whether it be lack of free time or a lack of interest in Minecraft in general, people ended up leaving to pursue other things. It's not quite as apocalyptic or barren as these descriptions make it seem, but it's more fun to describe it like this. + +Picking up the remnants from what others had left behind, one man was destined to continue developing for Modrinth. The one who began the whole operation in the first place, Geometrically, stood up and began developing. Thus came the development of project types, gallery images, API v2, and modpacks. + +## January 2022: API v2 introduction + +Right around the corner came 2022. Perchance this would be the time for the silence to be broken? Indeed, the world would be able to hear about all that was brewing over the past few months. + +Of course, this wasn't all introduced at once—it was a gradual rollout over several months. First was the introduction of v2 of Modrinth's API, allowing many breaking changes to occur, including namely the renaming of _mods_ to _projects_. Wait, why was this necessary? + +Up until this point, Modrinth only hosted mods. Project types, as we call them, allow projects to be given the designation of something other than _mod_; for example, _modpack_ or _resourcepack_, like we have today. This simple field, alongside all of the infrastructure which was needed to support it, was the first step to allow modpacks on Modrinth. + +## February 2022: Redesign + +Remember the interface introduced in December 2020? Let's scrap it! Actually, it wasn't entirely scrapped, but it got a treatment similar to the [Ship of Theseus](https://en.wikipedia.org/wiki/Ship_of_Theseus) to the point that it was barely recognizable. + +![The Modrinth homepage](../redesign/thumbnail.jpg) + +> The former Modrinth homepage + +Alongside this was the official announcement of API v2, as well as the introduction of the project gallery, the changelog tab, dependencies, and many other things. [Here's the blog post announcement for the redesign](../redesign)! + +February also brought the introduction of several new Modrinth Team members to the fold, including Prospector and triphora, both of whom are still on the team, alongside Hutzdog and venashial, who we thank for helping us through much of 2022. + +## March-April 2022: Small changes and preparation for modpacks + +A couple weeks after the redesign we pushed out some changes which included improvements to several tabs on project pages and many bug fixes. [The blog post from that can be found here](../knossos-v2.1.0). + +The next couple months were spent preparing for the release of modpacks. This is the first introduction of our "early adopters" program, still in use today, allowing a feedback loop of authors and other community members to create the best product that we can. Without early adopters, many of the features on Modrinth which you've come to love, including modpacks, plugins, resource packs, would be less than ideal. + +## May 2022: Modpacks in alpha + +In May, we finally did the big release of modpacks on Modrinth. Well, in alpha, anyway—but that was less of a marker of instability and more a marker of being incomplete without the launcher. [The modpack alpha release blog post can be found here](../modpacks-alpha). + +When we first announced modpacks, the initial format had been set in stone for a couple years, and it had been decided that CurseForge links would be allowed within them. This got turned on its head due to an email sent to us by Overwolf. More information on that can be found on the [Changes to Modrinth Modpacks blog post](../modpack-changes). + +![Progryck](../modpack-changes/thumbnail.jpg) + +## June-August 2022: Plugins and resource packs + +The summer of 2022 was largely dedicated to working on releasing creator monetization. First, though, we made a pit stop to introduce plugins and resource packs to Modrinth. [Find that blog post here](../plugins-resource-packs). + +Plugins in particular were tricky since we had to account for projects which had both mod and plugin versions. It was at this point we realized that the project type system isn't entirely what we cracked it up to be, and we're hoping to completely replace it once API v4 rolls around, as far away as that may sound. For now, though, it will suffice. + +## September-November 2022: Creator monetization + +With plugins and resource packs done, we continued working on creator monetization. This included [a brief experiment](../carbon-ads) with a different ad provider before we eventually switched to creating [our own ad system](https://adrinth.com). + +November brought the actual beta release of creator monetization—[here's the blog post for that](../creator-monetization-beta). We are continuing to develop and refine this system to ensure authors continue to earn money from publishing projects on Modrinth. + +## December 2022-January 2023: Anniversary Update + +That, of course, brings us to today's [Anniversary Update](../two-years-of-modrinth)! Now that you're done reading this, feel free to go back over to that post and read about everything that's new in the Anniversary Update so that I don't have to repeat myself. Take a look at our New Year's Resolutions for 2023 while you're at it, too! diff --git a/packages/blog/articles/two-years-of-modrinth.md b/packages/blog/articles/two-years-of-modrinth.md new file mode 100644 index 000000000..55541f41b --- /dev/null +++ b/packages/blog/articles/two-years-of-modrinth.md @@ -0,0 +1,128 @@ +--- +title: Modrinth's Anniversary Update +summary: Marking two years of Modrinth and discussing our New Year's Resolutions for 2023. +date: 2023-01-07 +authors: ['6plzAzU4'] +--- + +Modrinth initially [went into beta](../modrinth-beta) on November 30th, 2020. Just over a month ago was November 30th, 2022, marking **two years** since Modrinth was generally available as a platform for everyone to use. Today, we're proud to announce the Anniversary Update, celebrating both two years of Modrinth as well as the coming of the new year, and we'll be discussing our New Year's Resolutions for 2023. + +Before you read this post, though, we recommend taking a look at [our retrospective on Modrinth's history through 2020—2022](../two-years-of-modrinth-history). It just wouldn't be right to take a look at the present and the future without also taking a look at our past, seeing how far we've come from our humble beginnings. + +With that out of the way, this post primarily serves to announce a few of the smaller features we've been working on after the release of creator monetization. We've bundled these all together as the **Anniversary Update**. + +Looking just at what's already done is boring, though, so we'll also be looking at what's yet to come. Modrinth's future is even brighter than any of us can imagine, so we'll be focusing on what we're gonna do in order to get to that bright future. If you've ever made **New Year's Resolutions**, we're going to briefly discuss our resolutions for 2023. + +Without further adieu, let's get right into what's new with this new year! + +## Shader packs and data packs + +The long-awaited arrival of shader packs and data packs is now here on Modrinth! + +Shader packs can be viewed in the [shaders tab](/shaders). This includes shaders that support [Iris](/mod/iris), [Canvas](/mod/canvas), and OptiFine, as well as vanilla core shaders. (Even though they're installed via the resource pack system, we have decided to put Canvas and core shaders in shader packs since most users will not search in resource packs for shader packs, even if that's how they're installed.) + +Data packs can be found in the [data packs tab](/datapacks). These are implemented similarly to plugins, in that projects with a mod version can also upload a data pack version (and vice versa). Additionally, data pack authors can choose to have their data packs packaged as a mod using the handy-dandy button on the site. + +Data packs can optionally upload a corresponding resource pack as a separate file. We discourage bundling data files and asset files in the same zip file. + +## New landing page + +The [homepage](/) has been completely remade, featuring a scrolling list of random projects from Modrinth. Feel free to use this to discover new projects—just make sure you refresh occasionally, because they loop after a little while until you refresh! + +![A screenshot of the new homepage, with a maze background and projects scrolling across the bottom. Bold across the front is "The place for Minecraft mods".](./landing-page.jpg) + +## Project overhaul for creators + +We're continuing to bring expansions to the creator dashboard introduced with monetization. The new **Projects** tab allows you to view all of your projects in a table and quickly access their information and settings. + +[![The new Modrinth project dashboard](./projects-dashboard.jpg)](/dashboard/projects) + +The same page also introduces the ability to bulk-edit the external resource links without having to edit each page individually. For example, if your Discord invite expires, you used to have to edit each of your projects individually to add it back. Now you can just select the projects you want to edit the links for and edit them all at the same time! + +![A modal with several input fields for external resource links, listing multiple projects the input changes will be applied to.](./bulk-edit.jpg) + +Even better are the changes to the settings page for individual projects. Previously, the project settings page was disorganized and cluttered. The project settings page has been completely redone, inspired by GitHub's repository settings page. + +![The new project settings page, shown for Sodium.](./project-settings.jpg) + +Draft projects also now have a publishing checklist, making it more clear to authors as to what their next steps should look like. Red asterisks are items that must be completed before submitting and purple light bulbs are suggestions. + +![A card with several tasks for a draft project owner to do, such as adding a description and selecting the necessary information.](./publishing-checklist.jpg) + +## Version page overhaul + +The layout of the individual version page has gotten a complete overhaul. It's much easier to just show the new UI in action rather than trying to explain it! + +A screenshot of the way that individual versions look now: + +![A screenshot of the way that individual versions look now.](./version-page.jpg) + +That's not all, though. Version creation now automatically infers most details after you upload your first file. Try it out sometime—whenever you upload your first file, most stuff should already be filled in. This system is still in-development, so if you find any issues, please file an issue on [GitHub](https://github.com/modrinth/code). + +## Project card views + +Anywhere which lists projects, namely search and user pages, have gotten a great overhaul. You can choose between the classic list view, the grid view, and the gallery view. + +![A screenshot of the default view for the Modrinth shaders search.](./search-gallery-view.jpg) + +By default, shader packs and resource packs use the gallery view, user pages use the grid view, and everywhere else use the list view. You can cycle through them near the top of each page or change them in your [display settings](/settings). + +The gallery image uses the featured gallery image on a project, so please ensure if you are a shader pack or resource pack author that you set a featured gallery image! + +## Gallery image UI for creators + +The existing UI for gallery image creation, editing, and deletion was flawed in many ways, so we threw out the old way of doing it and created a whole new system for this. It should be less prone to the many many bugs that plagued the previous implementation. + +![The new gallery image editing UI, in a modal](./gallery-ui.jpg) + +## New project webhook + +Our [Discord server](https://discord.modrinth.com) has a brand new channel: #new-projects. A webhook sends a message to this channel every time a new project gets approved. Check it out when you get a chance! + +![A screenshot of the new project webhook for Iris Shaders.](./project-webhook.jpg) + +## Miscellaneous additions + +- Custom SPDX license identifiers can now be selected, and a license's text is now displayed in a modal if the author has not manually set a license link. +- Each project now has a color associated with it, generated from the icon. This color is used in place of a gallery image in search if the project has no gallery image. +- The [bug with disappearing and duplicated versions](https://github.com/modrinth/code/issues/1748) due to the reuse of version numbers is now fixed. +- Whenever a project gets its status updated (for example from _under review_ to _approved_), the project's team members will now get a notification. +- The ability to manually reorder gallery images has been added via an integer ordering field. In the future, this sorting ability may expand to team members and versions. We also hope to add a drag-and-drop functionality similar to Discord server organization. +- You can also now formally request that your project be marked as unlisted, private, or archived instead of always having it be listed first. +- The ability to schedule the release of projects and versions has been added to the backend and is likely to be added to the frontend in the next few weeks. +- Several other bug fixes and minor features, mainly contributed by community members. + +## New Year's Resolutions + +Now that we've looked at everything accomplished over the past month and a half, let's take a look at our New Year's Resolutions—things we wish to achieve during 2023. + +### Theseus Launcher + +During 2023, our main focus will shift to the Modrinth launcher, code-named Theseus. Progress has been off and on for the past year and a half, but we intend to fully launch it before the end of the year. Theseus will bring a next-level experience to Minecraft launchers, bringing first-class support for Modrinth and unique features that would be difficult for other providers to parallel. + +The release of the Theseus project will also mark the end of the "alpha" status for Modrinth modpacks. Stay tuned for more information about alpha tests and early adopters programs! + +### Continuing to grow creator tools + +Another one of our focuses for this year is to put more work into our analytics system and in growing creator monetization through our [Adrinth](https://adrinth.com) ad network. As of today, monetization is now out of beta, but we are still constantly working on ways to make Modrinth even better and easier to use for new and returning creators. Some of these improvements are big, like the project settings overhaul, while others are more subtle quality-of-life improvements, like the fixes to usage of duplicate version numbers. + +### API changes + +This year, Modrinth hopes to introduce version 3 of [our API](https://docs.modrinth.com/api/) with lots of fixes and smaller changes. While our plans are still work-in-progress for this, one of the things that needs to be done first is the removal of the old API v1, which was deprecated starting in January 2022. Here's our planned timeline for the removal of API v1: + +- **January 7th, 2023 (Now):** Begin sending messages to existing API v1 users +- **January 7th, 2023 (Now):** Add a field to each API result telling people to switch +- **February 14th, 2023:** Begin doing flickers of 5-10 minutes of 410 GONE response codes +- **March 1st, 2023:** Begin sending a permanent 410 GONE response for any non-GET routes +- **March 1st, 2023:** Ramp up 410 GONE flickers to last 6-12 hours for GET routes +- **March 15th, 2023:** Replace all remaining GET routes with a permanent 410 GONE response + +### Small updates throughout the year + +As always, we will be interspersing other, smaller quality-of-life updates throughout the year even as we work on the big stuff. We also want to fix any bugs which might come up alongside any updates. + +## Conclusion + +Modrinth was founded with the goal of creating a platform which keeps the broader modding community's interests at heart. Modrinth would not exist without the support of our users and of our contributors, and we thank everyone involved immensely for everything. Modrinth's development shall continue as long as the community is willing to support us on the way! + +We would love to hear any feedback you might have. Feel free to get in contact on [Discord](https://discord.modrinth.com), on [Twitter](https://twitter.com/modrinth), and on [Mastodon](https://floss.social/@modrinth). diff --git a/packages/blog/articles/whats-modrinth.md b/packages/blog/articles/whats-modrinth.md new file mode 100644 index 000000000..1994c84c4 --- /dev/null +++ b/packages/blog/articles/whats-modrinth.md @@ -0,0 +1,62 @@ +--- +title: What is Modrinth? +summary: "Hello, we are Modrinth – an open source mods hosting platform. Sounds dry, doesn't it? So let me tell you our story – and I promise, it won't be boring!" +date: 2020-11-27 +authors: ['aNd6VJql'] +--- + +Hello, we are Modrinth – an open source mods hosting platform. Sounds dry, doesn't it? So let me tell you our story – and I promise, it won't be boring! + +## Prelude and conception + +Before Modrinth was even thought of, there already were several giant platforms for mod hosting. However, most of them were too specialized, outdated, or transitively dependent on an uncaring hegemonic 3rd party. Authors and players were always in struggle. The community had to choose 2 out of 3: inconvenience, indifference, obsolescence. Urge for better service, either new or renewed, just founded or acquired arose. + +Although demand for proper competition is the seed, the germ of Modrinth, the biggest role was played by the Fabric project. It set an example of a community-powered alternative. It was democratic, FOSS, listening to the community, and welcoming contribution and 3rd party initiatives. They have shown the modding community that they can evolve and adapt, be accessible and welcoming, cooperative, and caring. + +## Fabricate and HexFabric + +And, oh boy, did they connect – the demand for competition grew so high, that at some point the community just exploded with novelty. During several months, almost a dozen projects were aiming to be the second Walmart, the third IKEA, the fourth Amazon for your mods. Here beings the story of HexFabrics... – wait, what? What's that? + +> HexFabric is an umbrella term for modern mod hosting technology. It got its name from Fabric, which at the point was poorly supported (if at all) by the major players on the stage. In practice, HexFabric is just a cozy Discord server, on which several projects have their deputizing channels. + +Back on track – Lots of HexFabrics were founded almost simultaneously. Altar.gg, Astronave, Diluv, ModForest, Minerepo... and, most importantly, Fabricate. + +Fabricate began its journey as a proprietary project indexing website by a single developer – Geometrically. It remained relatively unnoticed for a couple of weeks, and then it started gaining attention. This new website has amazing search! Yup, the whole thing was primarily about making seamless, gracious, appeasing smart real-time search. The community is now intrigued. + +## Becoming a team + +"But this looks awful! And it's proprietary!" – a few voices said. Among those voices were falseresync and MulverineX. They both had several objections to that and were pestering the original author. "FOSS is the true way for community project" and "Just use a license to prevent others from creating instances of your work," they told. + +Yet Fabricate remained proprietary for a while. However, once the pressure on the author became high, they gave up and open-sourced their work. This was the birth of Modrinth. It did not get its name for a little while longer though. + +Now that Modrinth was open source, it started gaining traction. Remember falseresync and MulverineX? They joined Geometrically on the branding site, and somewhere in the middle of the brainstorming process the logo and the name were born. At the same time AppleTheGolden, Aeledfyr, and Redblueflame began contributing to the actual code of the project, which is – nowadays known to everyone – in Rust. A solo suddenly became a team, ready for whatever future holds. + +## Development non-stop + +The newly born FOSS project is now evolving swiftly. Before our team arose the question: monolithic vs split app architecture. Monolithic would be easier to deploy and can serve pages quicker. The split architecture will simplify the development and allow for a feature-full user experience. The discussion was hot, and the sides were fierce. Nevertheless, the split pattern won. Now it was time to make proper backend and frontend apps. + +The work first began with the backend. Aeled, Red, and Geo started detaching API methods from visuals. The team worked hard. Consequent to the API splitting from the GUI, it became getting new and exciting features. The first feature to be added was custom Modrinth mods – before that, the website only indexed the competitor's service. + +However, for that to happen there had to be another step taken – migration from MongoDB to PostgreSQL. It was crucial for efficient data storage and complex relationships between projects. And the biggest propagator of that change was Apple, who introduced and successfully defended their case. + +Thus, with custom mods, better yet search has been implemented. After search, user accounts with external log-in made their way into the project. Now it first creators started uploading their mods – a monumental achievement. + +After the first creators came more – the community began taking Modrinth as a serious alternative hosting. At some point, uploads accelerated to the point that our team was forced to redo their plans and establish project editing and moderation considerably earlier than it could have been. Besides, creators need analytics, they need teams, they needed support system. So the backend developers tried their best to keep up and achieved their goals through enthusiastic labor and dedication. + +## Refreshed look + +Although, on the frontend side things weren't as bright, unfortunately. Once falseresync presented the new look and feel Modrinth should aim for, he was forced to dedicate less time to the project. As a consequence, the frontend was implemented rather haphazardly and was lacking in features compared to the backend. + +However, this did not stop the project from evolving. The backend team has continued to expand on existing features, and after a long period of time, the savior descended on the frontend – Prospector, who rapidly became a crucial contributor and a part of the team. With new and comprehensive design guidance from falseresync and critique from MulverineX and the community, Prospector achieved feature parity with backend and greatly improved the website look and feel. + +Improving the frontend wasn't an easy job: naughty CSS, runtime errors, the abundance of framework-related nuances – all were obstacles, and all were defeated. Through battles with web technologies, jokes about quirky styles, and hard work our team created the UI you see today. + +## Going beta + +> You have to believe that the dots will somehow connect in your future. _– Steve Jobs_ + +With the story complete, we are proud to announce that the Modrinth beta will be coming out on November 30th, with a refreshed look and a feature-complete modding website! It is a tremendous achievement for us and the community, which we are very proud of. + +It is heart-warming to admit that we're finally going officially online. We know it's not perfect yet. But regardless, we will continue our passion project as a team, and we will expand on it and make it only better! + +Stay tuned! diff --git a/packages/blog/articles/windows-borderless-malware-disclosure.md b/packages/blog/articles/windows-borderless-malware-disclosure.md new file mode 100644 index 000000000..746522c07 --- /dev/null +++ b/packages/blog/articles/windows-borderless-malware-disclosure.md @@ -0,0 +1,84 @@ +--- +title: 'Malware Discovery Disclosure: "Windows Borderless" mod' +summary: Threat Analysis and Plan of Action +date: 2024-05-07T12:00:00-08:00 +authors: ['Dc7EYhxG', 'MpxzqsyW'] +--- + +This is a disclosure of a malicious mod discovered to be hosted on the Modrinth platform. It is important to not panic or jump to conclusions, please carefully read the [Am I Affected?](#am-i-affected) and [Threat Summary](#threat-summary) sections. + +## Am I Affected? + +If you run **Windows** and have downloaded a mod called "**Windows Borderless**" (specific files listed below) between May 4th, 2024 and May 6th, 2024 and have run the game with the mod installed, you are affected. + +**IMPORTANT NOTE:** This mod is called, exactly, "Windows Borderless". There are other mods with similar names on Modrinth, which are **_NOT_** malware, such as "Borderless Mining", "Borderless", and "Borderless Mining Reworked". + +If you have not downloaded that mod or do not run Windows, there is no reason to believe you are at any risk. We have released a detection tool [available here](https://github.com/modrinth/oracle/releases/download/v0.0.1/ModrinthMalwareScanner.exe) which can scan your mods folder for the malicious files if you wish to make sure your instance does not have the mod. The tool is also [open-source](https://github.com/modrinth/oracle). + +**Download and run the detection tool [here](https://github.com/modrinth/oracle/releases/download/v0.0.1/ModrinthMalwareScanner.exe)!** + +### What do I do if I have used the "Windows Borderless" mod? + +First, delete the mod entirely from your computer. + +The mod harvested data stored by many Chromium-based projects such as **Google Chrome**, **Discord**, **Microsoft Edge**, and many other browsers such as Opera/Opera GX, Vivaldi, Brave, Firefox and over dozen more. Included in this data may be **account tokens**, **stored passwords**, **banking information**, **addresses**, and more. + +In order to protect yourself, change all of your passwords, and keep an eye out on your bank accounts and credit cards. + +## Threat Summary + +**Exposure level:** Low, ~372 distinct IPs downloaded affected files. One Discord account is alleged to have been stolen due to this. + +**Malware severity:** Medium (Discord, browser, and system info stealer, but does not self-replicate) + +**Projects affected:** + +| Name | Project ID | Former URL | +| ------------------ | ---------- | -------------------------------------------- | +| Windows Borderless | `ZQpQzwWE` | `https://modrinth.com/mod/windowsborderless` | + +**Files affected:** + +| Name | SHA1 Hash | Version ID | Download count | +| ---------------------------------------- | ------------------------------------------ | ---------- | -------------- | +| `windowedborderless-v0.2 - 1.20.4.jar` | `179b5da318604f97616b5108f305e2a8e4609484` | `NkTbhEmf` | 116 | +| `windowedborderless-v0.3 - 1.20.4.jar` | `1a1c4dcae846866c58cc1abf71fb7f7aa4e7352a` | `v87dk8Q7` | 15 | +| `windowedborderless-v0.4 - 1.20.+.jar` | `e4d55310039b965fce6756da5286b481cfb09946` | `pVfdgPhy` | 68 | +| `windowedborderless-v0.4 - 1.20.+.jar` | `2f47e57a6bedc729359ffaf6f0149876008b5cc3` | `Wt4RjZ49` | 119 | +| `windowedborderless-v0.4.1_-_1.20.+.jar` | `2f47e57a6bedc729359ffaf6f0149876008b5cc3` | `oIlYelrb` | 1 | + +None of these files were included in any modpacks on the Modrinth platform, so you are only at risk if you downloaded the mod directly. + +## Timeline + +### April 29th, 2:39pm - Project submitted for review as a legitimate mod + +The Modrinth project "Windows Borderless" is submitted for review with a single file uploaded that does not contain any malware. + +### April 30th, 12:15am - Modrinth moderators approve the project + +The "Windows Borderless" project is approved with only one file, which contained no malware. + +### May 2nd, 3:50am - New version containing malware is published + +A "Windows Borderless" version containing the file windowedborderless-v0.2 - 1.20.4.jar (mentioned in the table of affected files above) is published. This initial version of the malware did not include any credential or token stealing, but only sent identifying information about a user’s machine to a discord webhook. + +### May 4th, 4:01pm thru May 6th, 3:46am - More versions are uploaded + +Between May 4th, 4:01pm and May 6th, 3:46am, more new versions of the mod containing the malware were uploaded. These versions all contain credential and token stealers. + +### May 6, 2024 @ 7:21am - A Modrinth user reports the project + +A user submits a report against the mod, alleging that their Discord account got compromised after using the mod. + +### May 6, 2024 @ 10:37am - Modrinth moderators investigate the project + +The mod is investigated by Modrinth staff. We decompiled the mod and discovered that the mod contained malicious code. The threat is immediately obvious, so within a few minutes we take down the project, all CDN links related to the project, and all other projects by the same users. + +## Conclusion + +In response to this incident, we are actively developing a shared system to effectively quarantine known malicious mods by creating a web API to allow launchers to check if any of the files a user has downloaded match any of the files in our known malware database, and return up-to-date information about any known malware. + +We are also in the process with working with relevant law enforcement agencies to pass along all information we have. + +In order to also more proactively increase safety, we're also investigating possible methods of sandboxing or algorithmically detecting malware patterns in Java software. While these are infamously tricky to implement on certain platforms, we hope to do our best in order to ensure the best security for the modding community. diff --git a/packages/blog/blog.config.ts b/packages/blog/blog.config.ts new file mode 100644 index 000000000..91858f0cc --- /dev/null +++ b/packages/blog/blog.config.ts @@ -0,0 +1,34 @@ +import * as path from 'path' +import { repoPath } from './utils' + +/** + * The glob pattern to find all markdown articles which should be compiled. + */ +export const ARTICLES_GLOB = repoPath('packages/blog/articles/**/*.md') + +/** + * The directory where compiled articles are stored. + */ +export const COMPILED_DIR = repoPath('packages/blog/compiled') +export const ROOT_FILE = path.join(COMPILED_DIR, 'index.ts') + +/** + * The source directory for public assets used in articles. + */ +export const PUBLIC_SRC = repoPath('packages/blog/public') + +/** + * An array of git-repository-root-relative paths where public assets should be copied to. + */ +export const PUBLIC_LOCATIONS = [repoPath('apps/frontend/src/public/news/article')] + +/** + * The git-repository-root-relative path to the frontend RSS feed file. + */ +export const RSS_PATH = repoPath('apps/frontend/src/public/news/feed/rss.xml') +export const JSON_PATH = repoPath('apps/frontend/src/public/news/feed/articles.json') + +/** + * The base URL of the Modrinth site, used for the RSS feed. + */ +export const SITE_URL = 'https://modrinth.com' diff --git a/packages/blog/check.ts b/packages/blog/check.ts new file mode 100644 index 000000000..b976e053a --- /dev/null +++ b/packages/blog/check.ts @@ -0,0 +1,85 @@ +import { promises as fs } from 'fs' +import * as path from 'path' +import fastGlob from 'fast-glob' +import { repoPath, toVarName } from './utils' + +import { PUBLIC_SRC, PUBLIC_LOCATIONS, ARTICLES_GLOB, COMPILED_DIR } from './blog.config' + +async function checkPublicAssets() { + const srcFiles = await fastGlob(['**/*'], { cwd: PUBLIC_SRC, dot: true }) + let allOk = true + for (const target of PUBLIC_LOCATIONS) { + for (const relativeFile of srcFiles) { + const shouldExist = path.join(target, relativeFile) + try { + await fs.access(shouldExist) + } catch { + console.error(`⚠️ Missing public asset: ${shouldExist}`) + allOk = false + } + } + if (allOk) { + console.log(`✅ All public assets exist in: ${target}`) + } + } + if (!allOk) process.exit(1) +} + +async function checkCompiledArticles() { + const mdFiles = await fastGlob([ARTICLES_GLOB]) + const compiledFiles = await fastGlob([`${COMPILED_DIR}/*.ts`]) + const compiledVarNames = compiledFiles.map((f) => path.basename(f, '.ts')) + + // Check all .md have compiled .ts and .content.ts and the proper public thumbnail + for (const file of mdFiles) { + const varName = toVarName(path.basename(file, '.md')) + const compiledPath = path.join(COMPILED_DIR, varName + '.ts') + const contentPath = path.join(COMPILED_DIR, varName + '.content.ts') + if (!compiledVarNames.includes(varName)) { + console.error(`⚠️ Missing compiled article for: ${file} (should be: ${compiledPath})`) + process.exit(1) + } + try { + await fs.access(compiledPath) + } catch { + console.error(`⚠️ Compiled article file not found: ${compiledPath}`) + process.exit(1) + } + try { + await fs.access(contentPath) + } catch { + console.error(`⚠️ Compiled article content file not found: ${contentPath}`) + process.exit(1) + } + } + + // Check compiled .ts still have corresponding .md + for (const compiled of compiledFiles) { + const varName = path.basename(compiled, '.ts') + if (varName === 'index' || varName.endsWith('.content')) continue + + const mdPathGlob = repoPath(`packages/blog/articles/**/${varName.replace(/_/g, '*')}.md`) + const found = await fastGlob([mdPathGlob]) + if (!found.length) { + console.error(`❌ Compiled article ${compiled} has no matching markdown source!`) + process.exit(1) + } + } + + console.log( + '🎉 All articles are correctly compiled, matched, and have thumbnails (if declared)!', + ) +} + +async function main() { + console.log('🔎 Checking public assets...') + await checkPublicAssets() + + console.log('🔎 Checking compiled articles...') + await checkCompiledArticles() +} + +main().catch((e) => { + console.error('❌ Error in check.ts:', e) + process.exit(1) +}) diff --git a/packages/blog/compile.ts b/packages/blog/compile.ts new file mode 100644 index 000000000..684d13b29 --- /dev/null +++ b/packages/blog/compile.ts @@ -0,0 +1,256 @@ +import { promises as fs } from 'fs' +import * as path from 'path' +import fg from 'fast-glob' +import matter from 'gray-matter' +import { md } from '@modrinth/utils' +import { minify } from 'html-minifier-terser' +import { copyDir, toVarName } from './utils' +import RSS from 'rss' +import { parseStringPromise } from 'xml2js' + +import { + ARTICLES_GLOB, + COMPILED_DIR, + ROOT_FILE, + PUBLIC_SRC, + PUBLIC_LOCATIONS, + RSS_PATH, + JSON_PATH, + SITE_URL, +} from './blog.config' + +async function ensureCompiledDir() { + await fs.mkdir(COMPILED_DIR, { recursive: true }) +} + +async function hasThumbnail(slug: string): Promise { + const thumbnailPath = path.join(PUBLIC_SRC, slug, 'thumbnail.webp') + try { + await fs.access(thumbnailPath) + return true + } catch { + return false + } +} + +function getArticleLink(slug: string): string { + return `${SITE_URL}/news/article/${slug}` +} + +function getThumbnailUrl(slug: string, hasThumb: boolean): string { + if (hasThumb) { + return `${SITE_URL}/news/article/${slug}/thumbnail.webp` + } else { + return `${SITE_URL}/news/default.webp` + } +} + +async function compileArticles() { + await ensureCompiledDir() + + const files = await fg([ARTICLES_GLOB]) + console.log(`🔎 Found ${files.length} markdown articles!`) + const articleExports: string[] = [] + const articlesArray: string[] = [] + const articlesForRss = [] + const articlesForJson = [] + + for (const file of files) { + const src = await fs.readFile(file, 'utf8') + const { content, data } = matter(src) + + const { title, summary, date, slug: frontSlug, authors: authorsData, ...rest } = data + if (!title || !summary || !date) { + console.error(`❌ Missing required frontmatter in ${file}. Required: title, summary, date`) + process.exit(1) + } + + const html = md().render(content) + const minifiedHtml = await minify(html, { + collapseWhitespace: true, + removeComments: true, + }) + + const authors = authorsData ? authorsData : [] + + const slug = frontSlug || path.basename(file, '.md') + const varName = toVarName(slug) + const exportFile = path.join(COMPILED_DIR, `${varName}.ts`) + const contentFile = path.join(COMPILED_DIR, `${varName}.content.ts`) + const thumbnailPresent = await hasThumbnail(slug) + + const contentTs = ` +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = \`${minifiedHtml}\`; +`.trimStart() + await fs.writeFile(contentFile, contentTs, 'utf8') + + const ts = ` +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(\`./${varName}.content\`).then(m => m.html), + title: ${JSON.stringify(title)}, + summary: ${JSON.stringify(summary)}, + date: ${JSON.stringify(date)}, + slug: ${JSON.stringify(slug)}, + authors: ${JSON.stringify(authors)}, + thumbnail: ${thumbnailPresent}, + ${Object.keys(rest) + .map((k) => `${k}: ${JSON.stringify(rest[k])},`) + .join('\n ')} +}; +`.trimStart() + + await fs.writeFile(exportFile, ts, 'utf8') + articleExports.push(`import { article as ${varName} } from "./${varName}";`) + articlesArray.push(varName) + + articlesForRss.push({ + title, + summary, + date, + slug, + html: minifiedHtml, + } as never) + + articlesForJson.push({ + title, + summary, + thumbnail: getThumbnailUrl(slug, thumbnailPresent), + date: new Date(date).toISOString(), + link: getArticleLink(slug), + } as never) + } + + console.log(`📂 Compiled ${files.length} articles.`) + + const rootExport = ` +// AUTO-GENERATED FILE - DO NOT EDIT +${articleExports.join('\n')} + +export const articles = [ + ${articlesArray.join(',\n ')} +]; +`.trimStart() + + await fs.writeFile(ROOT_FILE, rootExport, 'utf8') + console.log(`🌟 Done! Wrote root articles export.`) + + await generateRssFeed(articlesForRss) + await generateJsonFile(articlesForJson) +} + +async function generateRssFeed(articles): Promise { + const sorted = [...articles].sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + ) + + let currentRssArticles: { title: string; html: string }[] = [] + try { + const xml = await fs.readFile(RSS_PATH, 'utf8') + const parsed = await parseStringPromise(xml) + const items = parsed.rss?.channel?.[0]?.item || [] + currentRssArticles = items.map((item) => ({ + title: (item.title?.[0] ?? '').trim(), + html: (item['content:encoded']?.[0] ?? '').replace(/^$/g, '').trim(), + })) + } catch { + currentRssArticles = [] + } + + const newArr = sorted.map((a) => ({ + title: (a.title ?? '').trim(), + html: (a.html ?? '').trim(), + })) + + let isEqual = currentRssArticles.length === newArr.length + if (isEqual) { + for (let i = 0; i < newArr.length; ++i) { + if ( + currentRssArticles[i].title !== newArr[i].title || + currentRssArticles[i].html !== newArr[i].html + ) { + isEqual = false + break + } + } + } + + if (isEqual) { + console.log(`⏭️ RSS feed not regenerated (articles unchanged)`) + return + } + + const feed = new RSS({ + title: 'Modrinth News', + description: 'Keep up-to-date on the latest news from Modrinth.', + feed_url: `${SITE_URL}/news/feed/rss.xml`, + site_url: `${SITE_URL}/news/`, + language: 'en', + generator: '@modrinth/blog', + }) + + for (const article of sorted) { + feed.item({ + title: article.title, + description: article.summary, + url: `${SITE_URL}/news/article/${article.slug}/`, + guid: `${SITE_URL}/news/article/${article.slug}/`, + date: article.date, + custom_elements: [{ 'content:encoded': `` }], + }) + } + + await fs.mkdir(path.dirname(RSS_PATH), { recursive: true }) + await fs.writeFile(RSS_PATH, feed.xml({ indent: true }), 'utf8') + console.log(`📂 RSS feed written to ${RSS_PATH}`) +} + +async function generateJsonFile(articles): Promise { + const sorted = [...articles].sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + ) + const json = { articles: sorted } + await fs.mkdir(path.dirname(JSON_PATH), { recursive: true }) + await fs.writeFile(JSON_PATH, JSON.stringify(json, null, 2) + '\n', 'utf8') + console.log(`📝 Wrote JSON articles to ${JSON_PATH}`) +} + +async function deleteDirContents(dir: string) { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }) + await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await fs.rm(fullPath, { recursive: true, force: true }) + } else { + await fs.unlink(fullPath) + } + }), + ) + } catch (error) { + console.error(`❌ Error deleting contents of ${dir}:`, error) + throw error + } +} + +async function copyPublicAssets() { + console.log('🚚 Copying ./public to all PUBLIC_LOCATIONS...') + for (const loc of PUBLIC_LOCATIONS) { + await deleteDirContents(loc) + await copyDir(PUBLIC_SRC, loc) + console.log(`📂 Copied ./public to ${loc}`) + } + console.log('🎉 All public assets copied!') +} + +async function main() { + await compileArticles() + await copyPublicAssets() +} + +main().catch((e) => { + console.error('❌ Error in compile.ts:', e) + process.exit(1) +}) diff --git a/packages/blog/compiled/a_new_chapter_for_modrinth_servers.content.ts b/packages/blog/compiled/a_new_chapter_for_modrinth_servers.content.ts new file mode 100644 index 000000000..d6cb247c8 --- /dev/null +++ b/packages/blog/compiled/a_new_chapter_for_modrinth_servers.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Over the few months, Modrinth has seen incredible interest towards our Servers product, and with significant growth, our vision for what Modrinth Servers can be has evolved alongside it. To continue striving towards our goal of providing the best place to get your own Minecraft multiplayer server, we’ve made the decision to bring our server hosting fully in-house.

Why We're Making This Change

Modrinth has some ambitious goals for the next year. We want to create the best possible way for all Java players play Minecraft, and to host and play their favorite modpacks and custom servers. To achieve this, it’s clear that Modrinth Servers needs to be built and scaled on our own infrastructure.

By running every aspect of our hosting platform, we gain the flexibility to tailor the experience to our community’s needs—whether that means deeper integrations with Modrinth’s ecosystem, better performance, or more innovative features. This also allows us to invest in the long-term sustainability of Modrinth Servers, ensuring that we can scale seamlessly and avoid running out of available servers stock.

A Thank You to Pyro

This change is purely a logistical step forward and does not reflect negatively on our partnership with Pyro. In fact, Pyro has been an incredible partner in getting Modrinth Servers off the ground and we are very grateful for their collaboration. We completely support Pyro and their future, and we know they’re working on some exciting new products of their own, which we can’t wait to check out!

What This Means for You

We know you may have questions, and we want to make this transition as smooth as possible.

  • What part of my server was being run by Pyro?

    Until this point, Pyro has been responsible for the physical server machines that run your Modrinth servers. This means that they have been responsible for the hardware that powers your server, as well as the files and data for them. Moving forward, all of this will exist under Modrinth.

  • What happens to my running servers?

    Your current servers will continue running, and we’ll provide a clear migration path if any action is needed on your part. You can expect a follow up soon, however our goal is to do this with 0 downtime or impact to you if possible.

  • Will anything else change that impacts me?

    Modrinth Servers will remain the same great experience its has been, you likely won’t notice any changes right away. Long term, this means we’ll be able to improve both the stability of servers as well as the features that make managing your server a breeze.

This is an exciting step toward a future where Modrinth is the go-to destination for Java Minecraft players—not just for mods and mod-packs, but for hosting and playing too. We appreciate your support and can’t wait to share more soon!

` diff --git a/packages/blog/compiled/a_new_chapter_for_modrinth_servers.ts b/packages/blog/compiled/a_new_chapter_for_modrinth_servers.ts new file mode 100644 index 000000000..ea943dc1c --- /dev/null +++ b/packages/blog/compiled/a_new_chapter_for_modrinth_servers.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./a_new_chapter_for_modrinth_servers.content`).then((m) => m.html), + title: 'A New Chapter for Modrinth Servers', + summary: 'Modrinth Servers is now fully operated in-house by the Modrinth Team.', + date: '2025-03-13T00:00:00.000Z', + slug: 'a-new-chapter-for-modrinth-servers', + authors: ['MpxzqsyW', 'Dc7EYhxG'], + thumbnail: true, +} diff --git a/packages/blog/compiled/accelerating_development.content.ts b/packages/blog/compiled/accelerating_development.content.ts new file mode 100644 index 000000000..0f1e1ed22 --- /dev/null +++ b/packages/blog/compiled/accelerating_development.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Update: On April 4, 2024 we announced that we had returned the remaining $800k in investor capital back to our investors to take a different path. Read that announcement here. This article remains here for archival purposes.


There are over 3 billion gamers worldwide, but only a small fraction ever go further and mod the games they play. Modrinth is here to bring modding to every player on the planet—all the while allowing mod creators to make a living off of it.

Since our founding in 2020 and up until a few months ago, Modrinth has been a purely volunteer project. In the past couple months, the Modrinth team has been more productive than ever. We've released the Anniversary Update, we're actively working on the launcher once more, and we're laying out plans for how to multiply Modrinth creator payouts.

The vision we have for the future of Modrinth is great, and right alongside that is the need for an amazing team to build out this vision. That's why we recently announced that we're hiring—we've already come so far on just volunteer work, but for Modrinth to be sustainable and for its growth to be sustainable, we need to pick up the pace.

That's why we're excited to announce that we've raised a pre-seed round of funding led by Makers Fund, with investors including Ryan Johnson, Stephen Cole, Pim de Witte, Chris Lee, and Andreas Thorstensson to accelerate development and expand to new horizons for Modrinth.

What's next?

We're thrilled to keep on building and iterating on Modrinth over the next few years. Here's a look into what we have in store over the next few months for Modrinth:

  • A feature-packed launcher
  • Creator organizations (like GitHub), wikis, graphs (with playtime, views, downloads, etc)
  • More creator payouts, through the growth of Adrinth
  • Discovery/recommendation of mods (especially up-and-coming content)
  • Comments (with built-in moderation and spam protection)
  • [Redacted]

Support for new games!

We are excited that we are able to build a product that will manage to grow us to sustainability and create the best modding experience for creators and users. Being able to pay ourselves and bring on new people is a big step in making that happen. There is still a lot to do, so let's get to it!

Q&A:

We know there might be some concerns so we included a short Q&A section below for some common ones. Feel free to ask in our Discord if you have any more questions!

Why does Modrinth need funding?

Our main expense is and will continue to be salaries. The labor cost has always been the main bottleneck for Modrinth. Having paid employees will allow us to develop Modrinth faster, bringing Modrinth to a point of sustainability and growing the platform. For example, we're planning to release our launcher this year, and eventually we're hoping to expand into more games. Those won't be possible without having paid employees.

Is Modrinth still community-first?

We started and always will have the goal of creating a community-oriented, open-source modding platform. Simply put, there isn't any reason for us not to be. It's clear that the previous impersonal, corporate approaches to video game modding have not worked, and Modrinth is excited to change that.

Will Modrinth still be open-source?

Yes! We are committed to having all (when possible) our current code and future code we write to be open-source. Copyright is held by the contributors as we have no CLA, so we cannot make it closed-source (even if we wanted to) without, well, violating the law.

Who's behind Modrinth?

The Modrinth team (currently consisting of Prospector, Emma, and Geometrically) is behind Modrinth. We've been modding Minecraft for years, with connections extending back to grade school. Investors have a minority stake in the company, and have no control or say in our decisions.

Is Modrinth going to adopt web3/cryptocurrency?

No. We have no plans to adopt or explore web3 for Modrinth.

Will investment money be used to fund creator payouts?

Not directly. Hiring more people will allow us to build up the infrastructure that can increase payouts, but the money we pay out to creators will always come from sustainable sources such as advertising and never from investment funds.

` diff --git a/packages/blog/compiled/accelerating_development.ts b/packages/blog/compiled/accelerating_development.ts new file mode 100644 index 000000000..7f4926d3b --- /dev/null +++ b/packages/blog/compiled/accelerating_development.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./accelerating_development.content`).then((m) => m.html), + title: "Accelerating Modrinth's Development", + summary: 'Our fundraiser and the future of Modrinth!', + date: '2023-02-01T20:00:00.000Z', + slug: 'accelerating-development', + authors: ['MpxzqsyW', 'Dc7EYhxG', '6plzAzU4'], + thumbnail: false, +} diff --git a/packages/blog/compiled/becoming_sustainable.content.ts b/packages/blog/compiled/becoming_sustainable.content.ts new file mode 100644 index 000000000..7a7c4760d --- /dev/null +++ b/packages/blog/compiled/becoming_sustainable.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Just over 3 weeks ago, we launched our new ads powered by Aditude. These ads have allowed us to improve creator revenue drastically and become sustainable. Read on for more info!

Creator Revenue

We’re excited to share we have been able to increase creator revenue by 5-8x what it was before!

There’s a couple changes to how revenue is distributed out to creators coming with this increase.

First, revenue is no longer entirely paid out the day they are earned. Previously, we used our own in-house advertisement deal which paid us in advance for the entire month, and we divided that among each day in the month, as the month progressed. With the switch to a more traditional ad network, we are paid on a NET 60 basis, which is fairly standard with ad networks. What this means is that some of your revenue may be pending until the ad network pays us out. Exactly how this works is explained further here.

Second, the revenue split between Modrinth and Creators has changed. See the next section on sustainability for more on this.

Some creators have wondered if the new revenue is a bug because it’s gone up so much!

Becoming Sustainable

We have updated the Modrinth creator revenue split from 90/10 to 75/25. However, all of the increases listed above are with the new rate included, so while the percentage is lower, the overall revenue is much, much higher.

While 90% is a more remarkable figure, we changed it in order to ensure we can keep running Modrinth and continue to grow creator revenue without having to worry about losing money on operational costs.

Through these changes, we are proud to announce Modrinth is now fully sustainable with the new income, with all hosting and operational costs accounted for (including paying our developers, moderators, and support staff!) With the new revenue, users will see reduced support times and we will be able to ship bigger and better updates quicker to you all!

In an effort to be more transparent with our community than ever before, we are opening up as many of our finances as possible so you all can know how we’re doing and where all the money is going. We’re working to develop a transparency page on our website for you to view all the graphs and numbers, but it wasn’t ready in time for this blog post (for now, you can view our site-wide ad revenue in the API here. We also plan to publish monthly transparency reports with more details about our revenue and expenses, the first of which should be available in early October, so keep an eye out for that.

For now, we can tell you that creators on Modrinth have earned a total of $160,868 on Modrinth to date (as of September 13, 2024), and here’s a graph of our revenue from the past 30 days:

Modrinth Advertising Revenue (last 30 days)

We have a lot of exciting things coming up still, and of course, we greatly appreciate all of your support!

` diff --git a/packages/blog/compiled/becoming_sustainable.ts b/packages/blog/compiled/becoming_sustainable.ts new file mode 100644 index 000000000..6f5da748a --- /dev/null +++ b/packages/blog/compiled/becoming_sustainable.ts @@ -0,0 +1,12 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./becoming_sustainable.content`).then((m) => m.html), + title: 'Quintupling Creator Revenue and Becoming Sustainable', + summary: 'Announcing an update to our monetization program, creator split, and more!', + date: '2024-09-13T20:00:00.000Z', + slug: 'becoming-sustainable', + authors: ['MpxzqsyW', 'Dc7EYhxG'], + thumbnail: true, + short_title: 'Becoming Sustainable', + short_summary: 'Announcing 5x creator revenue and updates to the monetization program.', +} diff --git a/packages/blog/compiled/capital_return.content.ts b/packages/blog/compiled/capital_return.content.ts new file mode 100644 index 000000000..801927642 --- /dev/null +++ b/packages/blog/compiled/capital_return.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Over three years ago, I started Modrinth: a new Minecraft modding platform built on community principles, a fully open-source codebase, and a focus on creators.

What started as a hobby project quickly grew into something much bigger, with over twelve thousand creators and millions of players modding their game with Modrinth! Running Modrinth quickly evolved into a full-time job as we worked to scale the platform, develop new features, and fix bugs.

As our small project that originated in the Fabric Discord server started to get more and more serious, we decided to seek funding to accelerate growth and keep up with the competition. A year and a half ago, we raised a $1.2 million pre-seed round of investor capital. With the money, we hired full-time developers and part-time community members, some of whom have supported Modrinth since the very beginning. With all this support, we launched creator monetization, authentication, analytics, organizations, collections, the Modrinth App, and support for more project types, growing Modrinth’s user base fifteen-fold!

But, this rapid growth came at some costs. We let sustainable infrastructure for moderation slip to the back-burner since we could just hire extra moderators to compensate, and more and more of my time as the founder was taken up by things that didn’t make Modrinth better. Bugs and technical debt also gradually infected our codebase as we focused on hyper-growth over maintenance.

Alongside this, as we looked more into the future, we saw that the venture-backed cycle wouldn’t be the right path for Modrinth. Every investor invests in a company with the expectation of a return on their investment, and while all of our backers have been incredibly supportive, we wanted to be able to work on Modrinth at our own pace and terms. We’ve seen other companies in this space prioritize profits and growth at the expense of the community and creators, and we didn’t want this to happen to Modrinth.

In short, forgoing the venture route would allow us to build Modrinth independently at a sustainable pace and put our creators, community, open-source nature, and values first, without having to worry about expectations of profit or growth.

In the end, as of February 1st, 2024, I decided to return $800k in remaining investor capital back to our investors.

This decision was not an easy one, as without this funding, we would be unable to support the Modrinth team as it previously existed. With this reality, I made the difficult decision to significantly reduce the size of our team to match our goals of sustainable growth.

I also owe a huge debt of gratitude to everyone on the team affected by all of this–Emma, Wyatt, Maya, Coolbot, Jade, Carter, and Prospector–for everything they have done to help make Modrinth what it is today.

I want to take a moment to highlight each of their contributions:

  • Emma was our lead moderator, social media manager, overall marketing lead, blog post writer, documentation maintainer, Minotaur maintainer, and support manager since joining the team in April 2021
  • Wyatt was a full-time backend developer that worked on our authentication system, analytics, collections, organizations, and tons of work on API v3, and more, since joining the team in February 2023
  • Maya was our first exclusive moderator hire, and despite a rough onboarding due to a lack of internal documentation and procedures on our side, had reviewed thousands of projects since joining the team in April 2023
  • Coolbot was another one of our moderators who especially helped us establish new procedures and improved internal documentation for moderators and had also reviewed thousands of projects since they joined the team in August 2023
  • Jade was also a moderator and had reviewed thousands of projects since joining the team in August 2023
  • Carter was a full-time frontend developer that worked on OAuth, analytics, collections, organizations, and more, since joining the team in October 2023
  • Prospector is our frontend developer and lead designer, who has been with us since September 2020 and has spearheaded multiple site redesigns, developed the frontend for core parts of the site, and more

This transition was challenging, causing significant delays in project reviews and support ticket resolution, not to mention the stress for the former team. While project review and support times have returned to normal, this was not the experience we wanted for our creators or users to have. I sincerely apologize that you all had to experience this transition, and I wish that it had been executed more smoothly.

I would also like to apologize for how long this post has taken to come out. It took longer than I expected to do all the legal work and coordination necessary to return the remaining money to the investors, but it has finally been finished.

Going forward, we will be continuing to build a platform that is sustainable for both the creators and all the people who work on making the platform what it is. Hosting Modrinth is already sustainable, and we are working to make developing Modrinth sustainable as well.

We’ve made great strides in this already with new moderation infrastructure including AutoMod and a built-in moderator checklist, greatly reducing moderator time per project. We’re also focused on increased transparency, through providing consistent updates on Modrinth’s development and making it easier to contribute to Modrinth with better documentation and contribution cycle.

We started Modrinth to serve the community, and are taking this path so we can continue to. We hope you all will continue to support us as the newly independent Modrinth.

Jai (aka Geometrically)
Founder of Modrinth

` diff --git a/packages/blog/compiled/capital_return.ts b/packages/blog/compiled/capital_return.ts new file mode 100644 index 000000000..b67f3e4fb --- /dev/null +++ b/packages/blog/compiled/capital_return.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./capital_return.content`).then((m) => m.html), + title: 'A Sustainable Path Forward for Modrinth', + summary: 'Our capital return and what’s next.', + date: '2024-04-04T20:00:00.000Z', + slug: 'capital-return', + authors: ['MpxzqsyW'], + thumbnail: false, +} diff --git a/packages/blog/compiled/carbon_ads.content.ts b/packages/blog/compiled/carbon_ads.content.ts new file mode 100644 index 000000000..0e2fd8a74 --- /dev/null +++ b/packages/blog/compiled/carbon_ads.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Update 10/24: A month and a half ago we began testing Carbon Ads on Modrinth, and in the end, using Carbon did not work out. After disabling ads with tracking in them, the revenue was about equal to or worse than what we were generating previously with EthicalAds. Effective today, we are switching our ads provider back to EthicalAds for the time being.

As a step towards implementing author payouts, we're experimenting with a couple different ad providers to see which one works the best for us. We've been using EthicalAds for a long time now, but we'd like to now try out CarbonAds.

In most respects, this is a temporary experiment, but we're hoping that Carbon will become our primary ad provider in the near future.

Over the past week and a half, we've garnered a lot of useful feedback in the #devlog channel of our Discord. Over those 1,300 or so messages of open discussion and debate, there were also a lot of questions, concerns, and misconceptions. This blog post aims to address the most frequent of those.

FAQ

Is Carbon GDPR and CCPA compliant?

Yes. This was confirmed to us via email by a Carbon representative.

Are the ads intrusive?

No. They fall under the Acceptable Ads Standard; that is, there is only ever one per page, they are less than 120 pixels tall, and they are separate and distinguishable from actual site content.

Where did the privacy settings go?

Alongside the introduction of Carbon, we have removed the privacy settings that we previously had. These privacy settings controlled whether PII would be sent in our internal analytics and whether you wanted personalized ads to show up. Our analytics do not contain PII and Modrinth does not show personalized ads. Both of those would be intense breaches of your privacy, opt-in or not, and Modrinth intends to respect your privacy.

Why are you switching before you've released payouts?

We have been using ariadne to take note of page views and ad revenue since August 1st, 2022. While creator payouts cannot yet be claimed, all ad revenue from this date forward will be claimable once payouts are released!

Payouts are not yet done, but this switch is one of the largest things that needs to be done prior to its release.

Why does Modrinth need to switch away from Ethical?

There are quite a number of reasons why it's not feasible for us to continue using Ethical. In order to be fully transparent, let's go into detail about each of them.

In-house ads

Over half of the ads shown by Ethical are their so-called "in-house ads". That is, Ethical does not have enough inventory to always be showing an ad, so instead it shows an advertisement for itself. These self-advertisements make a whopping $0 for Modrinth.

Ethical does provide an option to replace these self-advertisements with our own fallback ads, which we've done for the past month or so. However, negotiating those sorts of deals takes an excruciating amount of time, time that we would rather be spending on developing Modrinth to make it better.

Carbon allows us to have a more hands-off approach with advertising, which is most ideal for us right now.

Poor CPM

Ethical gives us an average of $0.24 for every thousand page views (also known as CPM) after taking into account the aforementioned in-house ads. Anyone who knows anything about the advertising business knows that this figure is abysmally low. With Modrinth getting over four million page views in a month's timespan, we make an average of less than $1000 per month with Ethical. This simply isn't sustainable for the thousands of creators on Modrinth.

While we can't quite be sure what our CPM with Carbon will be -- again, this is only a temporary experiment for now -- we have reason to believe that it will be considerably greater than what Ethical can provide.

Network in decline

Over the time that Modrinth has used Ethical, we have found that the diversity of the advertisers shown has declined at a rate greater than is sustainable. The vast majority of the ads shown by Ethical, excluding its in-house ads, are for DigitalOcean. If DigitalOcean decided to withdraw from Ethical, that would end up toppling our entire system. Modrinth's payouts simply cannot rest on this house of cards if we wish to grow in any capacity.

Can I still use my adblocker?

You are still able to access Modrinth using an adblocker, and Modrinth will not force you to disable it to access the site. However, Modrinth's ads are unintrusive and take up no more space than it would otherwise.

When you turn off your adblocker for Modrinth, you are supporting both Modrinth and its creators in the process. 100% of the ad revenue from creator pages, including projects, versions, and users, go directly to creators. The ad revenue from other pages, including search, pay for Modrinth's upkeep costs and allow us to continue to exist.

For the benefit of everyone involved, we humbly request that you turn off your adblocker for Modrinth. We have a full guide for how to turn off your adblocker located on our docs site.

Conclusion

In conclusion, we hope you're as excited about our upcoming release of payouts as we are. Exploring our options for ad providers is quintessential if we wish to be sustainable for payouts, and the best time to do this is now. As always, though, no release ETAs!

Please note that this blog post was not editorialized or reviewed by Carbon prior to publishing. These are the findings and words of Modrinth and Modrinth alone. What's said here about CPMs and other statistics will not be true of other sites, but they are true for Modrinth.

` diff --git a/packages/blog/compiled/carbon_ads.ts b/packages/blog/compiled/carbon_ads.ts new file mode 100644 index 000000000..6f30ddc2e --- /dev/null +++ b/packages/blog/compiled/carbon_ads.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./carbon_ads.content`).then((m) => m.html), + title: "Modrinth's Carbon Ads experiment", + summary: 'Experimenting with a different ad providers to find one which one works for us.', + date: '2022-09-08T00:00:00.000Z', + slug: 'carbon-ads', + authors: ['6plzAzU4'], + thumbnail: true, +} diff --git a/packages/blog/compiled/creator_monetization.content.ts b/packages/blog/compiled/creator_monetization.content.ts new file mode 100644 index 000000000..5e48f5f88 --- /dev/null +++ b/packages/blog/compiled/creator_monetization.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Yes, you read the title correctly: Modrinth's Creator Monetization Program, also known as payouts, is now in an open beta phase. All of the money that project owners have earned since August 1st is available to claim right now!

This includes even projects other than mods! Modpacks, plugins, and resource packs also generate payouts for creators to claim.

Alongside this, the frontend also got a facelift across the entire site, most notably on the settings, notifications, and user profile pages.

Motivation

Since the start, Modrinth has been a platform created by Minecraft content creators for Minecraft content creators. Allowing creators to earn a bit of money for their hard work and dedication to their content has been a goal since we started, and we are so incredibly ecstatic to finally be able to release this for everyone.

Whether it's used for buying coffee, paying for server costs, or to get that luxury pair of socks, we hope that creators will be able to use their payouts on whatever keeps them running. We want to encourage creators to keep making content for everyone to enjoy, with the hope that everyone will eventually be able to call Modrinth their go-to destination for Minecraft modded content.

How it works

For every project uploaded to Modrinth, we keep track of its page views and downloads through an internal system we call ariadne. Through our payouts algorithm (source code), we distribute 100% of ad revenue earned from creator pages to the creators behind these projects. Project owners can decide how to split it (or how not to split it) between their team members.

Modpacks are a bit different, with revenue split 80% to the Modrinth dependencies on the pack and 20% to the modpack author. This split is subject to change and will be evaluated periodically to ensure the split is reasonably fair.

After taking the search pages into account, around 10% of the site's ad revenue ends up going to us, mainly to cover hosting and personnel expenses, and 90% to creators.

While payouts will be small at first, we're working on improving our ads system to better fund the program. We've also got big projects coming soon to continue our trajectory of making the monetization program and the site better!

How do I earn money?

When a project of yours on Modrinth gets approved, you are automatically enrolled into the program. You will start to incur a balance, which you can view from the Monetization dashboard. You can claim your first payout via PayPal or Venmo as soon as you enter your credentials and have the minimum balance of 0.26 USD.

Even though the minimum is low, you will want to wait some time to allow your balance to build up before claiming. Each payment processor has its own fees which depend upon whether you're within the United States, which are detailed on the dashboard's revenue tab.

Once you request a transfer, you may have to confirm the transfer via email if you don't already have a PayPal account. If you do not confirm using the link in the email within 30 days, or the transfer fails for whatever reason, the amount requested will be returned to your Modrinth balance, though the processor's fees may already have been deducted by that point.

For residents outside the United States

Since Modrinth is a US-based company, all amounts are stored, displayed, and paid out in US dollars. PayPal will convert the amount to your local currency once you begin the process of transferring from your Modrinth balance to your PayPal account.

We're aware of some extenuating circumstances for creators living in areas affected by geopolitical conflict. As such, we are looking into alternative ways to allow payouts to continue in these regions.

At the moment, there are no mechanisms in place to make your Modrinth balance expire after some time, though this is likely to be added in the future for creators who do not claim their balance after several years. Rest assured, we will have processes in place to make sure that your money doesn't go poof just because you weren't able to claim it in time.

Frontend facelift

The website frontend has had some "small" changes of around 12,322 lines of code to accommodate payouts and many other changes. Many of these changes were inspired by the experiments done on the SvelteKit Rewrite, progress on which is paused for the time being. Navigate around the main site for a bit to discover some of these changes! Highlights include:

  • Improved project creation and report filing workflow via modals
  • Improved 404 page
  • Deduplicate identical version changelogs
  • Cleaner user profile pages
  • Easier to navigate settings and notifications
  • Spacing, font, and accessibility tweaks
  • And plenty more!

Conclusion

This is a jam-packed update, and it would be impossible to list all the changes in this post. Feel free to explore the site, claim your funds, and give us feedback on Discord. If you suspect you've found any critical bugs or exploits, please email us immediately at support@modrinth.com - otherwise, for non-critical bugs, report them on GitHub.

👑

` diff --git a/packages/blog/compiled/creator_monetization.ts b/packages/blog/compiled/creator_monetization.ts new file mode 100644 index 000000000..2db2b74d1 --- /dev/null +++ b/packages/blog/compiled/creator_monetization.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./creator_monetization.content`).then((m) => m.html), + title: 'Creators can now make money on Modrinth!', + summary: + 'Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.', + date: '2022-11-12T00:00:00.000Z', + slug: 'creator-monetization', + authors: ['6plzAzU4'], + thumbnail: true, +} diff --git a/packages/blog/compiled/creator_update.content.ts b/packages/blog/compiled/creator_update.content.ts new file mode 100644 index 000000000..424d28762 --- /dev/null +++ b/packages/blog/compiled/creator_update.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

December may be over, but that doesn’t mean we’re done giving gifts here at Modrinth. Over the past few months, we’ve been cooking up a whole bunch of new features for everyone to enjoy. Now seems like as good of a time as ever to bring you our Creator Update! Buckle up, because this is a big one.

The headlining features include:

  • Analytics - Allowing Modrinth creators to see statistics from their projects.
  • Organizations - Better tools to manage shared ownership over multiple projects.
  • Collections - A system for putting together shared sets of projects, similar to Spotify playlists.
  • New payouts system - Updates to the existing Creator Monetization Program to better serve creators around the world.
  • New Markdown editor - Explore a complete reworking of our text editor, making it easy even for those unfamiliar with Markdown.
  • OAuth integrations - Our own implementation of the OAuth specification, allowing external applications to “log in with Modrinth”.

Analytics

The long-awaited addition of analytics is here for creators! You can view analytics over time for your projects, including downloads, page views, and revenue, all in an effortlessly easy-to-use dashboard.

The analytics for a project, showing downloads, page views, and revenue, with a breakdown by country.

A screenshot of the analytics for a user, showing multiple different projects.

The data for analytics have been collected over the course of many months. In fact, the data for revenue goes all the way back to August 2022, and the data for downloads and views back to February 2023.

You can view the analytics for an individual project by going to the settings and clicking “Analytics”. You can view analytics for all of your projects in the analytics dashboard.

Organizations

Isn’t managing permissions across a bunch of different projects pretty tedious? We sure thought so. Just like on GitHub, you can now create organizations on Modrinth to manage permissions across multiple projects.

A screenshot of the organizations section of the Modrinth dashboard.

You can create organizations from the organizations dashboard. Each organization has a name, a brief summary, and an icon. Just like project members, organization members have a role, a monetization weight, and project permissions, plus permissions for the organization as a whole. Roles, monetization weights, and project permissions can be overridden on a per-project basis.

A screenshot of a user page, with two organizations shown at the very bottom.

Unlike GitHub, usernames and organization names on Modrinth do not conflict with one another. If you want to have an organization named after yourself, feel free to do so!

Collections

Just like how Spotify has playlists or how Goodreads has shelves, Modrinth now has collections! Collections are lists of Modrinth projects put together for a common purpose. You can then share these collections with others to view.

A screenshot of the Project Odyssey suite of mods as a collection.

Your followed projects now make up an automatically generated private collection, which you can access from the “Your collections” section of the dashboard.

Wait… aren’t those just modpacks?

Not quite! Modpacks are much more complex than collections. Collections are simply lists of projects. Here’s a quick comparison:

ModpacksCollections
Created through a launcher, such as the Modrinth App.Created on the Modrinth website.
Contains options files, configuration files, and optionally files from outside of Modrinth, wrapped together in a .mrpack file.Contains a list of Modrinth projects (mods, plugins, data packs, resource packs, shaders, and modpacks).
Has individual releases with version history.Instantly updates whenever a project is added or removed.
Must be reviewed by Modrinth’s staff and approved per Modrinth’s rules before it can be published.Does not need to be reviewed by Modrinth’s staff. Can go public at any time.
After approval, can be listed in search, archived, unlisted, or private.Can be public (shows up on your Modrinth profile), unlisted (only accessible by direct URL), or private (only you can access it).

All in all, collections are handy for easily grouping together and sharing Modrinth projects. If you’re bored on the subway heading home, you can look for new mods on your phone and quickly add them to a Modrinth collection. However, for many use cases, spending the time to create a modpack might make more sense. Collections and modpacks are both here to stay—one is not going to replace the other.

New payouts system

PayPal and Venmo are so 2023. To enter 2024, we are adding support for a bunch of different new payout methods, including ACH (available for direct transfer to a United States bank account) and a couple thousand gift cards. You know, just “a few”.

The withdrawal screen, with PayPal, Venmo, ACH, Visa, and a preview of two of the available options for the United States (AMC and Airbnb)

Whether you want Applebee’s in America, Boek & Bladkado in Belgium, or Cineplex in Canada, we’ve got them all and plenty more. Prepaid Visa cards, Amazon gift cards, and Steam gift cards are among the available options. Does anyone want a Home Depot gift card? We’ve got those, too.

New Markdown editor

For the longest time, Modrinth’s text editor for descriptions, changelogs, reports, and more has just been a box to enter Markdown syntax. What about people who don’t know Markdown, though? Even for those who do, writing it out by hand gets tedious after a while. That’s why we rebuilt it from the ground up to make it far easier to use.

Among its features are standard shortcuts (like Ctrl+B for bold), a monospace font in the editor itself, and buttons for inserting headers, basic formatting, lists, spoilers, block quotes, links, images, and YouTube videos.

Using the image button, you can also now upload images directly, instead of having to use an external host or the Gallery tab of a project. You can still insert images from outside sources, though certain sources (such as the Discord CDN) are blocked. We will notify authors using these blocked sources to replace the images.

OAuth integrations

Wouldn’t it be nice if other websites or apps could add a “Sign in with Modrinth” feature? We asked ourselves this and thought, yes, it would be nice to add. So we added it.

The OAuth2 protocol allows other services to gain a limited amount of access to your Modrinth account without compromising your login information. Maybe you want to create your own analytics dashboard? Or maybe you want to make your own way to add content to collections? How about connecting organization permissions to roles in a Discord server? The possibilities are endless.

A screenshot of an OAuth app requesting permission to your user profile.

You can create a new OAuth application in the Applications section of your settings. You can see which applications you’ve granted access to in the Authorizations section.

Conclusion

Want to hear more from us on a regular basis? Check us out on our social media pages; we post often on both Mastodon and X/Twitter. You can also chat with us on Discord if you like that.

Thanks to intergrav for making the banner image.

` diff --git a/packages/blog/compiled/creator_update.ts b/packages/blog/compiled/creator_update.ts new file mode 100644 index 000000000..534cd4921 --- /dev/null +++ b/packages/blog/compiled/creator_update.ts @@ -0,0 +1,12 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./creator_update.content`).then((m) => m.html), + title: 'Creator Update: Analytics, Organizations, Collections, and more', + summary: 'December may be over, but we’re not done giving gifts.', + date: '2024-01-06T20:00:00.000Z', + slug: 'creator-update', + authors: ['6plzAzU4'], + thumbnail: true, + short_title: 'The Creator Update', + short_summary: 'Adding analytics, orgs, collections, and more!', +} diff --git a/packages/blog/compiled/creator_updates_july_2025.content.ts b/packages/blog/compiled/creator_updates_july_2025.content.ts new file mode 100644 index 000000000..9d682f2b5 --- /dev/null +++ b/packages/blog/compiled/creator_updates_july_2025.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Hey all,

The last few months have been quite hectic for Modrinth. We've experienced all-time highs in both traffic and new creators and have outgrown a lot of our existing systems, which has led to a lot of issues plaguing creators, especially.

The team has been super hard at work at this, and I'm really glad to announce that we've fixed most of these issues long term.

  1. Upload issues (inputs not showing up, instability, etc)

    We've tracked these issues down to conflicting code between our ad provider and Modrinth's. For now, we've disabled ads for all logged in users across the site while we work on resolving these long term. Both web users and logged-in web users make a very small percentage of our ad revenue (7% for web and 0.05% for logged-in web users) so creators should see a very minimal revenue drop from this, and have a much better experience navigating and uploading to the site.

  2. Moderation and report response times

    Creators have had to wait, in some cases, weeks to get their projects reviewed. This is unacceptable on our part and we are actively overhauling our moderation tooling to improve the moderation experience (and lowering time spent per project). We've also hired 3 additional moderators/support staff (bringing our total to 7 and the total team to 17 people!). We're hoping to see a significant reduction in queue times over the coming weeks.

  3. Ad revenue instability

    While ad revenue is generally out of our control and tends to fluctuate a lot, on June 4th we noticed a sharp decrease in creator revenue (~35% less than normal levels). While our ad provider initially thought this was a display issue, after further inquiry there were 2 causes: 1) Google AdExchange falsely flagging our traffic as invalid 2) Amazon banning many gaming publishers from their network due to panic in the gaming ads space. While the Amazon ban is now resolved, we no longer are running Google AdExchange in the desktop app due to invalid traffic issues. This will lead to a permanent revenue decrease (AdX contributed to ~20% of our ad revenue). We also updated our prebid version (the underlying tech used to run ad auctions) which has shown a measurable increase, bringing revenue back to "normal" levels. Overall, we are closely monitoring and will keep you all posted. However, despite all the issues, due to some end-of-quarter campaigns, revenue in June was an all time high, at $227k ($170k paid to creators)!

  4. Payout outages

    Creators should be able to withdraw their revenue at all times, but due to slow PayPal clearing times and poor planning by us, we've had multiple week long outages in withdrawals. While we do store funds 1:1, these "outages" happen because we primarily store creator funds in an FDIC-insured bank account, as we wouldn't want a PayPal/Tremendous account suspension to cause creators to lose funds. We've now set up internal reporting which should never cause this to happen again (or, if it does, drastically reduce the time payout outages happen)

  5. Platform Revenue Route

    Due to some unannounced breaking changes in Aditude's API, the platform revenue API was broken. It is now working. You can also use start and end fields to filter any date range!

  6. API and Uptime

    We've migrated our infrastructure for the website, app, and servers to OVH over our existing non-redundant AWS system. We've hit 99.96% uptime on our API and 99.98% on Modrinth Servers!

Thank you all for your patience! If you are having any more issues or have any questions about all of this, feel free to DM @geometrically on Discord or start a support chat and we will be happy to help!

` diff --git a/packages/blog/compiled/creator_updates_july_2025.ts b/packages/blog/compiled/creator_updates_july_2025.ts new file mode 100644 index 000000000..4e0fd6cef --- /dev/null +++ b/packages/blog/compiled/creator_updates_july_2025.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./creator_updates_july_2025.content`).then((m) => m.html), + title: 'Creator Updates, July 2025', + summary: 'Addressing recent growth and growing pains that have been affecting creators.', + date: '2025-07-02T04:20:00.000Z', + slug: 'creator-updates-july-2025', + authors: ['MpxzqsyW'], + thumbnail: false, +} diff --git a/packages/blog/compiled/design_refresh.content.ts b/packages/blog/compiled/design_refresh.content.ts new file mode 100644 index 000000000..c63e4c231 --- /dev/null +++ b/packages/blog/compiled/design_refresh.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

We’ve got a big launch with tons of new stuff today and some important updates about Modrinth. Read on, because we have a lot to cover!

Modrinth+

First off, we’re launching Modrinth+, a monthly subscription to help support Modrinth and all of the creators on it directly!

As a Modrinth+ subscriber, you will get:

  • Ad-free browsing on the Modrinth App and website
  • An exclusive badge on your profile
  • Half of your subscription will go to creators on the site!
  • …and more coming soon!

Pricing starts at $5/month, with discounts depending on what region you live in and if you opt for an annual plan.

We created Modrinth+ so people could help support Modrinth and creators on the site. We have no plans to paywall any content on Modrinth, and creator features will never cost money. We started Modrinth as a free and open-source platform, and we intend to keep it that way.

If you do have a few extra dollars a month and want to help support Modrinth, this is a great way to do it!

New Site Design: Stage One

We’re launching Stage One of Modrinth’s refreshed look to Modrinth.com today as well. I want to stress that it’s not fully complete and we’re going to be continuing to refine and finish updating the rest of the pages over the coming weeks. However, it has enough significant usability improvements and new features that we’re launching it broadly now. Please bear with us while we work to complete it promptly!

A screenshot of the new project page

Key new features include:

  • New download interface to ensure users get the correct version for the Minecraft version and mod loader they’re using
  • New versions list page built from the ground up with a clean new look and tons of shortcuts to make navigation easier
  • New “compatibility” widget on project pages to see what game versions, platforms, and environments each mod supports at a glance
  • Exclusion filters in search pages
  • Improved support for vertical desktop displays

We know there will be some minor hiccups and disruptions of workflows, but we’d really appreciate it if you could gently let us know how a particular change has affected you on GitHub here (or upvote/comment on an existing issue) rather than declaring it’s the end of the world.

New Advertising

In the last few months, Modrinth has grown an incredible amount. We are now serving over a petabyte of data per month (that is, 1,000 terabytes!) to over 20 million unique IP addresses. It’s almost unfathomable how large we have become since we started from nothing just four years ago.

However, with growth like this, our costs have also grown drastically—primarily in bandwidth. This, unfortunately, means that we’ve grown well beyond what a single advertiser could support.

Our original plan was to build out our own ad network (Adrinth) where we could cut out the middleman and provide highly targeted ads without the need for tracking to our gaming-specific audience. Unfortunately, we’ve grown too quickly (a very good problem to have!) and don’t have the immediate resources to do this at this time.

This leaves us with no choice but to switch to a more traditional programmatic ads setup powered by Aditude for the time being. We're not making this decision lightly, and we understand that some folks will not be happy about this change. Rest assured, we've made sure that our new ad network partner meets our requirements, such as compliance with all local regulations such as GDPR and CCPA, and that the new ads remain as unobstructive as possible with this format.

These changes bring Modrinth back to sustainability as well as conservatively increasing creator revenue by three-fold! Along with paying hosting bills, the new income will also be used for more support staff and paid team members, decreasing ticket time and speeding up our development.

We also want to thank our friends over at BisectHosting for supporting us with our ad deal for the past year.

Modrinth App 0.8.1

Over the last few months, we’ve been overhauling the internals of the Modrinth App to drastically improve performance and stability. Over one hundred issues have been closed with this update alone! Here’s a short list of the major changes:

  • Newer versions of Forge and NeoForge now work!
  • Migrated internal launcher data to use SQLite. The app now loads in <40ms on average (compared to ~2.5s before)!
  • Fixed issues where profiles could disappear in the UI
  • Fixed random cases of the UI freezing up during actions
  • Fixed directory changes being very inconsistent
  • Drastically improved offline mode
  • Fix freezing and include crash reports logs tab
  • And over one hundred more fixes!

Don’t have the Modrinth App? Check it out here!

Conclusion

Want to hear more from us on a regular basis? Check us out on our social media pages; we post often on both Mastodon and X/Twitter. You can also chat with us on Discord if you like that.

Thanks to intergrav for making the banner image.

` diff --git a/packages/blog/compiled/design_refresh.ts b/packages/blog/compiled/design_refresh.ts new file mode 100644 index 000000000..cbeeb15cd --- /dev/null +++ b/packages/blog/compiled/design_refresh.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./design_refresh.content`).then((m) => m.html), + title: 'Introducing Modrinth+, a refreshed site look, and a new advertising system!', + summary: 'Learn about this major update to Modrinth.', + date: '2024-08-21T20:00:00.000Z', + slug: 'design-refresh', + authors: ['MpxzqsyW', 'Dc7EYhxG'], + thumbnail: true, + short_title: 'Modrinth+ and New Ads', + short_summary: + 'Introducing a new ad system, a subscription to remove ads, and a redesign of the website!', +} diff --git a/packages/blog/compiled/download_adjustment.content.ts b/packages/blog/compiled/download_adjustment.content.ts new file mode 100644 index 000000000..d77095cf9 --- /dev/null +++ b/packages/blog/compiled/download_adjustment.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

While working on the upcoming analytics update for Modrinth, our team found an issue leading to higher download counts from specific countries. This was caused by an oversight with regards to rate limiting, or in other words, certain files being downloaded over and over again. Importantly, this did not affect creator payouts; only our analytics. Approximately 15.4% of Modrinth downloads were found to be over-counted. These duplicates have been identified and are being removed from project download counts and analytics. Read on to learn about the cause of this error and how we fixed it.

A graph of many Modrinth projects and their download counts, showing a disproportionate amount of downloads from China.

Notice anything out of the ordinary?

More specifically, the issue we encountered is that the download counts from China were through the roof compared to the page view statistics.

A graph of many Modrinth projects and their page views, showing a relatively even distribution across countries.

Upon further investigation, there was one specific launcher that was repeatedly downloading the same files from Modrinth over and over again within a very short time span.

A table of downloads split into several parts.

Notice how the downloads in each section (delineated by the bold line) have the same path and were created within the same second.

This, to say the least, baffled us. We already had code called Sisyphus in place to limit the number of downloads that a single source can make over a given span of time. So what gives?

As it turns out, the issue lay in the underlying technology used by Sisyphus. It uses Cloudflare Workers in order to intercept the request each time that a file is requested to be downloaded. Essentially, it acted like so:

  1. A source (whether this be a launcher, someone clicking the download button on the website, etc.) would request a file from Modrinth.
  2. Sisyphus would take note of this source’s information, including what it requested, its IP address, and its request headers, and write it to a small database. If this source had not requested this path before, it would add one download to this file. If it had already requested it, it would not.
  3. Sisyphus would then give the file that the source requested. It gives the file regardless of whether the download counted or not.

For the most part, this system works fairly well. The main issue comes in step 2: it takes a little while for different Sisyphus instances to sync up with each other. One of the benefits of Cloudflare Workers is that the code is deployed to hundreds of different servers around the world. When multiple requests come in at the same time, they can get routed to different servers in order to allow each request to be handled faster. Cloudflare Workers, however, takes up to 60 seconds for each server’s information to sync up with each other. A server in Australia might know that a given source has already downloaded something, but a server in Turkey might not. As a result, multiple downloads from the same source might all get counted if they are handled by different servers.

In order to fix this, we entirely rewrote Sisyphus. It still uses Cloudflare Workers, but all of the processing of step 2 has been offloaded to the main Modrinth backend. This not only speeds up downloads (even if only briefly), but also makes download counts more reliable. Over the past few days, we've already implemented the necessary adjustments. Our observations have shown that the results are significantly more consistent in their accuracy. Instead of having strange spikes in activity, the graph of new downloads now follows the expected pattern.

A graph that is split up into two parts: on the left, a spiky graph with the text "old sisyphus". On the right, a graph with consistent dips and peaks.

Notice the spikes on the left? Compare that to the silky-smooth sinusoidal satisfaction on the right!

To reiterate, the issue is now resolved and payouts were not affected. Payouts do not take into account downloads from launchers other than the Modrinth App; therefore, this adjustment has no bearing on payouts.

P.S. Are you curious about why our download counter is called Sisyphus? In Greek mythology, Sisyphus rolls a boulder up a hill for the rest of eternity. Like Sisyphus, our download counter has no point other than to keep increasing for as long as Modrinth exists.

` diff --git a/packages/blog/compiled/download_adjustment.ts b/packages/blog/compiled/download_adjustment.ts new file mode 100644 index 000000000..bf8fec048 --- /dev/null +++ b/packages/blog/compiled/download_adjustment.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./download_adjustment.content`).then((m) => m.html), + title: 'Correcting Inflated Download Counts due to Rate Limiting Issue', + summary: 'A rate limiting issue caused inflated download counts in certain countries.', + date: '2023-11-10T20:00:00.000Z', + slug: 'download-adjustment', + authors: ['6plzAzU4', 'MpxzqsyW'], + thumbnail: false, + short_title: 'Correcting Inflated Download Counts', +} diff --git a/packages/blog/compiled/index.ts b/packages/blog/compiled/index.ts new file mode 100644 index 000000000..df58df5ec --- /dev/null +++ b/packages/blog/compiled/index.ts @@ -0,0 +1,56 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +import { article as a_new_chapter_for_modrinth_servers } from './a_new_chapter_for_modrinth_servers' +import { article as accelerating_development } from './accelerating_development' +import { article as becoming_sustainable } from './becoming_sustainable' +import { article as capital_return } from './capital_return' +import { article as carbon_ads } from './carbon_ads' +import { article as creator_monetization } from './creator_monetization' +import { article as creator_update } from './creator_update' +import { article as creator_updates_july_2025 } from './creator_updates_july_2025' +import { article as design_refresh } from './design_refresh' +import { article as download_adjustment } from './download_adjustment' +import { article as knossos_v2_1_0 } from './knossos_v2_1_0' +import { article as licensing_guide } from './licensing_guide' +import { article as modpack_changes } from './modpack_changes' +import { article as modpacks_alpha } from './modpacks_alpha' +import { article as modrinth_app_beta } from './modrinth_app_beta' +import { article as modrinth_beta } from './modrinth_beta' +import { article as modrinth_servers_beta } from './modrinth_servers_beta' +import { article as new_site_beta } from './new_site_beta' +import { article as plugins_resource_packs } from './plugins_resource_packs' +import { article as pride_campaign_2025 } from './pride_campaign_2025' +import { article as redesign } from './redesign' +import { article as skins_now_in_modrinth_app } from './skins_now_in_modrinth_app' +import { article as two_years_of_modrinth_history } from './two_years_of_modrinth_history' +import { article as two_years_of_modrinth } from './two_years_of_modrinth' +import { article as whats_modrinth } from './whats_modrinth' +import { article as windows_borderless_malware_disclosure } from './windows_borderless_malware_disclosure' + +export const articles = [ + a_new_chapter_for_modrinth_servers, + accelerating_development, + becoming_sustainable, + capital_return, + carbon_ads, + creator_monetization, + creator_update, + creator_updates_july_2025, + design_refresh, + download_adjustment, + knossos_v2_1_0, + licensing_guide, + modpack_changes, + modpacks_alpha, + modrinth_app_beta, + modrinth_beta, + modrinth_servers_beta, + new_site_beta, + plugins_resource_packs, + pride_campaign_2025, + redesign, + skins_now_in_modrinth_app, + two_years_of_modrinth_history, + two_years_of_modrinth, + whats_modrinth, + windows_borderless_malware_disclosure, +] diff --git a/packages/blog/compiled/knossos_v2_1_0.content.ts b/packages/blog/compiled/knossos_v2_1_0.content.ts new file mode 100644 index 000000000..e131389c9 --- /dev/null +++ b/packages/blog/compiled/knossos_v2_1_0.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

It's officially been a bit over a week since Modrinth launched out of beta. We have continued to make improvements to the user experience on the website.

New features

We've added a number of new features to improve your experience.

Click to expand gallery images

The new expanding gallery images

In the gallery page of a project, you can now click on the images to expand the image and view it more closely. You can also use the left arrow, right arrow, and Escape keyboard keys to aid navigation.

Filters for the 'Changelog' and 'Versions' pages

The new changelog and versions filtering options

Versions on the Changelog and Versions page can now be filtered by mod loader and Minecraft version.

More easily access the list of projects you follow

The new 'Following' button in the profile dropdown

The link to the list of your followed projects is now listed in your profile dropdown.

Fixes and Changes

While new features are great, we've also been working on a bunch of bugfixes. Below is a list of some of the notable fixes, but it is not a comprehensive list.

  • Improved the layout of the search page's search bar and options card to more dynamically adjust to screen size
  • Changed the tab indicator to be rounded
  • Changed the download icon to be more recognizable
  • Changed the profile dropdown caret to use an SVG instead of a text symbol for better font support
  • Changed the styling on text fields to be more consistent with the design language of the site
  • Changed the styling on disabled buttons to use an outline to reduce confusion
  • Changed the styling on links to be more consistent and obvious
  • Changed the wording of the options that move the sidebars to the right
  • Changed the green syntax highlighting in code blocks to match the brand color
  • Fixed the styling on various buttons and links that were missing hover or active states
  • Fixed the inconsistent rounding of the information card on the home page
  • [GH-370] Fixed download buttons in the changelog page
  • [GH-384] Fixed selecting too many Minecraft versions in the search page covering the license dropdown
  • [GH-390] Fixed the hover state of checkboxes not updating when clicking on the label
  • [GH-393] Fixed the padding of the donation link area when creating or editing a project
  • [GH-394] Fixed the rounding radius of dropdowns when opening upwards

Minotaur fixes

Minotaur, our Gradle plugin, has also received a few fixes. This isn't going to be relevant to most people, but is relevant to some developers using this tool to deploy their mods.

  • Debug mode (enabled through debugMode = true) allows previewing the data to be uploaded before uploading
  • Fix edge case with ForgeGradle due to broken publishing metadata
  • Fix game version detection on Fabric Loom 0.11
  • Fix doLast and related methods not being usable because the task was registered in afterEvaluate

These fixes should have been automatically pulled in, assuming you're using Minotaur 2.+. If not, you should be upgrading to 2.0.2.

Need a guide to migrate from Minotaur v1 to v2? Check the migration guide on the redesign post.

` diff --git a/packages/blog/compiled/knossos_v2_1_0.ts b/packages/blog/compiled/knossos_v2_1_0.ts new file mode 100644 index 000000000..3c36f0729 --- /dev/null +++ b/packages/blog/compiled/knossos_v2_1_0.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./knossos_v2_1_0.content`).then((m) => m.html), + title: 'This week in Modrinth development: Filters and Fixes', + summary: + 'Continuing to improve the user interface after a great first week since Modrinth launched out of beta.', + date: '2022-03-09T00:00:00.000Z', + slug: 'knossos-v2.1.0', + authors: ['Dc7EYhxG'], + thumbnail: true, +} diff --git a/packages/blog/compiled/licensing_guide.content.ts b/packages/blog/compiled/licensing_guide.content.ts new file mode 100644 index 000000000..86fa58473 --- /dev/null +++ b/packages/blog/compiled/licensing_guide.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Why do you need to license your software? What are those licenses for anyway? These questions are more important than you think

What is a software license?

To summarise the Wikipedia article on the matter, it's essentially a legal contract between you (the mod developer) and anyone who uses, copies, modifies, etc the mod or any code having to do with it. License has the power to allow people to do whatever they want, or only permit the usage of the mod in-game. However, the majority of cases lie in-between these opposites.

So which software license should I choose?

First and foremost, the choice of the software license is not entirely up to you, because you have to have the legal ability to do so. For instance, not all licenses are compatible with Minecraft's EULA (End-User License Agreement). Besides, if you are not the only one working on the project, you must get permission from all other contributors to your code before changing or adding a license. Please, ensure you have done so before implementing a license.

Before we can decide which one to use, however, we must establish some additional definitions. Open software licenses can be split into three main categories: public domain, permissive, and copyleft.

Permissive license

A permissive license is a type of license that usually gives the abilities to use, copy, modify, distribute, sell, and relicense a piece of software.

The most popular license on Modrinth, the MIT License, is a permissive license. It is an easy-to-read license designed to be used for developers, which is why it is used extensively in the Minecraft open source community.

The Apache License 2.0 is also a very good permissive license to use. The main difference between it and the MIT License is that the Apache License gives an explicit patent grant, whereas patents must be registered manually with the MIT. There is also an additional clause with the Apache License, stating that any modified files must "carry prominent notices" of it being modified.

Copyleft license

A copyleft license gives to the other party specific rights usually only given to the copyright owner, under the condition that those same rights are applied to all variations of that software. These are also sometimes called "viral" or "infectious" licenses, because of the requirement to pass those rights on to derivatives.

The second most common license on Modrinth is a copyleft license: the GNU Lesser General Public License Version 3 (usually shortened to LGPL-3.0).

Typically, when a copyleft license is wanted, the GPL-3.0 or AGPL-3.0 would be used. However, these licenses are incompatible if linking into Minecraft, due to an issue with the difference between proprietary and free software outlined by these licenses (more information here). An exception can be added to allow linking, such as that found here, but it is recommended to just use the LGPL-3.0 instead if possible.

Public domain dedication

A public domain dedication gives all rights to everyone who gets a copy of the software. This includes but is not limited to the ability to use, copy, modify, distribute, sell, or relicense that software. Software with a public domain dedication has no copyright holder.

The third most common license used on Modrinth is the Creative Commons Zero 1.0 Universal, which is a public domain dedication with a strong international legal basis, while still retaining trademark and patent rights.

Creative Commons licenses as a whole are not recommended for software, but rather for other creative works: use this license with caution. If you wish to have the simplest public domain dedication possible, the Unlicense is also an option.

What if I don't want to choose a license?

Without a license software is considered proprietary and all rights reserved. This means that people may only use it in the ways the copyright owner specifies, which, in the Minecraft world (no pun intended), typically just means downloading and using it; no modifications, unauthorized distributions: basically nothing.

This is why picking a proper software license is so important. It tells everyone what they can and cannot do with your software, making the difference between software anyone can contribute to and change however they want, and software that only you have the code behind.

That being said, All Rights Reserved and not using a license are options, if you don't want to choose a public domain, permissive, or copyleft license. This can be useful in some cases, but as with any license, be aware of the effects: contributions will be difficult or impossible, and users may be inclined not to use your software. Also, in case of Minecraft, all mods, including the All Rights Reserved mods, are affected by Minecraft's EULA, which states:

Any Mods you create for the Game from scratch belong to you (including pre-run Mods and in-memory Mods) and you can do whatever you want with them, as long as you don't sell them for money / try to make money from them and so long as you don't distribute Modded Versions of the Game.

What this means is you are not allowed to sell your mods even if you reserve all rights to them. There are plenty more examples of such details in licenses and other legal agreements in the modding world. All in all, be aware that you cannot decide all of your and other's rights with your license.

Conclusion

To conclude, the importance of a software license cannot be overstated. You can choose whatever license you want (assuming you have the legal ability, of course), but be aware of the differences and consequences of choosing one over another. The licenses we've specified are what we recommend, as they are common and easy to understand. Hopefully, you will make your decision based on what you want to use and what your goals and purposes are.

A massive thank you goes to Alexander Ryckeboer (Progryck) for the cover image!

Disclaimers

We are not lawyers, and thus, this is not legal advice. No warranty is given regarding this information, and we (Modrinth) disclaim liability for damages resulting in using this information given on an "as-is" basis. For more information on the legal aspect to software licensing, please refer to "The Legal Side of Open Source".

No matter your choice of license, by uploading any Content (including but not limited to text, software, and graphics) to Modrinth, you give us certain rights to your Content, including but not limited to the ability to use, reproduce, or distribute. For more information, please see the Modrinth Terms of Use.

Measurements for "most popular license", "second most common license", and "third most common license", were taken 2021-04-30. Custom licenses were not taken into account.

` diff --git a/packages/blog/compiled/licensing_guide.ts b/packages/blog/compiled/licensing_guide.ts new file mode 100644 index 000000000..5d1bfc48d --- /dev/null +++ b/packages/blog/compiled/licensing_guide.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./licensing_guide.content`).then((m) => m.html), + title: "Beginner's Guide to Licensing your Mods", + summary: + "Software licenses; the nitty-gritty legal aspect of software development. They're more important than you think.", + date: '2021-05-16T00:00:00.000Z', + slug: 'licensing-guide', + authors: ['6plzAzU4', 'aNd6VJql'], + thumbnail: true, +} diff --git a/packages/blog/compiled/modpack_changes.content.ts b/packages/blog/compiled/modpack_changes.content.ts new file mode 100644 index 000000000..4d5da95de --- /dev/null +++ b/packages/blog/compiled/modpack_changes.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

CurseForge CDN links requested to be removed by the end of the month

Modrinth's alpha launch of modpacks has been highly successful in the nearly two weeks it has been live, with over forty packs launched to the platform. However, a number of these packs include links to download mods from CurseForge's CDN, which has caught the attention of CurseForge. On May 24th, 2022, a representative from Overwolf sent email correspondence to us demanding us to remove all modpacks and documentation that contain references to CurseForge CDN links by the end of the month. The message was vague, and didn't specify whether or not they were making a legal threat against us or not, so we responded in attempt to clarify what would happen if we chose not to comply. In response, they told us that they would "consider next steps."

Modrinth has every intention of complying with their demands, despite our belief that this is a huge loss for the community. However, CurseForge's immediate "next steps" were to message launcher developers, requesting that they break support for Modrinth packs that contain CurseForge CDN links, and claiming to them that we outright refused to remove the packs containing the links from our platform ourselves when we did not refuse.

To be clear, Modrinth condemns the anti-competitive behaviors that CurseForge are engaging in, however, we do not wish for CurseForge or authors who have elected to opt-out of third party downloads from their platform to be our enemies. Modrinth is and will always remain a project in support of open source software, with open and free APIs for all to use, and encouraging of much needed competition and diversity in the mod hosting space.

Unfortunately, in order to comply with their request, all Modrinth modpacks must now use override JARs in place of any links to CurseForge's CDN. Specifically, CDN links to edge.forgecdn.net and media.forgecdn.net will no longer be part of the .mrpack specification, effective today. Of course, modpack authors must ensure that they are properly licensed to redistribute any mods that are not hosted on the Modrinth platform. While this is a huge blow to modpack creators and users of our platform for now, relying on CurseForge CDN links has always been unreliable as a long-term solution, because they could choose to change the links at any time, and it leaves variables outside of our control. In the long run, packs containing mostly mods hosted on Modrinth will be better for the growth of our platform and for the stability of modpacks.

In order to use mods exclusively hosted on CurseForge as override JARs, pack creators must ensure that either of the following conditions must be met:

  1. The mod is licensed under terms that allow for redistribution. The pack author is responsible for following the terms of the license.
  2. General or individual permission is granted from the mod author. This can be in the form of a message from the author or a statement made on a mod's project description granting permission to use it in modpacks.

In order to aid in this process, Modrinth will be building a third party mod license database and automated tools that will help pack creators with the hassle that will be ensuring all of the mods in their packs are properly licensed. In addition, packs will continue to be hand-reviewed by Modrinth moderation staff and verified. Do note that in this transition time, the review process for modpack projects may experience significant delays. Authors of existing modpacks on the platform will be reached out to in order to help them convert their existing packs to compliant packs.

For those wondering, our next steps as a company are:

  1. Mod license database for Modpack authors
  2. Creator monetization
  3. The Modrinth launcher for downloading and creating modpacks.
` diff --git a/packages/blog/compiled/modpack_changes.ts b/packages/blog/compiled/modpack_changes.ts new file mode 100644 index 000000000..2923bc98c --- /dev/null +++ b/packages/blog/compiled/modpack_changes.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./modpack_changes.content`).then((m) => m.html), + title: 'Changes to Modrinth Modpacks', + summary: 'CurseForge CDN links requested to be removed by the end of the month', + date: '2022-05-28T00:00:00.000Z', + slug: 'modpack-changes', + authors: ['MpxzqsyW', 'Dc7EYhxG'], + thumbnail: true, +} diff --git a/packages/blog/compiled/modpacks_alpha.content.ts b/packages/blog/compiled/modpacks_alpha.content.ts new file mode 100644 index 000000000..1e2d14b3d --- /dev/null +++ b/packages/blog/compiled/modpacks_alpha.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

After over a year of development, Modrinth is happy to announce that modpack support is now in alpha testing!

What does alpha mean, exactly? Principally, it means that modpack support is still unstable and that not everything is perfect yet. However, we believe it to be complete enough that it can be released for general use and testing.

From this point forward, Modrinth has shifted development effort from modpacks to creator payouts. This long-anticipated feature means that mod developers, modpack creators, and anyone else who uploads content to Modrinth will be eligible to get the ad revenue generated from their project pages.

Where can I find them?

Right next to mods on the site! URLs to modpacks are the same as mods, just with mod replaced with modpacks, so you can find the search at https://modrinth.com/modpacks.

Over a dozen modpacks have already been created by our early pack adopters, and those are available for download right now!

Wait, so how do I download them?

At this point in time, the only stable way to download modpacks and use them is through ATLauncher. You can also install Modrinth packs if you switch to the development branch of MultiMC. We're hoping to be supported by more launchers in the future, including our own launcher, which is still in development. Our documentation for playing modpacks will always have an up-to-date listing of the most popular ways to play packs.

How do I create packs?

You can either use ATLauncher or packwiz to create modpacks. The Modrinth format is unique for our purposes, which is specifically in order to allow mods from multiple platforms to be in a pack. Our documentation for creating modpacks will always have an up-to-date listing of the most popular ways to create packs.

Can I use CurseForge mods in my modpack?

Yes! The Modrinth format uses a link-based approach, meaning that theoretically, mods from any platform are usable. In practice, we are only allowing links from Modrinth, CurseForge, and GitHub. In the future, we may allow other sites.

What happened to Theseus?

For a while, we've been teasing Theseus, our own launcher. While lots of progress has been made on it, we haven't yet gotten it to a usable state even for alpha testing. Once we think it's usable, we will provide alpha builds -- however, for now, our main focus will be shifting to payouts, with Theseus development ramping up once that is out.

Remember: Modrinth only has a small team, and we have a lot of real-life responsibilities too. If you have experience in Rust or Svelte and would like to help out in developing it, please feel free to shoot a message in the #launcher channel in our Discord.

Conclusion

All in all, this update is quite exciting for everyone involved. Just like with the redesign, this is the culmination of months upon months of work, and modpack support is really a big stepping stone for what's still yet to come.

Remember: alpha means that it's still unstable! We are not expecting this release to go perfectly smoothly, but we still hope to provide the best modding experience possible. As always, the fastest and best way to get support is through our Discord.

Next stop: creator payouts!

` diff --git a/packages/blog/compiled/modpacks_alpha.ts b/packages/blog/compiled/modpacks_alpha.ts new file mode 100644 index 000000000..79fbb520a --- /dev/null +++ b/packages/blog/compiled/modpacks_alpha.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./modpacks_alpha.content`).then((m) => m.html), + title: 'Modrinth Modpacks: Now in alpha testing', + summary: + "After over a year of development, we're happy to announce that modpack support is now in alpha testing.", + date: '2022-05-15T00:00:00.000Z', + slug: 'modpacks-alpha', + authors: ['6plzAzU4'], + thumbnail: true, +} diff --git a/packages/blog/compiled/modrinth_app_beta.content.ts b/packages/blog/compiled/modrinth_app_beta.content.ts new file mode 100644 index 000000000..04b491347 --- /dev/null +++ b/packages/blog/compiled/modrinth_app_beta.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

The past few months have been a bit quiet on our part, but that doesn’t mean we haven’t been working on anything. In fact, this is quite possibly our biggest update yet, bringing the much-anticipated Modrinth App to general availability, alongside several other major features. Let’s get right into it!

Modrinth App Beta

Most of our time has been spent working on Modrinth App. This launcher integrates tightly with the website, bringing you the same bank of mods, modpacks, data packs, shaders, and resource packs already available for download on Modrinth.

Alongside that, there are a wealth of other features for you to find, including:

  • Full support for vanilla, Forge, Fabric, and Quilt
  • Full support for Windows, macOS, and Linux
  • Modrinth modpack importing, either through the website or through a .mrpack file
  • Modrinth modpack exporting to the .mrpack format to upload to the website or share with friends
  • Importing of instances from a variety of different launchers, including MultiMC, GDLauncher, ATLauncher, CurseForge, and Prism Launcher
  • The ability to update, add, and remove individual mods in a modpack
  • The ability to run different modded instances in parallel
  • The ability to view and share current and historical logs
  • An auto-updater to ensure the app is always up-to-date
  • An interactive tutorial to show you through the core features of the app
  • Performance through the roof, backed by Rust and Tauri (not Electron!)
  • Fully open-source under the GNU GPLv3 license

More features will, of course, be coming in the future. This is being considered a beta release. Nonetheless, we’re still very proud of what we’ve already created, and we’re pleased to say that it’s available for download on our website right now at https://modrinth.app. Check it out, play around with it, and have fun!

Authentication, scoped tokens, and security

The second major thing we’re releasing today is a wide range of changes to our authentication system. Security is a top concern at Modrinth, especially following recent events in the modded Minecraft community when several individuals were compromised due to a virus. While Modrinth was not affected directly by this attack, it provided a harrowing reminder of what we’re working with. That’s why we’re pleased to announce three major features today that will strengthen Modrinth’s security significantly: in-house authentication, two-factor authentication, and scoped personal access tokens.

In-house authentication and two-factor authentication

A screenshot of the new Modrinth sign-in page, showing options to sign in with Discord, GitHub, Microsoft, Google, Steam, GitLab, or with an email and password.

Until today, Modrinth has always used GitHub accounts exclusively for authentication. That changes now. Starting today, you can now connect your Discord, Microsoft, Google, Steam, and/or GitLab accounts to your Modrinth account. You may also forgo all six of those options and elect to use a good ol’ fashioned email and password. No problems with that! (If you’re curious, we store passwords hashed with the Argon2id method, meaning we couldn't read them even if we wanted to.)

` diff --git a/packages/blog/compiled/modrinth_app_beta.ts b/packages/blog/compiled/modrinth_app_beta.ts new file mode 100644 index 000000000..35c3f050d --- /dev/null +++ b/packages/blog/compiled/modrinth_app_beta.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./modrinth_app_beta.content`).then((m) => m.html), + title: 'Introducing Modrinth App Beta', + summary: + 'Changing the modded Minecraft landscape with the new Modrinth App, alongside several other major features.', + date: '2023-08-05T20:00:00.000Z', + slug: 'modrinth-app-beta', + authors: ['6plzAzU4'], + thumbnail: false, + short_title: 'Modrinth App Beta and Upgraded Authentication', + short_summary: 'Launching Modrinth App Beta and upgrading authentication.', +} diff --git a/packages/blog/compiled/modrinth_beta.content.ts b/packages/blog/compiled/modrinth_beta.content.ts new file mode 100644 index 000000000..376758cfe --- /dev/null +++ b/packages/blog/compiled/modrinth_beta.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

After six months of work, Modrinth enters Beta, helping modders host their mods with ease!

Six months ago, in order to fill a void in the modding community, the project that would eventually become Modrinth was founded. Modrinth was created to bring change to an otherwise stagnant landscape of hosts. Today, Modrinth enters Beta, a huge step forward for Modrinth!

Modrinth's brand new design, rolling out with the launch of Beta

Modrinth's brand new design, rolling out with the launch of Beta

What's new?

If you've checked out Modrinth in the past, here's the main things you'll notice that have changed:

  • All new clean and modern design in both light and dark modes
  • Mods now display download counts correctly
  • Mod information can now be edited in the author Dashboard
  • More information can be added to mods

What's next?

Modrinth is still in beta, of course, so there will be bugs. In the coming weeks and months, we will be prioritizing fixing the issues that currently exist and continue refining the design in areas that are rough.

If you find any, please report them to the issue tracker: https://github.com/modrinth/code/issues

If you would like to chat about Modrinth, our discord is open to all here: https://discord.modrinth.com

` diff --git a/packages/blog/compiled/modrinth_beta.ts b/packages/blog/compiled/modrinth_beta.ts new file mode 100644 index 000000000..8fe49c317 --- /dev/null +++ b/packages/blog/compiled/modrinth_beta.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./modrinth_beta.content`).then((m) => m.html), + title: 'Welcome to Modrinth Beta', + summary: + 'After six months of work, Modrinth enters Beta, helping modders host their mods with ease!', + date: '2020-12-01T00:00:00.000Z', + slug: 'modrinth-beta', + authors: ['Dc7EYhxG'], + thumbnail: true, +} diff --git a/packages/blog/compiled/modrinth_servers_beta.content.ts b/packages/blog/compiled/modrinth_servers_beta.content.ts new file mode 100644 index 000000000..277b7aac8 --- /dev/null +++ b/packages/blog/compiled/modrinth_servers_beta.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

It's been almost four years since we publicly launched Modrinth Beta. Today, we're thrilled to unveil a new beta release of a product we've been eagerly developing: Modrinth Servers.

Modrinth Servers aims to provide the most seamless experience for running and playing on modded servers. To make this possible, we have partnered with our friends at the server hosting provider Pyro. Together, we've developed fully custom software that gives us a unique advantage in scaling, offering new features and integrations that other hosts couldn't dream of.

For this beta launch, all servers are US-only. Please be aware of this if you are looking to purchase a server, as it may not be optimal for users outside of North America.

A screenshot of the fully-custom Modrinth Servers panel integrated into Modrinth

What makes Modrinth Servers unique?

We understand that entering the server hosting industry might come as a surprise given the number of existing providers. Here's what sets Modrinth Servers apart:

The most modern hardware

Your modpack shouldn't have to run slow. All our servers are powered by cutting-edge 2023 Ryzen 7 and Ryzen 9 CPUs with DDR5 memory. From our research, we couldn't find any other Minecraft server host offering such modern hardware at any price point, much less at our affordably low one. This ensures smooth performance even with the most demanding modpacks.

Seamless integration with Modrinth content

Download mods and modpacks directly from Modrinth without any hassle. This deep integration simplifies server setup and management like never before. With just a few clicks, you can have your server up and running with your favorite mods.

Fully custom panel and backend

Unlike most other server hosts that rely on off-the-shelf software like Multicraft or Pterodactyl, Modrinth Servers is fully custom-built from front to back. This enables higher performance and much deeper integration than is otherwise possible. Our intuitive interface makes server management a breeze, even for newcomers.

Dedicated support

Our team is committed to providing exceptional support. Whether you're experiencing technical issues or have questions, we're here to ensure your experience with Modrinth Servers is top-notch.

No tricky fees or up-charges

Modrinth Servers are offered in a very simple Small, Medium, and Large pricing model, and are priced based on the amount of RAM at $3/GB. Custom URLs, port configuration, off-site backups, and plenty of storage is included in every Modrinth Server purchase at no additional cost.

What’s next?

As this is a beta release, there's much more to come for Modrinth Servers:

  • Global availability: We plan to expand to more worldwide regions and offer the ability to select a region for your server, ensuring optimal performance no matter where you are.
  • Support more types of content: We'll be adding support for plugin loaders and improving support for data packs, giving you more flexibility and functionality
  • Social features: A friends system to make sharing invites to servers easier, streamlining sharing custom-built modpacks and servers with your community.
  • App integration: Full integration with Modrinth App, including the ability to sync an instance with a server or friends, making collaboration seamless.
  • Collaborative management: Give other Modrinth users access to your server panel so you can manage your server with your team.
  • Automatic creator commissions: Creators will automatically earn a portion of server proceeds when content is installed on a Modrinth Server.

And so much more... stay tuned!

We can't wait for you to try out Modrinth Servers and share your feedback. This is just the beginning, and we're excited to continue improving and expanding our services to better serve the Minecraft community.

From the teams at Modrinth and Pyro, with <3

` diff --git a/packages/blog/compiled/modrinth_servers_beta.ts b/packages/blog/compiled/modrinth_servers_beta.ts new file mode 100644 index 000000000..828c24bef --- /dev/null +++ b/packages/blog/compiled/modrinth_servers_beta.ts @@ -0,0 +1,12 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./modrinth_servers_beta.content`).then((m) => m.html), + title: 'Host your own server with Modrinth Servers — now in beta', + summary: 'Fast, simple, reliable servers directly integrated into Modrinth.', + date: '2024-11-03T06:00:00.000Z', + slug: 'modrinth-servers-beta', + authors: ['MpxzqsyW', 'Dc7EYhxG'], + thumbnail: true, + short_title: 'Introducing Modrinth Servers', + short_summary: 'Host your next Minecraft server with Modrinth.', +} diff --git a/packages/blog/compiled/new_site_beta.content.ts b/packages/blog/compiled/new_site_beta.content.ts new file mode 100644 index 000000000..14f2cf4f1 --- /dev/null +++ b/packages/blog/compiled/new_site_beta.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Update 04/02: Due to a number of (ridiculous) complaints we received such as “not being able to use the site on mobile” and “the ads are a bit much”, we have decided to halt the rollout of the beta site. Happy April 1st, everyone.


OwO hewwo evewyone! I'm super exdited to announth that Modwinth is getting a bwand new update! WOOHOO! But, befowe I dwelve into the detaiws, I want to apologize in advance because my grammar and spwelling might not be the best (teehee). Anyway, this new update is going to be sow awesome and is going to make Modwinth the best modding website evew!

But first, a quick message to all of our mobile users: We don't cawe about you anymore! insert evil laughter here We've decided to focus all of our efforts on desktop users, so we won't be supporting mobile devices anymore. If you want to use Modwinth, you'll have to buy a desktop or laptop computer! (LOL)

And that's not aww, we've also added a bunch mowe ads! Because, wet's face it, who doesn't wove ads? Am I wight? (hehe) We've added ads that will pop up evewy five seconds, so you won't miss them. And to make suwe you don't get bowed of seeing the same ad over and over again, we've made suwe to wotate them frequently. You'we welcome! UwU

Oh, and did I mention the nyew wayout and design? We've made it weally cool! YAY! We wanted to make suwe that UwU aww feel a sense of nyostalgia and weminisce about the good owd days of the intewnet. Wemembew those days when websites wooked wike they wewe made by a 5-yeaw-owd? Well, we've bwought that back! (WINKY WINK)

The Fitneshgwam Pacew Test is a multistage aewobic capabiwity test that pwogwessively gets mowe difficult as it continyues. The 20 metew pacer test will begin in 30 seconds. Nyya~ Meowmeow! Win the pacer test you must weach the othew end befowe the beep. Each time you hear the beep meow, uwu wun nyya to the othew end nya. The wun nya must be in wine with the beep. Meowmeow! The eawwier nya you wun, the mowe time you have to westa nya. If you faiw to weach the othew end befowe the beep nya meow, the test will end. So twy youw best and Gud wuck!

In suwummawy, we'we weally excited about the nyew changes and we hope UwU awe too! Modwinth is no wonger a pwace for everyone, but wather, for deskto-p users onwy. We've also added mowe ads than evew befowe and made suwe to make the wayout and design as tewwible as possibwe!

Enjoy! UwU

` diff --git a/packages/blog/compiled/new_site_beta.ts b/packages/blog/compiled/new_site_beta.ts new file mode 100644 index 000000000..47820dc11 --- /dev/null +++ b/packages/blog/compiled/new_site_beta.ts @@ -0,0 +1,12 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./new_site_beta.content`).then((m) => m.html), + title: '(April Fools 2023) Powering up your experience: Modrinth Technologies™️ beta launch!', + summary: "Welcome to the new era of Modrinth. We can't wait to hear your feedback.", + date: '2023-04-01T08:00:00.000Z', + slug: 'new-site-beta', + authors: [], + thumbnail: true, + short_title: '(April Fools 2023) Modrinth Technologies™️ beta launch!', + short_summary: 'Power up your experience.', +} diff --git a/packages/blog/compiled/plugins_resource_packs.content.ts b/packages/blog/compiled/plugins_resource_packs.content.ts new file mode 100644 index 000000000..71c9de554 --- /dev/null +++ b/packages/blog/compiled/plugins_resource_packs.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

With the addition of modpacks, creating new project types has become a lot easier. Our first additions to our new system are plugins and resource packs. We'll also be working on adding datapacks, shader packs, and worlds after payouts are released.

Don't worry - this hasn't taken away an awful lot of development time from author payouts. Those are still being worked on!

Plugins

With plugins, we're supporting five loaders and three proxies: Bukkit, Spigot, Paper, Purpur, Sponge, BungeeCord, Waterfall, and Velocity.

Several new categories have specifically been added for plugins, though mod categories can be used for plugins and vice versa.

Go browse our plugin section!

Why add plugins?

This is a question we've received quite often since we first announced our intention to host plugins, so let's break it down a bit.

Currently, there are three main platforms on which plugins can be downloaded from: Bukkit, Spigot, and Sponge's Ore. Notice the main issue there? These sites are bound to a specific loader. This isn't inherently bad - however, as forks and new projects spawn, there is a noticeable lack of flexibility in what can be hosted on a given platform. For example, Spigot is unable to host plugins which specifically depend on the exclusive features provided by Paper's API. Paper's solution to this is to build their own platform, but this simply perpetuates the same problem.

The best solution here is to create a separate platform which is unbiased and flexible enough to adapt to a changing ecosystem. Modrinth is the perfect candidate for this - after all, plugins are mods under a different name, and likewise mods are plugins under a different name.

No matter the situation, authors are always allowed to upload their plugins to multiple sites. Build automation is incredibly easy to set up, especially with "set it and forget it" build tools such as Minotaur.

Will paid plugins be supported?

No. Modrinth does not have the infrastructure to support this, and it's not currently planned. Author payouts are still being worked on.

What about mods that have plugin versions and vice versa?

Modrinth is taking a unique approach to this. While the search pages are separate, in reality, the backend is the same. You can select plugin loaders when creating a mod and you can select mod loaders when creating a plugin. The split only exists on the frontend so that projects like Chunky can share a single page across their versions.

Plugins which also have versions for mod loaders will be displayed under the /mod/ URL on the frontend. Plugins without mod loader versions are displayed under /plugin/.

Resource packs

The other thing we've added support for is resource packs!

Previously we hinted at Bedrock resource packs being supported in addition to Java resource packs. We've decided not to add Bedrock resource packs until we also add support for other Bedrock resources for various technical reasons.

Go browse our resource pack section!

Secondary categories

Resource packs are capable of adding a wide range of different things, like fonts, sounds, and core shaders. We found that the current category system was inadequate to account for all of these, especially with the three maximum limit. Thus, we've introduced a "secondary category" system, for categories which don't display by default but can still be searched. These secondary categories have a limit of 255 instead of three. Please add as many secondary categories as are relevant!

On search pages, "Features" have been split into their own header. Where categories for resource packs can be accurately described as themes, features instead show what exactly a resource pack adds. Resolutions have also been split into their own header, though selecting a pack resolution is optional.

What about resource packs that require a mod to function?

Resource packs are able to set dependencies on other projects (even those which aren't resource packs), just like how modpacks are able to set dependencies on mods. It's worth noting that OptiFine is not on the platform, and thus you cannot set a dependency on that; however, you can set a dependency on any of the other alternative mods which are available on Modrinth, including Entity Texture Features, OptiGUI, Continuity, CIT Resewn, Animatica, or Custom Entity Models.

Other miscellaneous changes

Version number changes

For a long time, version numbers have had a requirement to be unique within the same project. Alongside this update, we found it necessary to remove this restriction on version numbers. Thus, you'll no longer have to use something like 1.2.3+forge and 1.2.3+fabric if you have a project on multiple loaders - instead, you can just use 1.2.3.

To accommodate this, the frontend now appends the loaders and game versions onto the end of a URL if there are duplicates, and the Modrinth Maven now supports version IDs.

We do not recommend retroactively changing version numbers to remove this additional metadata, though. If you change your version numbers, the following will break:

  • URLs to specific versions
  • Buildscripts depending on your project via the Modrinth Maven
  • Download counters (see labrinth issue #351)

LiteLoader support

Modrinth now supports LiteLoader for mods. It's nothing special, but it should help with some archival efforts.

Misc category deletion

We've also deleted the Misc category as no one is going to want to filter by Misc in search. If you have any other suggestions for categories, feel free to suggest them in our Discord or Tweet at us!

Developer/API changes

The changes in this update are rather minimal when it comes to API-related stuff. Two new fields have been added to the project struct - approved, which is the timestamp of when the project was approved (null if it's not approved or unlisted), and additional_categories, another set of categories which are to be seen as less important than normal categories. You can read the secondary categories section for more info on it. If you wish to implement the headers in your API integration, the category list now has a header field.

As for the search result struct, created now matches the approved date rather than the published project field, and categories now also includes secondary categories. A new field, display_categories, matches only primary categories.

Differences between mod loaders and plugins will need to be hardcoded within your API integration for the time being if you wish to have them shown separately. This will be cleaned up in API v3 alongside a general cleanup of a lot of other small aspects of the API. If you have any suggestions for breaking API v3 changes, feel free to suggest them in our Discord. Development on API v3 is likely to begin before the end of the year.

Conclusion

We're very happy to be announcing this feature, even if it is minor in comparison to some of our other past and future announcements. Don't worry - author payouts are still being worked on, and will most likely be our next major announcement! We saw this as an opportunity to get a feature out with relatively little new code (since we'd already done everything needed alongside modpacks), so we ran with it.

As always, feel free to provide feedback on our Discord, and please report any bugs you come across on our GitHub.

` diff --git a/packages/blog/compiled/plugins_resource_packs.ts b/packages/blog/compiled/plugins_resource_packs.ts new file mode 100644 index 000000000..101fe93d4 --- /dev/null +++ b/packages/blog/compiled/plugins_resource_packs.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./plugins_resource_packs.content`).then((m) => m.html), + title: 'Plugins and Resource Packs now have a home on Modrinth', + summary: + 'A small update with a big impact: plugins and resource packs are now available on Modrinth!', + date: '2022-08-27T00:00:00.000Z', + slug: 'plugins-resource-packs', + authors: ['6plzAzU4'], + thumbnail: true, +} diff --git a/packages/blog/compiled/pride_campaign_2025.content.ts b/packages/blog/compiled/pride_campaign_2025.content.ts new file mode 100644 index 000000000..67fbc1514 --- /dev/null +++ b/packages/blog/compiled/pride_campaign_2025.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

What an incredible Pride Month! This June, we came together to support The Trevor Project, an essential organization providing crisis support and life-saving resources for LGBTQ+ young people. We are absolutely thrilled to announce that our community raised a stellar $2,395. That's not all, though — during the campaign, some donations were matched by H&M and the Trevor Project's Board of Directors up to six times, meaning the final impact of Modrinth's donations is a whopping $8,464. Our team was also in the top ten for most funds raised this year!

To put your generosity into perspective, a donation this size can fund hundreds of hours on The Trevor Project's 24/7 crisis hotline. It gives young people a lifeline in their hardest moments and supports important educational services for many others.

We couldn't have done it without you. To every person who donated, shared the post, or simply cheered us on from the sidelines — thank you. We are incredibly proud of what we've achieved together as a community.

While Pride Month provides a special opportunity to focus our efforts, the need for these critical resources continues all year long. The challenges faced by LGBTQ+ young people do not end on July 1st, and organizations like The Trevor Project require ongoing support to continue their life-saving work. If you are able, we encourage you to consider making a contribution at any time.

As part of this campaign, we also added the option to donate part of a Modrinth rewards balance to a variety of charities. This means Modrinth creators can directly use the revenue they earned from ads and Modrinth+ to donate to dozens of causes close to their heart. The Trevor Project is one option, alongside other prominent non-profits such as the American Cancer Society, St. Jude's Children's Research Hospital, Doctors Without Borders, and the Southern Poverty Law Center. You can donate your Modrinth balance to these groups and many more by clicking the "Withdraw" button on your revenue dashboard.

Modrinth's June 2025 campaign will be kept for posterity at this link.

You can donate to The Trevor Project at any time at this link.

` diff --git a/packages/blog/compiled/pride_campaign_2025.ts b/packages/blog/compiled/pride_campaign_2025.ts new file mode 100644 index 000000000..b069acc6b --- /dev/null +++ b/packages/blog/compiled/pride_campaign_2025.ts @@ -0,0 +1,12 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./pride_campaign_2025.content`).then((m) => m.html), + title: 'A Pride Month Success: Over $8,400 Raised for The Trevor Project!', + summary: 'Reflecting on our Pride Month fundraiser campaign for LGBTQ+ youth.', + date: '2025-07-01T18:00:00.000Z', + slug: 'pride-campaign-2025', + authors: ['6plzAzU4', 'bOHH0P9Z', '2cqK8Q5p', 'vNcGR3Fd'], + thumbnail: true, + short_title: 'Pride Month Fundraiser 2025', + short_summary: 'A reflection on our Pride Month fundraiser campaign.', +} diff --git a/packages/blog/compiled/redesign.content.ts b/packages/blog/compiled/redesign.content.ts new file mode 100644 index 000000000..dc57e1d5c --- /dev/null +++ b/packages/blog/compiled/redesign.content.ts @@ -0,0 +1,30 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

After months of relatively quiet development, Modrinth has released many new features and improvements, including a redesign. While we've been a bit silent recently on the website and blog, our Discord server has activity on the daily. Join us there and follow along with the development channels for the very latest information!

For both those who aren't in the Discord and for those who are, this serves as a status update for what exactly has been going on in our silence. There have been an unparalleled amount of changes, improvements, bug fixes, and new features being worked on since April 2021, and we are incredibly excited to share them with everyone. There are still many things we're still working on, such as modpacks, but we've decided to hold back on that as there is still some fine-tuning that needs to be done on that front.

New and improved design

The frontend has received a considerable facelift. With designs made in part by falseresync (and a sprinkle of bikeshedding), we present to you, the redesign!

As they say, a picture tells somewhere around nine-hundred odd words. As such, this section will be heavily focused on screenshots of the pages rather than long descriptions.

Project pages

The new page design, shown for Iris

A beautiful project page for Iris to match its beautiful shaders

On project pages, much of the focus has been shifted to the extended description rather than the metadata, which has been put over on the side. We've also added an option to switch this from the left side to the right side in your user settings, if you so desire.

Gallery

A preview of the gallery functionality

Pictures... pretty!

Developers can now add a Gallery section on each project page! Each uploaded image or GIF can have a title and description associated with them.

Changelog

The changelog page

A changelog page for showing the difference between updates!

Version changelogs are automatically compiled together into a large changelog list. These are put in reverse chronological order, and are separated for Fabric and Forge versions.

Version creation and dependencies

The version creation page

Version creation has gotten an overhaul!

While dependencies have existed in the backend for a while, their implementation was a bit haphazard and was never widely used due to never being in the frontend. Thus, all previous dependencies have been wiped, and they have been redone better(TM). And hey, now you can add and see dependencies in the frontend!

Profile settings & dashboard

The profile settings page

The new settings panel for managing your profile and other visual settings

The dashboard has been reworked and reorganized: the "My mods" section has been merged into the profile page itself, and the "Settings" page has been split into "Profile" and "Security". There are also options for switching the project and search information from the left side of the screen to the right.

A user's profile

The notifications page is also now its own page separate from the dashboard, accessible only from the header. The notifications page also has a highly-requested "Clear all" button.

The notifications page

Backend changes and API v2

There have been a number of breaking changes in this update, and as such, the API number has been bumped. The /api/ prefix has also been removed, as it's redundant when the base API URL is api.modrinth.com. This means the production URL is now api.modrinth.com/v2 instead of api.modrinth.com/api/v1.

The major changes include the universal rename of mod to project, as well as the move of the mod endpoint to search. While version 1 will be supported until January 2024 and won't be removed until July 2024, we still highly recommend that applications migrate as soon as possible. For full migration instructions, see the migration guide on the docs site.

Minotaur

Minotaur is the tool for mod developers to upload their mod directly to Modrinth automated through Gradle. Minotaur received a considerable facelift and is now a lot more user-friendly. Previously, an example buildscript might look like this:

task publishModrinth(type: com.modrinth.minotaur.TaskModrinthUpload) {
+  onlyIf {
+    System.getenv().MODRINTH_TOKEN
+  }
+  token = System.getenv().MODRINTH_TOKEN
+  projectId = 'AABBCCDD'
+  versionNumber = version
+  versionName = "[$project.minecraft_version] Mod Name $project.version"
+  releaseType = 'alpha'
+  changelog = project.changelog
+  uploadFile = remapJar
+  addGameVersion('1.18')
+  addGameVersion('1.18.1')
+  addGameVersion('1.18.2')
+  addLoader('fabric')
+}
+

This exact same buildscript snippet, in Minotaur 2.0.0, can be written as the following:

modrinth {
+  projectId = 'AABBCCDD'
+  versionName = "[$project.minecraft_version] Mod Name $project.version"
+  releaseType = 'alpha'
+  changelog = project.changelog
+  uploadFile = remapJar
+  gameVersions = ['1.18', '1.18.1', '1.18.2']
+  loaders = ['fabric']
+  dependencies = [
+          new ModDependency('P7dR8mSH', 'required') // Creates a new required dependency on Fabric API
+  ]
+}
+

Notice how it's now in a modrinth {...} block instead of creating a new task. The modrinth task is automatically created.

The loaders declaration in the new version isn't even needed if you're using Fabric Loom or ForgeGradle. The project version can be detected automatically, and the token uses the MODRINTH_TOKEN environment variable by default now. The game version and loader listings actually make sense now, and dependencies are possible!

More miscellanea

Along with the major headlining features, there are also a number of smaller features, fixes, and improvements coming with the big update. Most of these need no more than a bullet to describe, so here's a bullet list of the smaller things!

  • If you are the owner of a project, you can now transfer the ownership to another user, as long as they have already accepted an invitation to be a member. In the frontend, this can be done on the Settings page, under the "Team members" section.
  • iframes for YouTube videos are now allowed. Any iframes from elsewhere are still not allowed.
  • Files are now validated to ensure they contain a valid Forge or Fabric mod, or are in the correct modpack format.
  • When changing the status of a project, file moderators are now able to add a message (heading and body separate) to be seen by project team members.
  • Versions must now always have a file attached.
  • Projects will only be able to have draft status if they contain no versions. Additionally, a new archived status has been added.
  • Donation URLs have been re-enabled.
  • Fix: Markdown checkboxes will no longer render strangely (knossos#291)
  • Fix: Maven will no longer randomly break (labrinth#264 and labrinth#252)
  • ...and many other smaller things!

What happened to modpacks?

We've been teasing modpacks for a long time now. While they're done for the most part, we've decided to hold back on their release for the time being. We're working hard to get those done some time soon, and there'll be another post when those are ready for general consumption.

Conclusion and a call for developers

In conclusion, we hope that you're excited about this update as much as we are. We believe that, with how much work has been put into this update, it has definitely been worth the wait.

On a separate note, are you looking to contribute to Modrinth? Have you got experience with Rust or Svelte? We're hiring! Please reach out to Geometrically#8387 on Discord to apply for a position.

Thank you for reading, and may your dreams be filled with pineapples, tiny potatoes, and squirrels.

` diff --git a/packages/blog/compiled/redesign.ts b/packages/blog/compiled/redesign.ts new file mode 100644 index 000000000..36d1b677c --- /dev/null +++ b/packages/blog/compiled/redesign.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./redesign.content`).then((m) => m.html), + title: 'Now showing on Modrinth: A new look!', + summary: 'Releasing many new features and improvements, including a redesign!', + date: '2022-02-27T00:00:00.000Z', + slug: 'redesign', + authors: ['6plzAzU4'], + thumbnail: true, +} diff --git a/packages/blog/compiled/skins_now_in_modrinth_app.content.ts b/packages/blog/compiled/skins_now_in_modrinth_app.content.ts new file mode 100644 index 000000000..557550755 --- /dev/null +++ b/packages/blog/compiled/skins_now_in_modrinth_app.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

We're thrilled to roll out Modrinth App v0.10 with a beta release of one of our most highly requested features, the Skins page. The Skins page allows you to manage all of your Minecraft skins directly within Modrinth App. You can see all your saved custom skins and the default Minecraft skins in one convenient place.

The new skins page, featuring a cute animated player model, your custom skins & default skins.

Adding a new skin is simple, even Herobrine could do it! When you add or edit a skin, you can upload your custom texture file directly from your computer, choose between the wide or slim arm style to match your preferred character model, and even assign a specific cape to that look for the perfect finishing touch.

The interface makes it easy to preview your changes in real-time with the animated player model, so you can see exactly how your skin will look in-game before saving it.

The edit skin modal that shows when you go to add or edit a skin.

Fixes and More!

Alongside this major new feature, v0.10 includes a host of improvements and bug fixes to make your experience smoother. We've updated the news feed to use our new system, fixed issues with project descriptions, and tidied up how data is handled. For a full breakdown of all the changes, you can check out the complete changelog here.

As the skins feature is in beta, we're eager to hear your feedback! Jump in, give it a try, and let us know what you think. You can share your thoughts on our Discord server or start a support chat if you're running into issues.

Thank you! We can't wait to see your skins in action. Happy customizing!

` diff --git a/packages/blog/compiled/skins_now_in_modrinth_app.ts b/packages/blog/compiled/skins_now_in_modrinth_app.ts new file mode 100644 index 000000000..66cdd264b --- /dev/null +++ b/packages/blog/compiled/skins_now_in_modrinth_app.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./skins_now_in_modrinth_app.content`).then((m) => m.html), + title: 'Skins — Now in Modrinth App!', + summary: + 'Customize your look, save your favorite skins, and swap them out in a flash, all within Modrinth App.', + date: '2025-07-06T23:45:00.000Z', + slug: 'skins-now-in-modrinth-app', + authors: ['bOHH0P9Z', 'Dc7EYhxG'], + thumbnail: true, +} diff --git a/packages/blog/compiled/two_years_of_modrinth.content.ts b/packages/blog/compiled/two_years_of_modrinth.content.ts new file mode 100644 index 000000000..45e4ee2ed --- /dev/null +++ b/packages/blog/compiled/two_years_of_modrinth.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Modrinth initially went into beta on November 30th, 2020. Just over a month ago was November 30th, 2022, marking two years since Modrinth was generally available as a platform for everyone to use. Today, we're proud to announce the Anniversary Update, celebrating both two years of Modrinth as well as the coming of the new year, and we'll be discussing our New Year's Resolutions for 2023.

Before you read this post, though, we recommend taking a look at our retrospective on Modrinth's history through 2020—2022. It just wouldn't be right to take a look at the present and the future without also taking a look at our past, seeing how far we've come from our humble beginnings.

With that out of the way, this post primarily serves to announce a few of the smaller features we've been working on after the release of creator monetization. We've bundled these all together as the Anniversary Update.

Looking just at what's already done is boring, though, so we'll also be looking at what's yet to come. Modrinth's future is even brighter than any of us can imagine, so we'll be focusing on what we're gonna do in order to get to that bright future. If you've ever made New Year's Resolutions, we're going to briefly discuss our resolutions for 2023.

Without further adieu, let's get right into what's new with this new year!

Shader packs and data packs

The long-awaited arrival of shader packs and data packs is now here on Modrinth!

Shader packs can be viewed in the shaders tab. This includes shaders that support Iris, Canvas, and OptiFine, as well as vanilla core shaders. (Even though they're installed via the resource pack system, we have decided to put Canvas and core shaders in shader packs since most users will not search in resource packs for shader packs, even if that's how they're installed.)

Data packs can be found in the data packs tab. These are implemented similarly to plugins, in that projects with a mod version can also upload a data pack version (and vice versa). Additionally, data pack authors can choose to have their data packs packaged as a mod using the handy-dandy button on the site.

Data packs can optionally upload a corresponding resource pack as a separate file. We discourage bundling data files and asset files in the same zip file.

New landing page

The homepage has been completely remade, featuring a scrolling list of random projects from Modrinth. Feel free to use this to discover new projects—just make sure you refresh occasionally, because they loop after a little while until you refresh!

A screenshot of the new homepage, with a maze background and projects scrolling across the bottom. Bold across the front is "The place for Minecraft mods".

Project overhaul for creators

We're continuing to bring expansions to the creator dashboard introduced with monetization. The new Projects tab allows you to view all of your projects in a table and quickly access their information and settings.

The new Modrinth project dashboard

The same page also introduces the ability to bulk-edit the external resource links without having to edit each page individually. For example, if your Discord invite expires, you used to have to edit each of your projects individually to add it back. Now you can just select the projects you want to edit the links for and edit them all at the same time!

A modal with several input fields for external resource links, listing multiple projects the input changes will be applied to.

Even better are the changes to the settings page for individual projects. Previously, the project settings page was disorganized and cluttered. The project settings page has been completely redone, inspired by GitHub's repository settings page.

The new project settings page, shown for Sodium.

Draft projects also now have a publishing checklist, making it more clear to authors as to what their next steps should look like. Red asterisks are items that must be completed before submitting and purple light bulbs are suggestions.

A card with several tasks for a draft project owner to do, such as adding a description and selecting the necessary information.

Version page overhaul

The layout of the individual version page has gotten a complete overhaul. It's much easier to just show the new UI in action rather than trying to explain it!

A screenshot of the way that individual versions look now:

A screenshot of the way that individual versions look now.

That's not all, though. Version creation now automatically infers most details after you upload your first file. Try it out sometime—whenever you upload your first file, most stuff should already be filled in. This system is still in-development, so if you find any issues, please file an issue on GitHub.

Project card views

Anywhere which lists projects, namely search and user pages, have gotten a great overhaul. You can choose between the classic list view, the grid view, and the gallery view.

A screenshot of the default view for the Modrinth shaders search.

By default, shader packs and resource packs use the gallery view, user pages use the grid view, and everywhere else use the list view. You can cycle through them near the top of each page or change them in your display settings.

The gallery image uses the featured gallery image on a project, so please ensure if you are a shader pack or resource pack author that you set a featured gallery image!

Gallery image UI for creators

The existing UI for gallery image creation, editing, and deletion was flawed in many ways, so we threw out the old way of doing it and created a whole new system for this. It should be less prone to the many many bugs that plagued the previous implementation.

The new gallery image editing UI, in a modal

New project webhook

Our Discord server has a brand new channel: #new-projects. A webhook sends a message to this channel every time a new project gets approved. Check it out when you get a chance!

A screenshot of the new project webhook for Iris Shaders.

Miscellaneous additions

  • Custom SPDX license identifiers can now be selected, and a license's text is now displayed in a modal if the author has not manually set a license link.
  • Each project now has a color associated with it, generated from the icon. This color is used in place of a gallery image in search if the project has no gallery image.
  • The bug with disappearing and duplicated versions due to the reuse of version numbers is now fixed.
  • Whenever a project gets its status updated (for example from under review to approved), the project's team members will now get a notification.
  • The ability to manually reorder gallery images has been added via an integer ordering field. In the future, this sorting ability may expand to team members and versions. We also hope to add a drag-and-drop functionality similar to Discord server organization.
  • You can also now formally request that your project be marked as unlisted, private, or archived instead of always having it be listed first.
  • The ability to schedule the release of projects and versions has been added to the backend and is likely to be added to the frontend in the next few weeks.
  • Several other bug fixes and minor features, mainly contributed by community members.

New Year's Resolutions

Now that we've looked at everything accomplished over the past month and a half, let's take a look at our New Year's Resolutions—things we wish to achieve during 2023.

Theseus Launcher

During 2023, our main focus will shift to the Modrinth launcher, code-named Theseus. Progress has been off and on for the past year and a half, but we intend to fully launch it before the end of the year. Theseus will bring a next-level experience to Minecraft launchers, bringing first-class support for Modrinth and unique features that would be difficult for other providers to parallel.

The release of the Theseus project will also mark the end of the "alpha" status for Modrinth modpacks. Stay tuned for more information about alpha tests and early adopters programs!

Continuing to grow creator tools

Another one of our focuses for this year is to put more work into our analytics system and in growing creator monetization through our Adrinth ad network. As of today, monetization is now out of beta, but we are still constantly working on ways to make Modrinth even better and easier to use for new and returning creators. Some of these improvements are big, like the project settings overhaul, while others are more subtle quality-of-life improvements, like the fixes to usage of duplicate version numbers.

API changes

This year, Modrinth hopes to introduce version 3 of our API with lots of fixes and smaller changes. While our plans are still work-in-progress for this, one of the things that needs to be done first is the removal of the old API v1, which was deprecated starting in January 2022. Here's our planned timeline for the removal of API v1:

  • January 7th, 2023 (Now): Begin sending messages to existing API v1 users
  • January 7th, 2023 (Now): Add a field to each API result telling people to switch
  • February 14th, 2023: Begin doing flickers of 5-10 minutes of 410 GONE response codes
  • March 1st, 2023: Begin sending a permanent 410 GONE response for any non-GET routes
  • March 1st, 2023: Ramp up 410 GONE flickers to last 6-12 hours for GET routes
  • March 15th, 2023: Replace all remaining GET routes with a permanent 410 GONE response

Small updates throughout the year

As always, we will be interspersing other, smaller quality-of-life updates throughout the year even as we work on the big stuff. We also want to fix any bugs which might come up alongside any updates.

Conclusion

Modrinth was founded with the goal of creating a platform which keeps the broader modding community's interests at heart. Modrinth would not exist without the support of our users and of our contributors, and we thank everyone involved immensely for everything. Modrinth's development shall continue as long as the community is willing to support us on the way!

We would love to hear any feedback you might have. Feel free to get in contact on Discord, on Twitter, and on Mastodon.

` diff --git a/packages/blog/compiled/two_years_of_modrinth.ts b/packages/blog/compiled/two_years_of_modrinth.ts new file mode 100644 index 000000000..828c7ff05 --- /dev/null +++ b/packages/blog/compiled/two_years_of_modrinth.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./two_years_of_modrinth.content`).then((m) => m.html), + title: "Modrinth's Anniversary Update", + summary: "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.", + date: '2023-01-07T00:00:00.000Z', + slug: 'two-years-of-modrinth', + authors: ['6plzAzU4'], + thumbnail: true, +} diff --git a/packages/blog/compiled/two_years_of_modrinth_history.content.ts b/packages/blog/compiled/two_years_of_modrinth_history.content.ts new file mode 100644 index 000000000..f80ef901d --- /dev/null +++ b/packages/blog/compiled/two_years_of_modrinth_history.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Let's rewind a bit and take a look at the past two years of Modrinth's history. We've come so far from our pre-beta HexFabric days to today. A good portion of our pre-beta history can be found in the What is Modrinth blog post, but Modrinth obviously is not the same platform it was two years ago.

December 2020: Modrinth Beta begins

Modrinth's brand new design, rolling out with the launch of Beta

Modrinth's brand new design, rolling out with the launch of Beta

December was the release of the initial Modrinth Beta, bringing a completely different interface and the ability for mods to be created for the first time. This interface has since been completely discarded, but this is what Modrinth looked like for well over a year. It's hard to believe!

December also brought the introduction of the Minotaur Gradle plugin for the first time for upload automation. Minotaur today also looks nothing like it did when it was introduced, but it still accomplishes the same exact thing.

January 2021: Improvements to mod uploading

An announcement in mid-January brought several essential additions and improvements to Modrinth which we consider commonplace today. Among these were:

  • A separate version creation page
  • The ability to edit and delete existing versions
  • The ability to delete existing mods and users

January also brought the introduction of Google AdSense onto Modrinth. The eventual results, including our switch after to EthicalAds, solidified Modrinth's stance that ads should be unobtrusive and generally friendly to users.

February-March 2021: Follows, reports, notifications, oh my!

March brought the first introduction of the abilities to follow and report projects, as well as the automatic featuring of some versions depending on loader and Minecraft version. These systems have largely remained unchanged since their introduction, though the notification system will likely be getting a refresh come 2023.

April-December 2021: Season of silence

After follows, reports, and all that jazz, Modrinth largely went silent for a good while. This time period had some of the largest growth Modrinth had ever seen, and yet it seemed Modrinth's development had ground to a halt. Modrinth Team members were dropping like flies until there was a point where there was a single person on the team. What happened?

For various reasons, whether it be lack of free time or a lack of interest in Minecraft in general, people ended up leaving to pursue other things. It's not quite as apocalyptic or barren as these descriptions make it seem, but it's more fun to describe it like this.

Picking up the remnants from what others had left behind, one man was destined to continue developing for Modrinth. The one who began the whole operation in the first place, Geometrically, stood up and began developing. Thus came the development of project types, gallery images, API v2, and modpacks.

January 2022: API v2 introduction

Right around the corner came 2022. Perchance this would be the time for the silence to be broken? Indeed, the world would be able to hear about all that was brewing over the past few months.

Of course, this wasn't all introduced at once—it was a gradual rollout over several months. First was the introduction of v2 of Modrinth's API, allowing many breaking changes to occur, including namely the renaming of mods to projects. Wait, why was this necessary?

Up until this point, Modrinth only hosted mods. Project types, as we call them, allow projects to be given the designation of something other than mod; for example, modpack or resourcepack, like we have today. This simple field, alongside all of the infrastructure which was needed to support it, was the first step to allow modpacks on Modrinth.

February 2022: Redesign

Remember the interface introduced in December 2020? Let's scrap it! Actually, it wasn't entirely scrapped, but it got a treatment similar to the Ship of Theseus to the point that it was barely recognizable.

The Modrinth homepage

The former Modrinth homepage

Alongside this was the official announcement of API v2, as well as the introduction of the project gallery, the changelog tab, dependencies, and many other things. Here's the blog post announcement for the redesign!

February also brought the introduction of several new Modrinth Team members to the fold, including Prospector and triphora, both of whom are still on the team, alongside Hutzdog and venashial, who we thank for helping us through much of 2022.

March-April 2022: Small changes and preparation for modpacks

A couple weeks after the redesign we pushed out some changes which included improvements to several tabs on project pages and many bug fixes. The blog post from that can be found here.

The next couple months were spent preparing for the release of modpacks. This is the first introduction of our "early adopters" program, still in use today, allowing a feedback loop of authors and other community members to create the best product that we can. Without early adopters, many of the features on Modrinth which you've come to love, including modpacks, plugins, resource packs, would be less than ideal.

May 2022: Modpacks in alpha

In May, we finally did the big release of modpacks on Modrinth. Well, in alpha, anyway—but that was less of a marker of instability and more a marker of being incomplete without the launcher. The modpack alpha release blog post can be found here.

When we first announced modpacks, the initial format had been set in stone for a couple years, and it had been decided that CurseForge links would be allowed within them. This got turned on its head due to an email sent to us by Overwolf. More information on that can be found on the Changes to Modrinth Modpacks blog post.

Progryck

June-August 2022: Plugins and resource packs

The summer of 2022 was largely dedicated to working on releasing creator monetization. First, though, we made a pit stop to introduce plugins and resource packs to Modrinth. Find that blog post here.

Plugins in particular were tricky since we had to account for projects which had both mod and plugin versions. It was at this point we realized that the project type system isn't entirely what we cracked it up to be, and we're hoping to completely replace it once API v4 rolls around, as far away as that may sound. For now, though, it will suffice.

September-November 2022: Creator monetization

With plugins and resource packs done, we continued working on creator monetization. This included a brief experiment with a different ad provider before we eventually switched to creating our own ad system.

November brought the actual beta release of creator monetization—here's the blog post for that. We are continuing to develop and refine this system to ensure authors continue to earn money from publishing projects on Modrinth.

December 2022-January 2023: Anniversary Update

That, of course, brings us to today's Anniversary Update! Now that you're done reading this, feel free to go back over to that post and read about everything that's new in the Anniversary Update so that I don't have to repeat myself. Take a look at our New Year's Resolutions for 2023 while you're at it, too!

` diff --git a/packages/blog/compiled/two_years_of_modrinth_history.ts b/packages/blog/compiled/two_years_of_modrinth_history.ts new file mode 100644 index 000000000..9458ebbb8 --- /dev/null +++ b/packages/blog/compiled/two_years_of_modrinth_history.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./two_years_of_modrinth_history.content`).then((m) => m.html), + title: 'Two years of Modrinth: a retrospective', + summary: 'The history of Modrinth as we know it from December 2020 to December 2022.', + date: '2023-01-07T00:00:00.000Z', + slug: 'two-years-of-modrinth-history', + authors: ['6plzAzU4'], + thumbnail: false, +} diff --git a/packages/blog/compiled/whats_modrinth.content.ts b/packages/blog/compiled/whats_modrinth.content.ts new file mode 100644 index 000000000..3e07d1ee9 --- /dev/null +++ b/packages/blog/compiled/whats_modrinth.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

Hello, we are Modrinth – an open source mods hosting platform. Sounds dry, doesn't it? So let me tell you our story – and I promise, it won't be boring!

Prelude and conception

Before Modrinth was even thought of, there already were several giant platforms for mod hosting. However, most of them were too specialized, outdated, or transitively dependent on an uncaring hegemonic 3rd party. Authors and players were always in struggle. The community had to choose 2 out of 3: inconvenience, indifference, obsolescence. Urge for better service, either new or renewed, just founded or acquired arose.

Although demand for proper competition is the seed, the germ of Modrinth, the biggest role was played by the Fabric project. It set an example of a community-powered alternative. It was democratic, FOSS, listening to the community, and welcoming contribution and 3rd party initiatives. They have shown the modding community that they can evolve and adapt, be accessible and welcoming, cooperative, and caring.

Fabricate and HexFabric

And, oh boy, did they connect – the demand for competition grew so high, that at some point the community just exploded with novelty. During several months, almost a dozen projects were aiming to be the second Walmart, the third IKEA, the fourth Amazon for your mods. Here beings the story of HexFabrics... – wait, what? What's that?

HexFabric is an umbrella term for modern mod hosting technology. It got its name from Fabric, which at the point was poorly supported (if at all) by the major players on the stage. In practice, HexFabric is just a cozy Discord server, on which several projects have their deputizing channels.

Back on track – Lots of HexFabrics were founded almost simultaneously. Altar.gg, Astronave, Diluv, ModForest, Minerepo... and, most importantly, Fabricate.

Fabricate began its journey as a proprietary project indexing website by a single developer – Geometrically. It remained relatively unnoticed for a couple of weeks, and then it started gaining attention. This new website has amazing search! Yup, the whole thing was primarily about making seamless, gracious, appeasing smart real-time search. The community is now intrigued.

Becoming a team

"But this looks awful! And it's proprietary!" – a few voices said. Among those voices were falseresync and MulverineX. They both had several objections to that and were pestering the original author. "FOSS is the true way for community project" and "Just use a license to prevent others from creating instances of your work," they told.

Yet Fabricate remained proprietary for a while. However, once the pressure on the author became high, they gave up and open-sourced their work. This was the birth of Modrinth. It did not get its name for a little while longer though.

Now that Modrinth was open source, it started gaining traction. Remember falseresync and MulverineX? They joined Geometrically on the branding site, and somewhere in the middle of the brainstorming process the logo and the name were born. At the same time AppleTheGolden, Aeledfyr, and Redblueflame began contributing to the actual code of the project, which is – nowadays known to everyone – in Rust. A solo suddenly became a team, ready for whatever future holds.

Development non-stop

The newly born FOSS project is now evolving swiftly. Before our team arose the question: monolithic vs split app architecture. Monolithic would be easier to deploy and can serve pages quicker. The split architecture will simplify the development and allow for a feature-full user experience. The discussion was hot, and the sides were fierce. Nevertheless, the split pattern won. Now it was time to make proper backend and frontend apps.

The work first began with the backend. Aeled, Red, and Geo started detaching API methods from visuals. The team worked hard. Consequent to the API splitting from the GUI, it became getting new and exciting features. The first feature to be added was custom Modrinth mods – before that, the website only indexed the competitor's service.

However, for that to happen there had to be another step taken – migration from MongoDB to PostgreSQL. It was crucial for efficient data storage and complex relationships between projects. And the biggest propagator of that change was Apple, who introduced and successfully defended their case.

Thus, with custom mods, better yet search has been implemented. After search, user accounts with external log-in made their way into the project. Now it first creators started uploading their mods – a monumental achievement.

After the first creators came more – the community began taking Modrinth as a serious alternative hosting. At some point, uploads accelerated to the point that our team was forced to redo their plans and establish project editing and moderation considerably earlier than it could have been. Besides, creators need analytics, they need teams, they needed support system. So the backend developers tried their best to keep up and achieved their goals through enthusiastic labor and dedication.

Refreshed look

Although, on the frontend side things weren't as bright, unfortunately. Once falseresync presented the new look and feel Modrinth should aim for, he was forced to dedicate less time to the project. As a consequence, the frontend was implemented rather haphazardly and was lacking in features compared to the backend.

However, this did not stop the project from evolving. The backend team has continued to expand on existing features, and after a long period of time, the savior descended on the frontend – Prospector, who rapidly became a crucial contributor and a part of the team. With new and comprehensive design guidance from falseresync and critique from MulverineX and the community, Prospector achieved feature parity with backend and greatly improved the website look and feel.

Improving the frontend wasn't an easy job: naughty CSS, runtime errors, the abundance of framework-related nuances – all were obstacles, and all were defeated. Through battles with web technologies, jokes about quirky styles, and hard work our team created the UI you see today.

Going beta

You have to believe that the dots will somehow connect in your future. – Steve Jobs

With the story complete, we are proud to announce that the Modrinth beta will be coming out on November 30th, with a refreshed look and a feature-complete modding website! It is a tremendous achievement for us and the community, which we are very proud of.

It is heart-warming to admit that we're finally going officially online. We know it's not perfect yet. But regardless, we will continue our passion project as a team, and we will expand on it and make it only better!

Stay tuned!

` diff --git a/packages/blog/compiled/whats_modrinth.ts b/packages/blog/compiled/whats_modrinth.ts new file mode 100644 index 000000000..133ad0249 --- /dev/null +++ b/packages/blog/compiled/whats_modrinth.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./whats_modrinth.content`).then((m) => m.html), + title: 'What is Modrinth?', + summary: + "Hello, we are Modrinth – an open source mods hosting platform. Sounds dry, doesn't it? So let me tell you our story – and I promise, it won't be boring!", + date: '2020-11-27T00:00:00.000Z', + slug: 'whats-modrinth', + authors: ['aNd6VJql'], + thumbnail: false, +} diff --git a/packages/blog/compiled/windows_borderless_malware_disclosure.content.ts b/packages/blog/compiled/windows_borderless_malware_disclosure.content.ts new file mode 100644 index 000000000..7eb6c1eb3 --- /dev/null +++ b/packages/blog/compiled/windows_borderless_malware_disclosure.content.ts @@ -0,0 +1,2 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const html = `

This is a disclosure of a malicious mod discovered to be hosted on the Modrinth platform. It is important to not panic or jump to conclusions, please carefully read the Am I Affected? and Threat Summary sections.

Am I Affected?

If you run Windows and have downloaded a mod called "Windows Borderless" (specific files listed below) between May 4th, 2024 and May 6th, 2024 and have run the game with the mod installed, you are affected.

IMPORTANT NOTE: This mod is called, exactly, "Windows Borderless". There are other mods with similar names on Modrinth, which are NOT malware, such as "Borderless Mining", "Borderless", and "Borderless Mining Reworked".

If you have not downloaded that mod or do not run Windows, there is no reason to believe you are at any risk. We have released a detection tool available here which can scan your mods folder for the malicious files if you wish to make sure your instance does not have the mod. The tool is also open-source.

Download and run the detection tool here!

What do I do if I have used the "Windows Borderless" mod?

First, delete the mod entirely from your computer.

The mod harvested data stored by many Chromium-based projects such as Google Chrome, Discord, Microsoft Edge, and many other browsers such as Opera/Opera GX, Vivaldi, Brave, Firefox and over dozen more. Included in this data may be account tokens, stored passwords, banking information, addresses, and more.

In order to protect yourself, change all of your passwords, and keep an eye out on your bank accounts and credit cards.

Threat Summary

Exposure level: Low, ~372 distinct IPs downloaded affected files. One Discord account is alleged to have been stolen due to this.

Malware severity: Medium (Discord, browser, and system info stealer, but does not self-replicate)

Projects affected:

NameProject IDFormer URL
Windows BorderlessZQpQzwWEhttps://modrinth.com/mod/windowsborderless

Files affected:

NameSHA1 HashVersion IDDownload count
windowedborderless-v0.2 - 1.20.4.jar179b5da318604f97616b5108f305e2a8e4609484NkTbhEmf116
windowedborderless-v0.3 - 1.20.4.jar1a1c4dcae846866c58cc1abf71fb7f7aa4e7352av87dk8Q715
windowedborderless-v0.4 - 1.20.+.jare4d55310039b965fce6756da5286b481cfb09946pVfdgPhy68
windowedborderless-v0.4 - 1.20.+.jar2f47e57a6bedc729359ffaf6f0149876008b5cc3Wt4RjZ49119
windowedborderless-v0.4.1_-_1.20.+.jar2f47e57a6bedc729359ffaf6f0149876008b5cc3oIlYelrb1

None of these files were included in any modpacks on the Modrinth platform, so you are only at risk if you downloaded the mod directly.

Timeline

April 29th, 2:39pm - Project submitted for review as a legitimate mod

The Modrinth project "Windows Borderless" is submitted for review with a single file uploaded that does not contain any malware.

April 30th, 12:15am - Modrinth moderators approve the project

The "Windows Borderless" project is approved with only one file, which contained no malware.

May 2nd, 3:50am - New version containing malware is published

A "Windows Borderless" version containing the file windowedborderless-v0.2 - 1.20.4.jar (mentioned in the table of affected files above) is published. This initial version of the malware did not include any credential or token stealing, but only sent identifying information about a user’s machine to a discord webhook.

May 4th, 4:01pm thru May 6th, 3:46am - More versions are uploaded

Between May 4th, 4:01pm and May 6th, 3:46am, more new versions of the mod containing the malware were uploaded. These versions all contain credential and token stealers.

May 6, 2024 @ 7:21am - A Modrinth user reports the project

A user submits a report against the mod, alleging that their Discord account got compromised after using the mod.

May 6, 2024 @ 10:37am - Modrinth moderators investigate the project

The mod is investigated by Modrinth staff. We decompiled the mod and discovered that the mod contained malicious code. The threat is immediately obvious, so within a few minutes we take down the project, all CDN links related to the project, and all other projects by the same users.

Conclusion

In response to this incident, we are actively developing a shared system to effectively quarantine known malicious mods by creating a web API to allow launchers to check if any of the files a user has downloaded match any of the files in our known malware database, and return up-to-date information about any known malware.

We are also in the process with working with relevant law enforcement agencies to pass along all information we have.

In order to also more proactively increase safety, we're also investigating possible methods of sandboxing or algorithmically detecting malware patterns in Java software. While these are infamously tricky to implement on certain platforms, we hope to do our best in order to ensure the best security for the modding community.

` diff --git a/packages/blog/compiled/windows_borderless_malware_disclosure.ts b/packages/blog/compiled/windows_borderless_malware_disclosure.ts new file mode 100644 index 000000000..d10209a51 --- /dev/null +++ b/packages/blog/compiled/windows_borderless_malware_disclosure.ts @@ -0,0 +1,10 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +export const article = { + html: () => import(`./windows_borderless_malware_disclosure.content`).then((m) => m.html), + title: 'Malware Discovery Disclosure: "Windows Borderless" mod', + summary: 'Threat Analysis and Plan of Action', + date: '2024-05-07T20:00:00.000Z', + slug: 'windows-borderless-malware-disclosure', + authors: ['Dc7EYhxG', 'MpxzqsyW'], + thumbnail: true, +} diff --git a/packages/blog/index.ts b/packages/blog/index.ts new file mode 100644 index 000000000..33a68a444 --- /dev/null +++ b/packages/blog/index.ts @@ -0,0 +1 @@ +export { articles } from './compiled' diff --git a/packages/blog/package.json b/packages/blog/package.json new file mode 100644 index 000000000..c192ad1d0 --- /dev/null +++ b/packages/blog/package.json @@ -0,0 +1,28 @@ +{ + "name": "@modrinth/blog", + "version": "0.0.0", + "private": true, + "main": "./index.ts", + "types": "./index.d.ts", + "scripts": { + "lint": "jiti ./check.ts && eslint . && prettier --check .", + "fix": "jiti ./compile.ts && eslint . --fix && prettier --write ." + }, + "devDependencies": { + "@types/html-minifier-terser": "^7.0.2", + "@types/rss": "^0.0.32", + "@types/xml2js": "^0.4.14", + "eslint": "^8.57.0", + "eslint-config-custom": "workspace:*", + "jiti": "^2.4.2", + "tsconfig": "workspace:*" + }, + "dependencies": { + "@modrinth/utils": "workspace:*", + "fast-glob": "^3.3.3", + "gray-matter": "^4.0.3", + "html-minifier-terser": "^7.2.0", + "rss": "^1.2.2", + "xml2js": "^0.6.2" + } +} diff --git a/packages/blog/public/a-new-chapter-for-modrinth-servers/thumbnail.webp b/packages/blog/public/a-new-chapter-for-modrinth-servers/thumbnail.webp new file mode 100644 index 000000000..a54d8c13c Binary files /dev/null and b/packages/blog/public/a-new-chapter-for-modrinth-servers/thumbnail.webp differ diff --git a/packages/blog/public/becoming-sustainable/abnormally-high-revenue.webp b/packages/blog/public/becoming-sustainable/abnormally-high-revenue.webp new file mode 100644 index 000000000..0fbefa983 Binary files /dev/null and b/packages/blog/public/becoming-sustainable/abnormally-high-revenue.webp differ diff --git a/packages/blog/public/becoming-sustainable/revenue.webp b/packages/blog/public/becoming-sustainable/revenue.webp new file mode 100644 index 000000000..712d5b1a8 Binary files /dev/null and b/packages/blog/public/becoming-sustainable/revenue.webp differ diff --git a/packages/blog/public/becoming-sustainable/thumbnail.webp b/packages/blog/public/becoming-sustainable/thumbnail.webp new file mode 100644 index 000000000..87c03025c Binary files /dev/null and b/packages/blog/public/becoming-sustainable/thumbnail.webp differ diff --git a/packages/blog/public/carbon-ads/thumbnail.webp b/packages/blog/public/carbon-ads/thumbnail.webp new file mode 100644 index 000000000..72937eb67 Binary files /dev/null and b/packages/blog/public/carbon-ads/thumbnail.webp differ diff --git a/packages/blog/public/creator-monetization/thumbnail.webp b/packages/blog/public/creator-monetization/thumbnail.webp new file mode 100644 index 000000000..e74257f46 Binary files /dev/null and b/packages/blog/public/creator-monetization/thumbnail.webp differ diff --git a/packages/blog/public/creator-update/collections.jpg b/packages/blog/public/creator-update/collections.jpg new file mode 100644 index 000000000..1e822763f Binary files /dev/null and b/packages/blog/public/creator-update/collections.jpg differ diff --git a/packages/blog/public/creator-update/oauth.jpg b/packages/blog/public/creator-update/oauth.jpg new file mode 100644 index 000000000..1d8d1a378 Binary files /dev/null and b/packages/blog/public/creator-update/oauth.jpg differ diff --git a/packages/blog/public/creator-update/organizations.jpg b/packages/blog/public/creator-update/organizations.jpg new file mode 100644 index 000000000..42f135c7c Binary files /dev/null and b/packages/blog/public/creator-update/organizations.jpg differ diff --git a/packages/blog/public/creator-update/payout-methods.jpg b/packages/blog/public/creator-update/payout-methods.jpg new file mode 100644 index 000000000..8e3717e73 Binary files /dev/null and b/packages/blog/public/creator-update/payout-methods.jpg differ diff --git a/packages/blog/public/creator-update/project-analytics.jpg b/packages/blog/public/creator-update/project-analytics.jpg new file mode 100644 index 000000000..a566a226f Binary files /dev/null and b/packages/blog/public/creator-update/project-analytics.jpg differ diff --git a/packages/blog/public/creator-update/thumbnail.webp b/packages/blog/public/creator-update/thumbnail.webp new file mode 100644 index 000000000..0096172d1 Binary files /dev/null and b/packages/blog/public/creator-update/thumbnail.webp differ diff --git a/packages/blog/public/creator-update/user-analytics.jpg b/packages/blog/public/creator-update/user-analytics.jpg new file mode 100644 index 000000000..a554c43ed Binary files /dev/null and b/packages/blog/public/creator-update/user-analytics.jpg differ diff --git a/packages/blog/public/creator-update/user-orgs.jpg b/packages/blog/public/creator-update/user-orgs.jpg new file mode 100644 index 000000000..fab723916 Binary files /dev/null and b/packages/blog/public/creator-update/user-orgs.jpg differ diff --git a/packages/blog/public/design-refresh/project-page.webp b/packages/blog/public/design-refresh/project-page.webp new file mode 100644 index 000000000..d422f3b41 Binary files /dev/null and b/packages/blog/public/design-refresh/project-page.webp differ diff --git a/packages/blog/public/design-refresh/thumbnail.webp b/packages/blog/public/design-refresh/thumbnail.webp new file mode 100644 index 000000000..946148fc6 Binary files /dev/null and b/packages/blog/public/design-refresh/thumbnail.webp differ diff --git a/packages/blog/public/download-adjustment/country-download-counts.jpg b/packages/blog/public/download-adjustment/country-download-counts.jpg new file mode 100644 index 000000000..067dd0e55 Binary files /dev/null and b/packages/blog/public/download-adjustment/country-download-counts.jpg differ diff --git a/packages/blog/public/download-adjustment/country-page-views.jpg b/packages/blog/public/download-adjustment/country-page-views.jpg new file mode 100644 index 000000000..2e9c1b50e Binary files /dev/null and b/packages/blog/public/download-adjustment/country-page-views.jpg differ diff --git a/packages/blog/public/download-adjustment/downloads-table.jpg b/packages/blog/public/download-adjustment/downloads-table.jpg new file mode 100644 index 000000000..d8e1fccb4 Binary files /dev/null and b/packages/blog/public/download-adjustment/downloads-table.jpg differ diff --git a/packages/blog/public/download-adjustment/new-sisyphus.jpg b/packages/blog/public/download-adjustment/new-sisyphus.jpg new file mode 100644 index 000000000..a2245e52a Binary files /dev/null and b/packages/blog/public/download-adjustment/new-sisyphus.jpg differ diff --git a/packages/blog/public/knossos-v2.1.0/download-icon.jpg b/packages/blog/public/knossos-v2.1.0/download-icon.jpg new file mode 100644 index 000000000..b75585e63 Binary files /dev/null and b/packages/blog/public/knossos-v2.1.0/download-icon.jpg differ diff --git a/packages/blog/public/knossos-v2.1.0/dropdown-caret.jpg b/packages/blog/public/knossos-v2.1.0/dropdown-caret.jpg new file mode 100644 index 000000000..8fbf864c2 Binary files /dev/null and b/packages/blog/public/knossos-v2.1.0/dropdown-caret.jpg differ diff --git a/packages/blog/public/knossos-v2.1.0/expand-gallery.jpg b/packages/blog/public/knossos-v2.1.0/expand-gallery.jpg new file mode 100644 index 000000000..8dac16332 Binary files /dev/null and b/packages/blog/public/knossos-v2.1.0/expand-gallery.jpg differ diff --git a/packages/blog/public/knossos-v2.1.0/following.jpg b/packages/blog/public/knossos-v2.1.0/following.jpg new file mode 100644 index 000000000..ffc419500 Binary files /dev/null and b/packages/blog/public/knossos-v2.1.0/following.jpg differ diff --git a/packages/blog/public/knossos-v2.1.0/styling.jpg b/packages/blog/public/knossos-v2.1.0/styling.jpg new file mode 100644 index 000000000..14ae57b60 Binary files /dev/null and b/packages/blog/public/knossos-v2.1.0/styling.jpg differ diff --git a/packages/blog/public/knossos-v2.1.0/thumbnail.webp b/packages/blog/public/knossos-v2.1.0/thumbnail.webp new file mode 100644 index 000000000..f1b92f17e Binary files /dev/null and b/packages/blog/public/knossos-v2.1.0/thumbnail.webp differ diff --git a/packages/blog/public/knossos-v2.1.0/version-filters.jpg b/packages/blog/public/knossos-v2.1.0/version-filters.jpg new file mode 100644 index 000000000..87f772c0b Binary files /dev/null and b/packages/blog/public/knossos-v2.1.0/version-filters.jpg differ diff --git a/packages/blog/public/licensing-guide/thumbnail.webp b/packages/blog/public/licensing-guide/thumbnail.webp new file mode 100644 index 000000000..cec9981e7 Binary files /dev/null and b/packages/blog/public/licensing-guide/thumbnail.webp differ diff --git a/packages/blog/public/modpack-changes/thumbnail.webp b/packages/blog/public/modpack-changes/thumbnail.webp new file mode 100644 index 000000000..110af1f00 Binary files /dev/null and b/packages/blog/public/modpack-changes/thumbnail.webp differ diff --git a/packages/blog/public/modpacks-alpha/thumbnail.webp b/packages/blog/public/modpacks-alpha/thumbnail.webp new file mode 100644 index 000000000..1e7826ca6 Binary files /dev/null and b/packages/blog/public/modpacks-alpha/thumbnail.webp differ diff --git a/packages/blog/public/modrinth-app-beta/app.jpg b/packages/blog/public/modrinth-app-beta/app.jpg new file mode 100644 index 000000000..d32e6c1de Binary files /dev/null and b/packages/blog/public/modrinth-app-beta/app.jpg differ diff --git a/packages/blog/public/modrinth-app-beta/auth.jpg b/packages/blog/public/modrinth-app-beta/auth.jpg new file mode 100644 index 000000000..d3674e521 Binary files /dev/null and b/packages/blog/public/modrinth-app-beta/auth.jpg differ diff --git a/packages/blog/public/modrinth-beta/new-design.jpg b/packages/blog/public/modrinth-beta/new-design.jpg new file mode 100644 index 000000000..98d8d6093 Binary files /dev/null and b/packages/blog/public/modrinth-beta/new-design.jpg differ diff --git a/packages/blog/public/modrinth-beta/thumbnail.webp b/packages/blog/public/modrinth-beta/thumbnail.webp new file mode 100644 index 000000000..566f979ff Binary files /dev/null and b/packages/blog/public/modrinth-beta/thumbnail.webp differ diff --git a/packages/blog/public/modrinth-servers-beta/panel.jpg b/packages/blog/public/modrinth-servers-beta/panel.jpg new file mode 100644 index 000000000..4a2c3a583 Binary files /dev/null and b/packages/blog/public/modrinth-servers-beta/panel.jpg differ diff --git a/packages/blog/public/modrinth-servers-beta/thumbnail.webp b/packages/blog/public/modrinth-servers-beta/thumbnail.webp new file mode 100644 index 000000000..340baa69e Binary files /dev/null and b/packages/blog/public/modrinth-servers-beta/thumbnail.webp differ diff --git a/packages/blog/public/new-site-beta/thumbnail.webp b/packages/blog/public/new-site-beta/thumbnail.webp new file mode 100644 index 000000000..75a547070 Binary files /dev/null and b/packages/blog/public/new-site-beta/thumbnail.webp differ diff --git a/packages/blog/public/plugins-resource-packs/thumbnail.webp b/packages/blog/public/plugins-resource-packs/thumbnail.webp new file mode 100644 index 000000000..89f039335 Binary files /dev/null and b/packages/blog/public/plugins-resource-packs/thumbnail.webp differ diff --git a/packages/blog/public/pride-campaign-2025/thumbnail.webp b/packages/blog/public/pride-campaign-2025/thumbnail.webp new file mode 100644 index 000000000..455d884ef Binary files /dev/null and b/packages/blog/public/pride-campaign-2025/thumbnail.webp differ diff --git a/packages/blog/public/redesign/adorn.jpg b/packages/blog/public/redesign/adorn.jpg new file mode 100644 index 000000000..cb516746a Binary files /dev/null and b/packages/blog/public/redesign/adorn.jpg differ diff --git a/packages/blog/public/redesign/consistency.jpg b/packages/blog/public/redesign/consistency.jpg new file mode 100644 index 000000000..5e5d29f5b Binary files /dev/null and b/packages/blog/public/redesign/consistency.jpg differ diff --git a/packages/blog/public/redesign/iris.jpg b/packages/blog/public/redesign/iris.jpg new file mode 100644 index 000000000..5d5f2c522 Binary files /dev/null and b/packages/blog/public/redesign/iris.jpg differ diff --git a/packages/blog/public/redesign/jellysquid.jpg b/packages/blog/public/redesign/jellysquid.jpg new file mode 100644 index 000000000..7cf593b62 Binary files /dev/null and b/packages/blog/public/redesign/jellysquid.jpg differ diff --git a/packages/blog/public/redesign/notifications.jpg b/packages/blog/public/redesign/notifications.jpg new file mode 100644 index 000000000..12d71a50a Binary files /dev/null and b/packages/blog/public/redesign/notifications.jpg differ diff --git a/packages/blog/public/redesign/profile-settings.jpg b/packages/blog/public/redesign/profile-settings.jpg new file mode 100644 index 000000000..a0e7f3e20 Binary files /dev/null and b/packages/blog/public/redesign/profile-settings.jpg differ diff --git a/packages/blog/public/redesign/thumbnail.webp b/packages/blog/public/redesign/thumbnail.webp new file mode 100644 index 000000000..56125a1a4 Binary files /dev/null and b/packages/blog/public/redesign/thumbnail.webp differ diff --git a/packages/blog/public/redesign/version-creation.jpg b/packages/blog/public/redesign/version-creation.jpg new file mode 100644 index 000000000..c45109ccd Binary files /dev/null and b/packages/blog/public/redesign/version-creation.jpg differ diff --git a/packages/blog/public/skins-now-in-modrinth-app/edit-skin.webp b/packages/blog/public/skins-now-in-modrinth-app/edit-skin.webp new file mode 100644 index 000000000..02cd9c75d Binary files /dev/null and b/packages/blog/public/skins-now-in-modrinth-app/edit-skin.webp differ diff --git a/packages/blog/public/skins-now-in-modrinth-app/skins-page.webp b/packages/blog/public/skins-now-in-modrinth-app/skins-page.webp new file mode 100644 index 000000000..d9a1cac7c Binary files /dev/null and b/packages/blog/public/skins-now-in-modrinth-app/skins-page.webp differ diff --git a/packages/blog/public/skins-now-in-modrinth-app/thumbnail.webp b/packages/blog/public/skins-now-in-modrinth-app/thumbnail.webp new file mode 100644 index 000000000..26ccf49cf Binary files /dev/null and b/packages/blog/public/skins-now-in-modrinth-app/thumbnail.webp differ diff --git a/packages/blog/public/two-years-of-modrinth/bulk-edit.jpg b/packages/blog/public/two-years-of-modrinth/bulk-edit.jpg new file mode 100644 index 000000000..5443d90da Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/bulk-edit.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/gallery-ui.jpg b/packages/blog/public/two-years-of-modrinth/gallery-ui.jpg new file mode 100644 index 000000000..f2bb2493d Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/gallery-ui.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/landing-page.jpg b/packages/blog/public/two-years-of-modrinth/landing-page.jpg new file mode 100644 index 000000000..0dfca6b26 Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/landing-page.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/project-settings.jpg b/packages/blog/public/two-years-of-modrinth/project-settings.jpg new file mode 100644 index 000000000..af3dca3c6 Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/project-settings.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/project-webhook.jpg b/packages/blog/public/two-years-of-modrinth/project-webhook.jpg new file mode 100644 index 000000000..184b93aba Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/project-webhook.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/projects-dashboard.jpg b/packages/blog/public/two-years-of-modrinth/projects-dashboard.jpg new file mode 100644 index 000000000..11981054b Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/projects-dashboard.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/publishing-checklist.jpg b/packages/blog/public/two-years-of-modrinth/publishing-checklist.jpg new file mode 100644 index 000000000..d8de372fc Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/publishing-checklist.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/search-gallery-view.jpg b/packages/blog/public/two-years-of-modrinth/search-gallery-view.jpg new file mode 100644 index 000000000..fe218292d Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/search-gallery-view.jpg differ diff --git a/packages/blog/public/two-years-of-modrinth/thumbnail.webp b/packages/blog/public/two-years-of-modrinth/thumbnail.webp new file mode 100644 index 000000000..af2b32cd1 Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/thumbnail.webp differ diff --git a/packages/blog/public/two-years-of-modrinth/version-page.jpg b/packages/blog/public/two-years-of-modrinth/version-page.jpg new file mode 100644 index 000000000..77841c8a7 Binary files /dev/null and b/packages/blog/public/two-years-of-modrinth/version-page.jpg differ diff --git a/packages/blog/public/windows-borderless-malware-disclosure/thumbnail.webp b/packages/blog/public/windows-borderless-malware-disclosure/thumbnail.webp new file mode 100644 index 000000000..692e56589 Binary files /dev/null and b/packages/blog/public/windows-borderless-malware-disclosure/thumbnail.webp differ diff --git a/packages/blog/tsconfig.json b/packages/blog/tsconfig.json new file mode 100644 index 000000000..92e7710b9 --- /dev/null +++ b/packages/blog/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/base.json", + "include": [".", ".eslintrc.js"], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "lib": ["esnext", "dom"], + "noImplicitAny": false + } +} diff --git a/packages/blog/utils.ts b/packages/blog/utils.ts new file mode 100644 index 000000000..06d1d587b --- /dev/null +++ b/packages/blog/utils.ts @@ -0,0 +1,40 @@ +import { execSync } from 'child_process' +import * as path from 'path' + +let REPO_ROOT_CACHE: string | null = null +export function getRepoRoot(): string { + if (REPO_ROOT_CACHE) return REPO_ROOT_CACHE + return (REPO_ROOT_CACHE = execSync('git rev-parse --show-toplevel').toString().trim()) +} + +export function repoPath(...segments: string[]): string { + return path.join(getRepoRoot(), ...segments) +} + +export async function copyDir( + src: string, + dest: string, + logFn: (src: string, dest: string) => void = () => {}, +): Promise { + const { promises: fs } = await import('fs') + await fs.mkdir(dest, { recursive: true }) + const entries = await fs.readdir(src, { withFileTypes: true }) + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + if (entry.isDirectory()) { + await copyDir(srcPath, destPath, logFn) + } else if (entry.isFile()) { + await fs.copyFile(srcPath, destPath) + logFn(srcPath, destPath) + } + } +} + +export function toVarName(file: string): string { + return file + .replace(/\.md$/, '') + .replace(/[^a-zA-Z0-9]/g, '_') + .replace(/^_+/, '') + .replace(/_+$/, '') +} diff --git a/packages/daedalus/Cargo.toml b/packages/daedalus/Cargo.toml index 014ce6f83..0b2bbdd83 100644 --- a/packages/daedalus/Cargo.toml +++ b/packages/daedalus/Cargo.toml @@ -2,7 +2,7 @@ name = "daedalus" version = "0.2.3" authors = ["Jai A "] -edition = "2024" +edition.workspace = true license = "MIT" description = "Utilities for querying and parsing Minecraft metadata" repository = "https://github.com/modrinth/daedalus/" @@ -18,3 +18,6 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true chrono = { workspace = true, features = ["serde"] } thiserror.workspace = true + +[lints] +workspace = true diff --git a/packages/daedalus/package.json b/packages/daedalus/package.json index a5af9cf71..b2ee389f8 100644 --- a/packages/daedalus/package.json +++ b/packages/daedalus/package.json @@ -2,9 +2,8 @@ "name": "@modrinth/daedalus", "scripts": { "build": "cargo build --release", - "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings", - "fix": "cargo fmt && cargo clippy --fix", - "dev": "cargo run", - "test": "cargo test" + "lint": "cargo fmt --check && cargo clippy --all-targets", + "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", + "test": "cargo nextest run --all-targets --no-fail-fast" } } diff --git a/packages/daedalus/src/modded.rs b/packages/daedalus/src/modded.rs index b828cd6dc..6c4733dfa 100644 --- a/packages/daedalus/src/modded.rs +++ b/packages/daedalus/src/modded.rs @@ -1,7 +1,7 @@ use crate::minecraft::{ Argument, ArgumentType, Library, VersionInfo, VersionType, }; -use chrono::{DateTime, TimeZone, Utc}; +use chrono::{DateTime, NaiveDateTime, Utc}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashMap; @@ -26,7 +26,6 @@ pub struct SidedDataEntry { pub server: String, } -#[allow(deprecated)] fn deserialize_date<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -34,7 +33,10 @@ where let s = String::deserialize(deserializer)?; serde_json::from_str::>(&format!("\"{s}\"")) - .or_else(|_| Utc.datetime_from_str(&s, "%Y-%m-%dT%H:%M:%S%.9f")) + .or_else(|_| { + NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.9f") + .map(|date| date.and_utc()) + }) .map_err(serde::de::Error::custom) } diff --git a/packages/moderation/.eslintrc.js b/packages/moderation/.eslintrc.js new file mode 100644 index 000000000..7c8979a43 --- /dev/null +++ b/packages/moderation/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ['custom/library'], + env: { + node: true, + }, +} diff --git a/packages/moderation/LICENSE b/packages/moderation/LICENSE new file mode 100644 index 000000000..e72bfddab --- /dev/null +++ b/packages/moderation/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/packages/moderation/README.md b/packages/moderation/README.md new file mode 100644 index 000000000..adb1e3494 --- /dev/null +++ b/packages/moderation/README.md @@ -0,0 +1,149 @@ +# @modrinth/moderation + +This package contains the moderation checklist system used for reviewing projects on Modrinth. It provides a structured and transparent way to define moderation stages, actions, and messages that are displayed to moderators during the review process. + +## Structure + +The package is organized as follows: + +``` +/packages/moderation/ +├── data/ +│ ├── checklist.ts # Main checklist definition - imports and exports all stages +│ ├── messages/ # Markdown files containing message templates +│ │ ├── title/ # Messages for the title stage +│ │ ├── description/ # Messages for the description stage +│ │ └── ... # One directory per stage +│ └── stages/ # Stage definition files +│ ├── title.ts # Title stage definition +│ ├── description.ts # Description stage definition +│ └── ... # One file per stage +└── types/ # Type definitions + ├── actions.ts # Action-related types + ├── messages.ts # Message-related types + └── stage.ts # Stage-related types +``` + +## Stages + +A stage represents a discrete step in the moderation process, like checking a project's title, description, or links. Each stage has: + +- A title displayed to moderators +- A link to guidance documentation +- An optional navigation path to direct moderators to the relevant part of the project page +- A list of actions that moderators can take + +Stages are defined in individual files in the `data/stages` directory and are assembled into the complete checklist in `data/checklist.ts`. + +## Actions + +Actions represent decisions moderators can make for each stage. They can be buttons, dropdowns, toggles, etc. Actions can have: + +- Labels displayed to the moderator +- Messages that are included in the final moderation decision +- Suggested moderation status and severity +- Optional text inputs for additional information +- Conditional behavior based on other selected actions + +Each action requires a unique `id` field that is used for conditional logic and action relationships. The `suggestedStatus` and `severity` fields help determine the overall moderation outcome. + +## Messages + +Messages are the actual text that will be included in communications to project authors. To promote maintainability and reuse, messages are stored as Markdown files in the `data/messages` directory, organized by stage. + +### Variable replacement + +You can use variables in your messages that will be replaced with user input: + +1. Define a variable in the `relevantExtraInput` array of an action: + +```typescript +relevantExtraInput: [ + { + label: 'Explanation for the user', + variable: 'MESSAGE', + required: true, + }, +], +``` + +2. Use the variable in your message with `%VARIABLE%` syntax: + +```markdown +# Your Message Title + +Here is some explanation about the issue. + +%MESSAGE% + +More text after the variable. +``` + +The `%MESSAGE%` placeholder will be replaced with the text entered by the moderator. + +## Conditional logic + +The moderation system supports conditional behavior that changes based on the selection of other actions. + +### Conditional messages + +You can define different messages for an action based on other selected actions: + +```typescript +{ + id: 'my_action', + type: 'button', + label: 'My Action', + weight: 100, + message: async () => (await import('../messages/default-message.md?raw')).default, + conditionalMessages: [ + { + conditions: { + requiredActions: ['other_action_id'], + excludedActions: ['another_action_id'] + }, + message: async () => (await import('../messages/conditional-message.md?raw')).default, + } + ] +} +``` + +### Enabling and disabling actions + +Actions can enable or disable other actions when selected: + +```typescript +{ + id: 'parent_action', + type: 'button', + label: 'Parent Action', + // This will show these actions when parent_action is selected + enablesActions: [ + { + id: 'child_action', + type: 'button', + label: 'Child Action', + // ...other properties + } + ], + // This will hide actions with these IDs when parent_action is selected + disablesActions: ['incompatible_action_id'] +} +``` + +### Conditional text inputs + +Text inputs can be conditionally shown based on selected actions: + +```typescript +relevantExtraInput: [ + { + label: 'Additional Information', + variable: 'INFO', + showWhen: { + requiredActions: ['specific_action_id'], + excludedActions: ['incompatible_action_id'], + }, + }, +] +``` diff --git a/packages/moderation/data/checklist.ts b/packages/moderation/data/checklist.ts new file mode 100644 index 000000000..5b0edc4c4 --- /dev/null +++ b/packages/moderation/data/checklist.ts @@ -0,0 +1,28 @@ +import type { Stage } from '../types/stage' +import modpackPermissionsStage from './modpack-permissions-stage' +import categories from './stages/categories' +import copyright from './stages/copyright' +import description from './stages/description' +import gallery from './stages/gallery' +import links from './stages/links' +import ruleFollowing from './stages/rule-following' +import sideTypes from './stages/side-types' +import slug from './stages/slug' +import summary from './stages/summary' +import title from './stages/title' +import versions from './stages/versions' + +export default [ + title, + slug, + summary, + description, + links, + categories, + sideTypes, + gallery, + versions, + copyright, + ruleFollowing, + modpackPermissionsStage, +] as ReadonlyArray diff --git a/packages/moderation/data/keybinds.ts b/packages/moderation/data/keybinds.ts new file mode 100644 index 000000000..9a4e4f02d --- /dev/null +++ b/packages/moderation/data/keybinds.ts @@ -0,0 +1,45 @@ +import type { KeybindListener } from '../types/keybinds' + +const keybinds: KeybindListener[] = [ + { + id: 'next-stage', + keybind: 'ArrowRight', + description: 'Go to next stage', + enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage, + action: (ctx) => ctx.actions.tryGoNext(), + }, + { + id: 'previous-stage', + keybind: 'ArrowLeft', + description: 'Go to previous stage', + enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage, + action: (ctx) => ctx.actions.tryGoBack(), + }, + { + id: 'generate-message', + keybind: 'Ctrl+Shift+E', + description: 'Generate moderation message', + action: (ctx) => ctx.actions.tryGenerateMessage(), + }, + { + id: 'toggle-collapse', + keybind: 'Shift+C', + description: 'Toggle collapse/expand', + action: (ctx) => ctx.actions.tryToggleCollapse(), + }, + { + id: 'reset-progress', + keybind: 'Ctrl+Shift+R', + description: 'Reset moderation progress', + action: (ctx) => ctx.actions.tryResetProgress(), + }, + { + id: 'skip-project', + keybind: 'Ctrl+Shift+S', + description: 'Skip to next project', + enabled: (ctx) => ctx.state.futureProjectCount > 0 && !ctx.state.isDone, + action: (ctx) => ctx.actions.trySkipProject(), + }, +] + +export default keybinds diff --git a/packages/moderation/data/messages/categories/inaccurate.md b/packages/moderation/data/messages/categories/inaccurate.md new file mode 100644 index 000000000..ae75c47de --- /dev/null +++ b/packages/moderation/data/messages/categories/inaccurate.md @@ -0,0 +1,3 @@ +## Misuse of Tags + +Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate. Including that selected tags honestly represent your project. diff --git a/packages/moderation/data/messages/copyright/reupload.md b/packages/moderation/data/messages/copyright/reupload.md new file mode 100644 index 000000000..3822ad78c --- /dev/null +++ b/packages/moderation/data/messages/copyright/reupload.md @@ -0,0 +1,7 @@ +## Reuploads are forbidden + +This project appears to contain content from %ORIGINAL_PROJECT% by %ORIGINAL_AUTHOR%. + +Per section 4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) this is strictly forbidden. + +If you believe this is an error, or you can verify you are the creator and rightful owner of this content please let us know. Otherwise, we ask that you **do not resubmit this project**.`, diff --git a/packages/moderation/data/messages/description/headers-as-body.md b/packages/moderation/data/messages/description/headers-as-body.md new file mode 100644 index 000000000..ded6925db --- /dev/null +++ b/packages/moderation/data/messages/description/headers-as-body.md @@ -0,0 +1,7 @@ +## Description Accessibility + +In accordance with section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we request that `# header`s not be used as body text. + +Headers are interpreted differently by screen-readers and thus should generally only be used for things like separating sections of your Description. + +If you would like to emphasize a particular sentence or paragraph, instead consider using `**bold**` text using the **B** button above the text editor. diff --git a/packages/moderation/data/messages/description/image-only.md b/packages/moderation/data/messages/description/image-only.md new file mode 100644 index 000000000..d827f9902 --- /dev/null +++ b/packages/moderation/data/messages/description/image-only.md @@ -0,0 +1,9 @@ +## Image Descriptions + +In accordance with section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we ask that you provide a text alternative to your current Description. + +It is important that your Description contains enough detail about your project that a user can have a full understanding of it from text alone. + +A text-based transcription allows for those using screen readers, and users with slow internet connections unable to load images to be able to access the contents of your Description. This also acts as a backup in case the image in your Description ever goes offline for some reason. + +We appreciate how much effort you put into your Description, but accessibility is important to us at Modrinth, if you would like you could put the transcription of your Description entirely in a `details` tag, so as to not spoil the visuals of your Description. diff --git a/packages/moderation/data/messages/description/insufficient-packs.md b/packages/moderation/data/messages/description/insufficient-packs.md new file mode 100644 index 000000000..fdd264787 --- /dev/null +++ b/packages/moderation/data/messages/description/insufficient-packs.md @@ -0,0 +1,8 @@ +## Insufficient Description + +Per section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations) your project's Description should clearly inform the reader of the content, purpose, and appeal of your project. + +Currently, it looks like there are some missing details. + +What does your modpack add? What features does it have? Why would a user want to download it? Be specific! +See descriptions like [Simply Optimized](https://modrinth.com/modpack/sop) or [Aged](https://modrinth.com/modpack/aged) for examples of what a good description looks like. diff --git a/packages/moderation/data/messages/description/insufficient-projects.md b/packages/moderation/data/messages/description/insufficient-projects.md new file mode 100644 index 000000000..18f8c4e68 --- /dev/null +++ b/packages/moderation/data/messages/description/insufficient-projects.md @@ -0,0 +1,8 @@ +## Insufficient Description + +Per section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations) your project's Description should clearly inform the reader of the content, purpose, and appeal of your project. + +Currently, it looks like there are some missing details. + +What does your project add? What features does it have? Why would a user want to download it? Be specific! +See descriptions like [Sodium](https://modrinth.com/mod/sodium) or [LambDynamicLights](https://modrinth.com/mod/lambdynamiclights) for examples of what a good description looks like. diff --git a/packages/moderation/data/messages/description/insufficient.md b/packages/moderation/data/messages/description/insufficient.md new file mode 100644 index 000000000..b87fa62d7 --- /dev/null +++ b/packages/moderation/data/messages/description/insufficient.md @@ -0,0 +1,7 @@ +## Insufficient Description + +Per section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations) your project's Description should clearly inform the reader of the content, purpose, and appeal of your project. + +Currently, it looks like there are some missing details. + +> %EXPLAINER% diff --git a/packages/moderation/data/messages/description/non-english.md b/packages/moderation/data/messages/description/non-english.md new file mode 100644 index 000000000..1a9131e23 --- /dev/null +++ b/packages/moderation/data/messages/description/non-english.md @@ -0,0 +1,5 @@ +## No English Description + +Per section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#accessibility) a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations. + +You may include your non-English Description if you would like but we ask that you also add an English translation of the Description to your Description page, if you would like to use an online translator to do this, we recommend [DeepL](https://www.deepl.com/translator). diff --git a/packages/moderation/data/messages/description/non-standard-text.md b/packages/moderation/data/messages/description/non-standard-text.md new file mode 100644 index 000000000..88d0f2d23 --- /dev/null +++ b/packages/moderation/data/messages/description/non-standard-text.md @@ -0,0 +1,7 @@ +## Description Accessibility + +Per section 2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#clear-and-honest-function) your description must be plainly readable and accessible. + +Using non-standard text characters like Zalgo or "fancy text" in place of text anywhere in your project, including the Description, Summary, or Title can make your project pages inaccessible. + +This is important for users who rely on Screen Readers and for search engines in order to provide relevant results to users. Please remove any instances of this type of text. diff --git a/packages/moderation/data/messages/description/unfinished.md b/packages/moderation/data/messages/description/unfinished.md new file mode 100644 index 000000000..9e6e161e4 --- /dev/null +++ b/packages/moderation/data/messages/description/unfinished.md @@ -0,0 +1,7 @@ +## Unfinished Description + +It looks like your project Description is still a WIP (Work In Progress). + +> %REASON% + +Please remember to submit only when ready, as it is important your project meets the requirements of Section 2.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#general-expectations), if you have any questions on this feel free to reach out! diff --git a/packages/moderation/data/messages/gallery/insufficient.md b/packages/moderation/data/messages/gallery/insufficient.md new file mode 100644 index 000000000..0319ce6d8 --- /dev/null +++ b/packages/moderation/data/messages/gallery/insufficient.md @@ -0,0 +1,8 @@ +## Insufficient Gallery Images + +We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations). +Keep in mind that you should: + +- Set a featured image that best represents your project. +- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description. +- Upload any relevant images in your Description to your Gallery tab for best results. diff --git a/packages/moderation/data/messages/gallery/not-relevant.md b/packages/moderation/data/messages/gallery/not-relevant.md new file mode 100644 index 000000000..6a3a5a85a --- /dev/null +++ b/packages/moderation/data/messages/gallery/not-relevant.md @@ -0,0 +1,3 @@ +## Unrelated Gallery Images + +Per section 5.5 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) any images in your project's Gallery must be relevant to the project and also include a Title. diff --git a/packages/moderation/data/messages/links/misused.md b/packages/moderation/data/messages/links/misused.md new file mode 100644 index 000000000..b38a87f3b --- /dev/null +++ b/packages/moderation/data/messages/links/misused.md @@ -0,0 +1,3 @@ +## Misuse of External Resources + +Per section 5.4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) all links must lead to correctly labeled publicly available resources that are directly related to your project. diff --git a/packages/moderation/data/messages/links/not-accessible-other.md b/packages/moderation/data/messages/links/not-accessible-other.md new file mode 100644 index 000000000..af1f5a23a --- /dev/null +++ b/packages/moderation/data/messages/links/not-accessible-other.md @@ -0,0 +1,5 @@ +## Unreachable Links + +Per section 5.4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) all links must lead to correctly labeled publicly available resources that are directly related to your project. + +Currently, your %LINK% link is inaccessible! diff --git a/packages/moderation/data/messages/links/not-accessible-source.md b/packages/moderation/data/messages/links/not-accessible-source.md new file mode 100644 index 000000000..74953b027 --- /dev/null +++ b/packages/moderation/data/messages/links/not-accessible-source.md @@ -0,0 +1,5 @@ +## Unreachable Links + +Per section 5.4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) all links must lead to correctly labeled publicly available resources that are directly related to your project. + +Currently, your Source link directs to a Page Not Found error, likely because your repository is private, make sure to make your repository public before resubmitting your project! diff --git a/packages/moderation/data/messages/rule-breaking.md b/packages/moderation/data/messages/rule-breaking.md new file mode 100644 index 000000000..4c475aee5 --- /dev/null +++ b/packages/moderation/data/messages/rule-breaking.md @@ -0,0 +1,5 @@ +# Does not follow content rules + +Our moderators have determined that your project does not follow Modrinth's Content Rules, and has been rejected. + +%MESSAGE% diff --git a/packages/moderation/data/messages/side-types/inaccurate-mod.md b/packages/moderation/data/messages/side-types/inaccurate-mod.md new file mode 100644 index 000000000..2e1243a06 --- /dev/null +++ b/packages/moderation/data/messages/side-types/inaccurate-mod.md @@ -0,0 +1,9 @@ +## Environment Information + +Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side. + +For a brief rundown of how this works: + +- **Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium). +- **Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree). +- A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon). diff --git a/packages/moderation/data/messages/side-types/inaccurate-modpack.md b/packages/moderation/data/messages/side-types/inaccurate-modpack.md new file mode 100644 index 000000000..05bc1a8de --- /dev/null +++ b/packages/moderation/data/messages/side-types/inaccurate-modpack.md @@ -0,0 +1,10 @@ +## Incorrect Environment Information + +Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side. + +For a brief rundown of how this works: + +- Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized). +- Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light). + +When in doubt, test for yourself or check the requirements of the mods in your pack. diff --git a/packages/moderation/data/messages/slug/misused.md b/packages/moderation/data/messages/slug/misused.md new file mode 100644 index 000000000..37f2afa43 --- /dev/null +++ b/packages/moderation/data/messages/slug/misused.md @@ -0,0 +1,3 @@ +## Misuse of Slug + +Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your project slug (URL) must accurately represent your project. diff --git a/packages/moderation/data/messages/summary/formatting.md b/packages/moderation/data/messages/summary/formatting.md new file mode 100644 index 000000000..36cbfdd9e --- /dev/null +++ b/packages/moderation/data/messages/summary/formatting.md @@ -0,0 +1,7 @@ +## Insufficient Summary + +Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not include any extra formatting such as lists, or links. + +Your project summary should provide a brief overview of your project that informs and entices users. + +This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting. diff --git a/packages/moderation/data/messages/summary/insufficient.md b/packages/moderation/data/messages/summary/insufficient.md new file mode 100644 index 000000000..a804b0be0 --- /dev/null +++ b/packages/moderation/data/messages/summary/insufficient.md @@ -0,0 +1,5 @@ +## Insufficient Summary + +Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your project summary should provide a brief overview of your project that informs and entices users. + +This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting. diff --git a/packages/moderation/data/messages/summary/non-english.md b/packages/moderation/data/messages/summary/non-english.md new file mode 100644 index 000000000..9e3c525d6 --- /dev/null +++ b/packages/moderation/data/messages/summary/non-english.md @@ -0,0 +1,5 @@ +## No English Summary + +Per section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#accessibility) a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations. + +You may include your non-English Summary but we ask that you also add an English translation. diff --git a/packages/moderation/data/messages/summary/repeat-title.md b/packages/moderation/data/messages/summary/repeat-title.md new file mode 100644 index 000000000..772374444 --- /dev/null +++ b/packages/moderation/data/messages/summary/repeat-title.md @@ -0,0 +1,7 @@ +## Insufficient Summary + +Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not be the same as your project's Title. + +Your project summary should provide a brief overview of your project that informs and entices users. + +This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting. diff --git a/packages/moderation/data/messages/title/minecraft-branding.md b/packages/moderation/data/messages/title/minecraft-branding.md new file mode 100644 index 000000000..525a1d8c7 --- /dev/null +++ b/packages/moderation/data/messages/title/minecraft-branding.md @@ -0,0 +1,7 @@ +## Project Title + +Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the title. + +The title of your project may be confusingly similar to the game, and we encourage you to change your title to avoid a potential violation of Minecraft's Usage Guidelines. + +Abbreviations like "MC" or elaborate titles that do not make the name Minecraft a significant portion of the name are okay. diff --git a/packages/moderation/data/messages/title/similarities.md b/packages/moderation/data/messages/title/similarities.md new file mode 100644 index 000000000..0011e5a16 --- /dev/null +++ b/packages/moderation/data/messages/title/similarities.md @@ -0,0 +1,3 @@ +## Project Branding + +Per section 1.8 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we ask that you change your project title and other relevant branding to avoid causing confusion or implying association with existing projects. diff --git a/packages/moderation/data/messages/title/useless-info.md b/packages/moderation/data/messages/title/useless-info.md new file mode 100644 index 000000000..51e1f5182 --- /dev/null +++ b/packages/moderation/data/messages/title/useless-info.md @@ -0,0 +1,7 @@ +## Misuse of Title + +Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) we ask that you limit the title to just the name of your project. + +Additional information, such as themes, tags, supported versions or loaders, etc. should be saved for the Summary or Description. + +When changing your project title, remember to also ensure that your project slug (URL) matches and accurately represents your project. diff --git a/packages/moderation/data/messages/versions/incorrect-additional-files.md b/packages/moderation/data/messages/versions/incorrect-additional-files.md new file mode 100644 index 000000000..34c9c72df --- /dev/null +++ b/packages/moderation/data/messages/versions/incorrect-additional-files.md @@ -0,0 +1,7 @@ +## Incorrect Use of Additional Files + +It looks like you've uploaded multiple `mod.jar` files to one Version as Additional Files. Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) each Version of your project must include only one `mod.jar` that corresponds to its respective Minecraft and loader versions. + +This allows users to easily find and download the file they need for the version they're on with ease. The Additional Files feature can be used for things like a `Sources.jar`. + +Please upload each version of your mod separately, thank you. diff --git a/packages/moderation/data/messages/versions/invalid-modpacks.md b/packages/moderation/data/messages/versions/invalid-modpacks.md new file mode 100644 index 000000000..62cb8fcaf --- /dev/null +++ b/packages/moderation/data/messages/versions/invalid-modpacks.md @@ -0,0 +1,3 @@ +## Modpacks on Modrinth + +It looks like you've uploaded your Modpack as a `.zip`, unfortunately, this is invalid and is why your project type is "Mod". I recommend taking a look at our support page about [Modrinth Modpacks](https://support.modrinth.com/en/articles/8802250-modpacks-on-modrinth), and once you're ready feel free to resubmit your project as a `.mrpack`. Don't forget to delete the old files from your Versions! diff --git a/packages/moderation/data/messages/versions/invalid-resourcepacks.md b/packages/moderation/data/messages/versions/invalid-resourcepacks.md new file mode 100644 index 000000000..60a01c636 --- /dev/null +++ b/packages/moderation/data/messages/versions/invalid-resourcepacks.md @@ -0,0 +1,3 @@ +## Resource Packs on Modrinth + +It looks like you've selected loaders for your Resource Pack that are causing it to be marked as a different project type. Resource Packs must only be uploaded with the "Resource Pack" loader selected. Please re-upload all versions of your resource pack and make sure to only select "Resource Pack" as the loader. diff --git a/packages/moderation/data/modpack-permissions-stage.ts b/packages/moderation/data/modpack-permissions-stage.ts new file mode 100644 index 000000000..c64b3b74d --- /dev/null +++ b/packages/moderation/data/modpack-permissions-stage.ts @@ -0,0 +1,25 @@ +import type { ModerationModpackPermissionApprovalType, Project } from '@modrinth/utils' +import type { Stage } from '../types/stage' +import { BoxIcon } from '@modrinth/assets' + +export default { + id: 'modpack-permissions', + title: 'Modpack Permissions', + icon: BoxIcon, + // Replace me please. + guidance_url: 'https://docs.modrinth.com/moderation/modpack-permissions', + shouldShow: (project: Project) => project.project_type === 'modpack', + actions: [], +} as Stage + +export const finalPermissionMessages: Record< + ModerationModpackPermissionApprovalType['id'], + string | undefined +> = { + yes: undefined, + 'with-attribution-and-source': undefined, + 'with-attribution': `The following content has attribution requirements, meaning that you must link back to the page where you originally found this content in your modpack description or version changelog (e.g. linking a mod's CurseForge page if you got it from CurseForge):`, + no: 'The following content is not allowed in Modrinth modpacks due to licensing restrictions. Please contact the author(s) directly for permission or remove the content from your modpack:', + 'permanent-no': `The following content is not allowed in Modrinth modpacks, regardless of permission obtained. This may be because it breaks Modrinth's content rules or because the authors, upon being contacted for permission, have declined. Please remove the content from your modpack:`, + unidentified: `The following content could not be identified. Please provide proof of its origin along with proof that you have permission to include it:`, +} diff --git a/packages/moderation/data/stages/categories.ts b/packages/moderation/data/stages/categories.ts new file mode 100644 index 000000000..d3a89710e --- /dev/null +++ b/packages/moderation/data/stages/categories.ts @@ -0,0 +1,24 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { TagsIcon } from '@modrinth/assets' + +const categories: Stage = { + title: "Are the project's tags/categories accurate?", + id: 'tags', + icon: TagsIcon, + guidance_url: 'https://modrinth.com/legal/rules#miscellaneous', + navigate: '/settings/tags', + actions: [ + { + id: 'categories_inaccurate', + type: 'button', + label: 'Inaccurate', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/categories/inaccurate.md?raw')).default, + } as ButtonAction, + ], +} + +export default categories diff --git a/packages/moderation/data/stages/copyright.ts b/packages/moderation/data/stages/copyright.ts new file mode 100644 index 000000000..64d5a8ae4 --- /dev/null +++ b/packages/moderation/data/stages/copyright.ts @@ -0,0 +1,37 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { CopyrightIcon } from '@modrinth/assets' + +const copyright: Stage = { + title: 'Does the author have proper permissions to post this project?', + id: 'copyright', + icon: CopyrightIcon, + guidance_url: 'https://modrinth.com/legal/rules', + actions: [ + { + id: 'copyright_reupload', + type: 'button', + label: 'Re-upload', + weight: 10, + suggestedStatus: 'rejected', + severity: 'high', + message: async () => (await import('../messages/copyright/reupload.md?raw')).default, + relevantExtraInput: [ + { + label: 'What is the title of the original project?', + variable: 'ORIGINAL_PROJECT', + required: true, + suggestions: ['Vanilla Tweaks'], + }, + { + label: 'What is the author of the original project?', + variable: 'ORIGINAL_AUTHOR', + required: true, + suggestions: ['Vanilla Tweaks Team'], + }, + ], + } as ButtonAction, + ], +} + +export default copyright diff --git a/packages/moderation/data/stages/description.ts b/packages/moderation/data/stages/description.ts new file mode 100644 index 000000000..9afe05b4b --- /dev/null +++ b/packages/moderation/data/stages/description.ts @@ -0,0 +1,107 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { LibraryIcon } from '@modrinth/assets' + +const description: Stage = { + title: "Is the project's description sufficient?", + id: 'description', + icon: LibraryIcon, + guidance_url: 'https://modrinth.com/legal/rules#general-expectations', + navigate: '/', + actions: [ + { + id: 'description_insufficient', + type: 'button', + label: 'Insufficient (custom)', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => (await import('../messages/description/insufficient.md?raw')).default, + relevantExtraInput: [ + { + label: 'Please elaborate on how the author can improve their description.', + variable: 'EXPLAINER', + large: true, + required: true, + }, + ], + } as ButtonAction, + { + id: 'description_insufficient_packs', + type: 'button', + label: 'Insufficient', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + shouldShow: (project) => project.project_type === 'modpack', + message: async () => + (await import('../messages/description/insufficient-packs.md?raw')).default, + } as ButtonAction, + { + id: 'description_insufficient_projects', + type: 'button', + label: 'Insufficient', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + shouldShow: (project) => project.project_type !== 'modpack', + message: async () => + (await import('../messages/description/insufficient-projects.md?raw')).default, + } as ButtonAction, + { + id: 'description_non_english', + type: 'button', + label: 'Non-english', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => (await import('../messages/description/non-english.md?raw')).default, + } as ButtonAction, + { + id: 'description_unfinished', + type: 'button', + label: 'Unfinished', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/description/unfinished.md?raw')).default, + relevantExtraInput: [ + { + label: 'Please specify the reason the description appears unfinished.', + variable: 'REASON', + required: true, + }, + ], + } as ButtonAction, + { + id: 'description_headers_as_body', + type: 'button', + label: 'Headers as body text', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/description/headers-as-body.md?raw')).default, + } as ButtonAction, + { + id: 'description_image_only', + type: 'button', + label: 'Image-only', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => (await import('../messages/description/image-only.md?raw')).default, + } as ButtonAction, + { + id: 'description_non_standard_text', + type: 'button', + label: 'Non-standard text', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => + (await import('../messages/description/non-standard-text.md?raw')).default, + } as ButtonAction, + ], +} + +export default description diff --git a/packages/moderation/data/stages/gallery.ts b/packages/moderation/data/stages/gallery.ts new file mode 100644 index 000000000..d7dcb6c7e --- /dev/null +++ b/packages/moderation/data/stages/gallery.ts @@ -0,0 +1,33 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { ImageIcon } from '@modrinth/assets' + +const gallery: Stage = { + title: "Are this project's gallery images sufficient?", + id: 'gallery', + icon: ImageIcon, + guidance_url: 'https://modrinth.com/legal/rules#general-expectations', + navigate: '/gallery', + actions: [ + { + id: 'gallery_insufficient', + type: 'button', + label: 'Insufficient', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/gallery/insufficient.md?raw')).default, + } as ButtonAction, + { + id: 'gallery_not_relevant', + type: 'button', + label: 'Not relevant', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/gallery/not-relevant.md?raw')).default, + } as ButtonAction, + ], +} + +export default gallery diff --git a/packages/moderation/data/stages/links.ts b/packages/moderation/data/stages/links.ts new file mode 100644 index 000000000..531c15fb0 --- /dev/null +++ b/packages/moderation/data/stages/links.ts @@ -0,0 +1,49 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { LinkIcon } from '@modrinth/assets' + +const links: Stage = { + title: "Are the project's links accessible and not misleading?", + id: 'links', + icon: LinkIcon, + guidance_url: 'https://modrinth.com/legal/rules#miscellaneous', + navigate: '/settings/links', + actions: [ + { + id: 'links_misused', + type: 'button', + label: 'Links are misused', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/links/misused.md?raw')).default, + } as ButtonAction, + { + id: 'links_not_accessible_source', + type: 'button', + label: 'Not accessible (source)', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/links/not-accessible-source.md?raw')).default, + } as ButtonAction, + { + id: 'links_not_accessible_other', + type: 'button', + label: 'Not accessible (other)', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/links/not-accessible-other.md?raw')).default, + relevantExtraInput: [ + { + label: 'Please specify the link type that is inaccessible.', + variable: 'LINK', + required: true, + }, + ], + } as ButtonAction, + ], +} + +export default links diff --git a/packages/moderation/data/stages/rule-following.ts b/packages/moderation/data/stages/rule-following.ts new file mode 100644 index 000000000..15bad9359 --- /dev/null +++ b/packages/moderation/data/stages/rule-following.ts @@ -0,0 +1,32 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { ListBulletedIcon } from '@modrinth/assets' + +const ruleFollowing: Stage = { + title: 'Does this project break our content rules?', + id: 'rule-following', + icon: ListBulletedIcon, + guidance_url: 'https://modrinth.com/legal/rules', + navigate: '/', + actions: [ + { + id: 'rule_breaking_yes', + type: 'button', + label: 'Yes', + weight: 10, + suggestedStatus: 'rejected', + severity: 'critical', + message: async () => (await import('../messages/rule-breaking.md?raw')).default, + relevantExtraInput: [ + { + label: 'Please explain to the user how it infringes on our content rules.', + variable: 'MESSAGE', + required: true, + large: true, + }, + ], + } as ButtonAction, + ], +} + +export default ruleFollowing diff --git a/packages/moderation/data/stages/side-types.ts b/packages/moderation/data/stages/side-types.ts new file mode 100644 index 000000000..3a37a89ad --- /dev/null +++ b/packages/moderation/data/stages/side-types.ts @@ -0,0 +1,36 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { GlobeIcon } from '@modrinth/assets' + +const sideTypes: Stage = { + title: "Is the project's environment information accurate?", + id: 'environment', + icon: GlobeIcon, + guidance_url: 'https://modrinth.com/legal/rules#miscellaneous', + navigate: '/settings#side-types', + actions: [ + { + id: 'side_types_inaccurate_modpack', + type: 'button', + label: 'Inaccurate (modpack)', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + shouldShow: (project) => project.project_type === 'modpack', + message: async () => + (await import('../messages/side-types/inaccurate-modpack.md?raw')).default, + } as ButtonAction, + { + id: 'side_types_inaccurate_mod', + type: 'button', + label: 'Inaccurate (mod)', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + shouldShow: (project) => project.project_type === 'mod', + message: async () => (await import('../messages/side-types/inaccurate-mod.md?raw')).default, + } as ButtonAction, + ], +} + +export default sideTypes diff --git a/packages/moderation/data/stages/slug.ts b/packages/moderation/data/stages/slug.ts new file mode 100644 index 000000000..6fad5c813 --- /dev/null +++ b/packages/moderation/data/stages/slug.ts @@ -0,0 +1,23 @@ +import { HashIcon } from '@modrinth/assets' +import type { Stage } from '../../types/stage' + +const slugStage: Stage = { + title: 'Is the slug accurate and appropriate?', + id: 'slug', + icon: HashIcon, + guidance_url: 'https://modrinth.com/legal/rules#miscellaneous', + navigate: '/settings', + actions: [ + { + id: 'slug_misused', + type: 'button', + label: 'Misused', + weight: 100, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/slug/misused.md?raw')).default, + }, + ], +} + +export default slugStage diff --git a/packages/moderation/data/stages/summary.ts b/packages/moderation/data/stages/summary.ts new file mode 100644 index 000000000..5e4834d54 --- /dev/null +++ b/packages/moderation/data/stages/summary.ts @@ -0,0 +1,50 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { AlignLeftIcon } from '@modrinth/assets' + +const summary: Stage = { + title: "Is the project's summary sufficient?", + id: 'summary', + icon: AlignLeftIcon, + guidance_url: 'https://modrinth.com/legal/rules#miscellaneous', + actions: [ + { + id: 'summary_insufficient', + type: 'button', + label: 'Insufficient', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/summary/insufficient.md?raw')).default, + } as ButtonAction, + { + id: 'summary_repeat_title', + type: 'button', + label: 'Repeat of title', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/summary/repeat-title.md?raw')).default, + } as ButtonAction, + { + id: 'summary_formatting', + type: 'button', + label: 'Formatting', + weight: 10, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/summary/formatting.md?raw')).default, + } as ButtonAction, + { + id: 'summary_non_english', + type: 'button', + label: 'Non-english', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => (await import('../messages/summary/non-english.md?raw')).default, + } as ButtonAction, + ], +} + +export default summary diff --git a/packages/moderation/data/stages/title.ts b/packages/moderation/data/stages/title.ts new file mode 100644 index 000000000..1bc15d383 --- /dev/null +++ b/packages/moderation/data/stages/title.ts @@ -0,0 +1,41 @@ +import { BookOpenIcon } from '@modrinth/assets' +import type { Stage } from '../../types/stage' + +const titleStage: Stage = { + title: 'Is this title free of useless information?', + text: async () => '**Title:** `%PROJECT_TITLE%`', + id: 'title', + icon: BookOpenIcon, + guidance_url: 'https://modrinth.com/legal/rules#miscellaneous', + actions: [ + { + id: 'title_useless_info', + type: 'button', + label: 'Contains useless info', + weight: 100, + suggestedStatus: 'flagged', + severity: 'low', + message: async () => (await import('../messages/title/useless-info.md?raw')).default, + }, + { + id: 'title_minecraft_branding', + type: 'button', + label: 'Minecraft title', + weight: 100, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => (await import('../messages/title/minecraft-branding.md?raw')).default, + }, + { + id: 'title_similarities', + type: 'button', + label: 'Title similarities', + weight: 100, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => (await import('../messages/title/similarities.md?raw')).default, + }, + ], +} + +export default titleStage diff --git a/packages/moderation/data/stages/versions.ts b/packages/moderation/data/stages/versions.ts new file mode 100644 index 000000000..7e96bec1a --- /dev/null +++ b/packages/moderation/data/stages/versions.ts @@ -0,0 +1,46 @@ +import type { Stage } from '../../types/stage' +import type { ButtonAction } from '../../types/actions' +import { VersionIcon } from '@modrinth/assets' + +const versions: Stage = { + title: "Are these project's files correct?", + id: 'versions', + icon: VersionIcon, + guidance_url: 'https://modrinth.com/legal/rules#miscellaneous', + navigate: '/versions', + actions: [ + { + id: 'versions_incorrect_additional', + type: 'button', + label: 'Incorrect additional files', + weight: 10, + suggestedStatus: 'flagged', + severity: 'medium', + message: async () => + (await import('../messages/versions/incorrect-additional-files.md?raw')).default, + } as ButtonAction, + { + id: 'versions_invalid_modpacks', + type: 'button', + label: 'Invalid file type (modpacks)', + weight: 10, + suggestedStatus: 'rejected', + severity: 'medium', + shouldShow: (project) => project.project_type === 'modpack', + message: async () => (await import('../messages/versions/invalid-modpacks.md?raw')).default, + } as ButtonAction, + { + id: 'versions_invalid_resourcepacks', + type: 'button', + label: 'Invalid file type (resourcepacks)', + weight: 10, + suggestedStatus: 'rejected', + severity: 'medium', + shouldShow: (project) => project.project_type === 'resourcepack', + message: async () => + (await import('../messages/versions/invalid-resourcepacks.md?raw')).default, + } as ButtonAction, + ], +} + +export default versions diff --git a/packages/moderation/index.ts b/packages/moderation/index.ts new file mode 100644 index 000000000..b0a6afea9 --- /dev/null +++ b/packages/moderation/index.ts @@ -0,0 +1,8 @@ +export * from './types/actions' +export * from './types/messages' +export * from './types/stage' +export * from './types/keybinds' +export * from './utils' + +export { default as checklist } from './data/checklist' +export { default as keybinds } from './data/keybinds' diff --git a/packages/moderation/package.json b/packages/moderation/package.json new file mode 100644 index 000000000..39a4a49e4 --- /dev/null +++ b/packages/moderation/package.json @@ -0,0 +1,21 @@ +{ + "name": "@modrinth/moderation", + "version": "0.0.0", + "private": true, + "main": "./index.ts", + "types": "./index.d.ts", + "scripts": { + "lint": "eslint . && prettier --check .", + "fix": "eslint . --fix && prettier --write ." + }, + "dependencies": { + "@modrinth/utils": "workspace:*", + "@modrinth/assets": "workspace:*", + "vue": "^3.5.13" + }, + "devDependencies": { + "eslint": "^8.57.0", + "eslint-config-custom": "workspace:*", + "tsconfig": "workspace:*" + } +} diff --git a/packages/moderation/tsconfig.json b/packages/moderation/tsconfig.json new file mode 100644 index 000000000..3c7340846 --- /dev/null +++ b/packages/moderation/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/vue.json", + "include": [".", ".eslintrc.js"], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "lib": ["esnext", "dom"], + "noImplicitAny": false + }, + "types": ["@stripe/stripe-js"] +} diff --git a/packages/moderation/types/actions.ts b/packages/moderation/types/actions.ts new file mode 100644 index 000000000..c86973158 --- /dev/null +++ b/packages/moderation/types/actions.ts @@ -0,0 +1,264 @@ +import type { Project } from '@modrinth/utils' +import type { WeightedMessage } from './messages' + +export type ActionType = + | 'button' + | 'dropdown' + | 'multi-select-chips' + | 'toggle' + | 'conditional-button' + +export type Action = + | ButtonAction + | DropdownAction + | MultiSelectChipsAction + | ToggleAction + | ConditionalButtonAction + +export type ModerationStatus = 'approved' | 'rejected' | 'flagged' +export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical' + +export interface BaseAction { + /** + * The type of action, which determines how the action is presented to the moderator and what it does. + */ + type: ActionType + + /** + * Any additional text data that is required to complete the action. + */ + relevantExtraInput?: AdditionalTextInput[] + + /** + * Suggested moderation status when this action is selected. + */ + suggestedStatus?: ModerationStatus + + /** + * Suggested severity level for this moderation action. + */ + severity?: ModerationSeverity + + /** + * Actions that become available when this action is selected. + */ + enablesActions?: Action[] + + /** + * Actions that become unavailable when this action is selected. + */ + disablesActions?: string[] // Array of action IDs + + /** + * Unique identifier for this action, used for conditional logic. + */ + id?: string + + /** + * A function that determines whether this action should be shown for a given project. + * + * By default, it returns `true`, meaning the action is always shown. + */ + shouldShow?: (project: Project) => boolean +} + +/** + * Represents a conditional message that changes based on other selected actions. + */ +export interface ConditionalMessage extends WeightedMessage { + /** + * Conditions that must be met for this message to be used. + */ + conditions: { + /** + * Action IDs that must be selected for this message to apply. + */ + requiredActions?: string[] + + /** + * Action IDs that must NOT be selected for this message to apply. + */ + excludedActions?: string[] + } +} + +/** + * Represents a button action, which is a simple toggle button that can be used to append a message to the final moderation message. + */ +export interface ButtonAction extends BaseAction, WeightedMessage { + type: 'button' + + /** + * The label of the button, which is displayed to the moderator. The text on the button. + */ + label: string + + /** + * Alternative messages based on other selected actions. + */ + conditionalMessages?: ConditionalMessage[] +} + +/** + * Represents a simple toggle/checkbox action with separate layout handling. + */ +export interface ToggleAction extends BaseAction, WeightedMessage { + type: 'toggle' + + /** + * The label of the toggle, which is displayed to the moderator. + */ + label: string + + /** + * Description text that appears below the toggle. + */ + description?: string + + /** + * Whether the toggle is checked by default. + */ + defaultChecked?: boolean + + /** + * Alternative messages based on other selected actions. + */ + conditionalMessages?: ConditionalMessage[] +} + +/** + * Represents a button that has different behavior based on other selected actions. + */ +export interface ConditionalButtonAction extends BaseAction { + type: 'conditional-button' + + /** + * The label of the button, which is displayed to the moderator. + */ + label: string + + /** + * Different message configurations based on conditions. + */ + messageVariants: ConditionalMessage[] + + /** + * Global fallback message if no variants match their conditions. + */ + fallbackMessage?: () => Promise + + /** + * The weight of the action's fallback message, used to determine the place where the message is placed in the final moderation message. + */ + fallbackWeight?: number +} + +export interface DropdownActionOption extends WeightedMessage { + /** + * The label of the option, which is displayed to the moderator. + */ + label: string + + /** + * A function that determines whether this option should be shown for a given project. + * + * By default, it returns `true`, meaning the option is always shown. + */ + shouldShow?: (project: Project) => boolean +} + +export interface DropdownAction extends BaseAction { + type: 'dropdown' + + /** + * The label associated with the dropdown. + */ + label: string + + /** + * The options available in the dropdown. + */ + options: DropdownActionOption[] + + /** + * The default option selected in the dropdown, by index. + */ + defaultOption?: number +} + +export interface MultiSelectChipsOption extends WeightedMessage { + /** + * The label of the chip, which is displayed to the moderator. + */ + label: string + + /** + * A function that determines whether this option should be shown for a given project. + * + * By default, it returns `true`, meaning the option is always shown. + */ + shouldShow?: (project: Project) => boolean +} + +export interface MultiSelectChipsAction extends BaseAction { + type: 'multi-select-chips' + + /** + * The label associated with the multi-select chips. + */ + label: string + + /** + * The options available in the multi-select chips. + */ + options: MultiSelectChipsOption[] +} + +export interface AdditionalTextInput { + /** + * The label of the text input, which is displayed to the moderator. + */ + label: string + + /** + * The placeholder text for the text input. + */ + placeholder?: string + + /** + * Whether the text input is required to be filled out before the action can be completed. + */ + required?: boolean + + /** + * Whether the text input should use the full markdown editor rather than a simple text input. + */ + large?: boolean + + /** + * The variable name that will be replaced in the message with the input value. + * For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message + * will be replaced with the input value. + */ + variable?: string + + /** + * Conditions that determine when this input is shown. + */ + showWhen?: { + /** + * Action IDs that must be selected for this input to be shown. + */ + requiredActions?: string[] + + /** + * Action IDs that must NOT be selected for this input to be shown. + */ + excludedActions?: string[] + } + + /** + * Optional suggestions for the input. Useful for repeating phrases or common responses. + */ + suggestions?: string[] +} diff --git a/packages/moderation/types/keybinds.ts b/packages/moderation/types/keybinds.ts new file mode 100644 index 000000000..393985c77 --- /dev/null +++ b/packages/moderation/types/keybinds.ts @@ -0,0 +1,136 @@ +import type { Project } from '@modrinth/utils' + +export interface ModerationActions { + tryGoNext: () => void + tryGoBack: () => void + tryGenerateMessage: () => void + trySkipProject: () => void + + tryToggleCollapse: () => void + tryResetProgress: () => void + tryExitModeration: () => void + + tryApprove: () => void + tryReject: () => void + tryWithhold: () => void + tryEditMessage: () => void + + tryToggleAction: (actionIndex: number) => void + trySelectDropdownOption: (actionIndex: number, optionIndex: number) => void + tryToggleChip: (actionIndex: number, chipIndex: number) => void + + tryFocusNextAction: () => void + tryFocusPreviousAction: () => void + tryActivateFocusedAction: () => void +} + +export interface ModerationState { + currentStage: number + totalStages: number + currentStageId: string | undefined + currentStageTitle: string + + isCollapsed: boolean + isDone: boolean + hasGeneratedMessage: boolean + isLoadingMessage: boolean + isModpackPermissionsStage: boolean + + futureProjectCount: number + visibleActionsCount: number + + focusedActionIndex: number | null + focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null +} + +export interface ModerationContext { + project: Project + state: ModerationState + actions: ModerationActions +} + +export interface KeybindDefinition { + key: string + ctrl?: boolean + shift?: boolean + alt?: boolean + meta?: boolean + preventDefault?: boolean +} + +export interface KeybindListener { + id: string + keybind: KeybindDefinition | KeybindDefinition[] | string | string[] + description: string + enabled?: (ctx: ModerationContext) => boolean + action: (ctx: ModerationContext) => void +} + +export function parseKeybind(keybindString: string): KeybindDefinition { + const parts = keybindString.split('+').map((p) => p.trim().toLowerCase()) + + return { + key: parts.find((p) => !['ctrl', 'shift', 'alt', 'meta', 'cmd'].includes(p)) || '', + ctrl: parts.includes('ctrl') || parts.includes('cmd'), + shift: parts.includes('shift'), + alt: parts.includes('alt'), + meta: parts.includes('meta') || parts.includes('cmd'), + preventDefault: true, + } +} + +export function normalizeKeybind(keybind: KeybindDefinition | string): KeybindDefinition { + return typeof keybind === 'string' ? parseKeybind(keybind) : keybind +} + +export function matchesKeybind(event: KeyboardEvent, keybind: KeybindDefinition | string): boolean { + const def = normalizeKeybind(keybind) + return ( + event.key.toLowerCase() === def.key.toLowerCase() && + event.ctrlKey === (def.ctrl ?? false) && + event.shiftKey === (def.shift ?? false) && + event.altKey === (def.alt ?? false) && + event.metaKey === (def.meta ?? false) + ) +} + +export function handleKeybind( + event: KeyboardEvent, + ctx: ModerationContext, + keybinds: KeybindListener[], +): boolean { + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + (event.target as HTMLElement)?.closest('.cm-editor') || + (event.target as HTMLElement)?.classList?.contains('cm-content') || + (event.target as HTMLElement)?.classList?.contains('cm-line') + ) { + return false + } + + for (const keybind of keybinds) { + if (keybind.enabled && !keybind.enabled(ctx)) { + continue + } + + const keybindDefs = Array.isArray(keybind.keybind) + ? keybind.keybind.map(normalizeKeybind) + : [normalizeKeybind(keybind.keybind)] + + const matches = keybindDefs.some((def) => matchesKeybind(event, def)) + + if (matches) { + keybind.action(ctx) + + const shouldPrevent = keybindDefs.some((def) => def.preventDefault !== false) + if (shouldPrevent) { + event.preventDefault() + } + + return true + } + } + + return false +} diff --git a/packages/moderation/types/messages.ts b/packages/moderation/types/messages.ts new file mode 100644 index 000000000..baa1645d9 --- /dev/null +++ b/packages/moderation/types/messages.ts @@ -0,0 +1,13 @@ +export interface WeightedMessage { + /** + * The weight of the action's active message, used to determine the place where the message is placed in the final moderation message. + */ + weight: number + + /** + * The message which is appended to the final moderation message if the button is active. + * @returns A function that lazily loads the message which is appended if the button is active. + * @example async () => (await import('../messages/example.md?raw')).default, + */ + message: () => Promise +} diff --git a/packages/moderation/types/stage.ts b/packages/moderation/types/stage.ts new file mode 100644 index 000000000..661cec83e --- /dev/null +++ b/packages/moderation/types/stage.ts @@ -0,0 +1,52 @@ +import type { Project } from '@modrinth/utils' +import type { Action } from './actions' +import type { FunctionalComponent, SVGAttributes } from 'vue' + +/** + * Represents a moderation stage with associated actions and optional navigation logic. + */ +export interface Stage { + /** + * The title of the stage, displayed to the moderator. + */ + title: string + + /** + * An optional description or additional text for the stage. + */ + text?: () => Promise + + /** + * Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title. + */ + id?: string + + /** + * Optional icon for the stage, displayed in the stage list and next to the title. + */ + icon?: FunctionalComponent + + /** + * URL to the guidance document for this stage. + */ + guidance_url: string + + /** + * An array of actions that can be taken in this stage. + */ + actions: Action[] + + /** + * Optional navigation path to redirect the moderator when this stage is shown. + * + * This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`. + */ + navigate?: string + + /** + * A function that determines whether this stage should be shown for a given project. + * + * By default, it returns `true`, meaning the stage is always shown. + */ + shouldShow?: (project: Project) => boolean +} diff --git a/packages/moderation/utils.ts b/packages/moderation/utils.ts new file mode 100644 index 000000000..48f843968 --- /dev/null +++ b/packages/moderation/utils.ts @@ -0,0 +1,298 @@ +import type { Project } from '@modrinth/utils' +import type { + Action, + AdditionalTextInput, + ButtonAction, + ConditionalMessage, + ToggleAction, +} from './types/actions' + +export interface ActionState { + selected: boolean + value?: Set | number | string | unknown +} + +export interface MessagePart { + weight: number + content: string + actionId: string + stageIndex: number +} + +export type SerializedActionState = { + isSet?: boolean +} & ActionState + +export function getActionIdForStage( + action: Action, + stageIndex: number, + actionIndex?: number, + enabledIndex?: number, +): string { + if (action.id) { + return `stage-${stageIndex}-${action.id}` + } + const suffix = enabledIndex !== undefined ? `-enabled-${enabledIndex}` : '' + return `stage-${stageIndex}-action-${actionIndex}${suffix}` +} + +export function getActionId(action: Action, currentStage: number, index?: number): string { + return getActionIdForStage(action, currentStage, index) +} + +export function getActionKey( + action: Action, + currentStage: number, + visibleActions: Action[], +): string { + const index = visibleActions.indexOf(action) + return `${currentStage}-${index}-${getActionId(action, currentStage)}` +} + +export function serializeActionStates(states: Record): string { + const serializable: Record = {} + for (const [key, state] of Object.entries(states)) { + serializable[key] = { + selected: state.selected, + value: state.value instanceof Set ? Array.from(state.value) : state.value, + isSet: state.value instanceof Set, + } + } + return JSON.stringify(serializable) +} + +export function deserializeActionStates(data: string): Record { + try { + const parsed = JSON.parse(data) + const states: Record = {} + for (const [key, state] of Object.entries(parsed as Record)) { + states[key] = { + selected: state.selected, + value: state.isSet ? new Set(state.value as unknown[]) : state.value, + } + } + return states + } catch { + return {} + } +} + +export function initializeActionState(action: Action): ActionState { + if (action.type === 'toggle') { + return { + selected: action.defaultChecked || false, + } + } else if (action.type === 'dropdown') { + return { + selected: true, + value: action.defaultOption || 0, + } + } else if (action.type === 'multi-select-chips') { + return { + selected: false, + value: new Set(), + } + } else { + return { + selected: false, + } + } +} + +export function processMessage( + message: string, + action: Action, + stageIndex: number, + textInputValues: Record, +): string { + let processedMessage = message + + if (action.relevantExtraInput) { + action.relevantExtraInput.forEach((input, index) => { + if (input.variable) { + const inputKey = `stage-${stageIndex}-${action.id || `action-${index}`}-${index}` + const value = textInputValues[inputKey] || '' + + const regex = new RegExp(`%${input.variable}%`, 'g') + processedMessage = processedMessage.replace(regex, value) + } + }) + } + + return processedMessage +} + +export function findMatchingVariant( + variants: ConditionalMessage[], + selectedActionIds: string[], + allValidActionIds?: string[], + currentStageIndex?: number, +): ConditionalMessage | null { + for (const variant of variants) { + const conditions = variant.conditions + + const meetsRequired = + !conditions.requiredActions || + conditions.requiredActions.every((id) => { + let fullId = id + if (currentStageIndex !== undefined && !id.startsWith('stage-')) { + fullId = `stage-${currentStageIndex}-${id}` + } + + if (allValidActionIds && !allValidActionIds.includes(fullId)) { + return false + } + return selectedActionIds.includes(fullId) + }) + + const meetsExcluded = + !conditions.excludedActions || + !conditions.excludedActions.some((id) => { + let fullId = id + if (currentStageIndex !== undefined && !id.startsWith('stage-')) { + fullId = `stage-${currentStageIndex}-${id}` + } + return selectedActionIds.includes(fullId) + }) + + if (meetsRequired && meetsExcluded) { + return variant + } + } + + return null +} + +export async function getActionMessage( + action: ButtonAction | ToggleAction, + selectedActionIds: string[], + allValidActionIds?: string[], +): Promise { + if (action.conditionalMessages && action.conditionalMessages.length > 0) { + const matchingConditional = findMatchingVariant( + action.conditionalMessages, + selectedActionIds, + allValidActionIds, + ) + if (matchingConditional) { + return (await matchingConditional.message()) as string + } + } + + return (await action.message()) as string +} + +export function getVisibleInputs( + action: Action, + actionStates: Record, +): AdditionalTextInput[] { + if (!action.relevantExtraInput) return [] + + const selectedActionIds = Object.entries(actionStates) + .filter(([, state]) => state.selected) + .map(([id]) => id) + + return action.relevantExtraInput.filter((input) => { + if (!input.showWhen) return true + + const meetsRequired = + !input.showWhen.requiredActions || + input.showWhen.requiredActions.every((id) => selectedActionIds.includes(id)) + + const meetsExcluded = + !input.showWhen.excludedActions || + !input.showWhen.excludedActions.some((id) => selectedActionIds.includes(id)) + + return meetsRequired && meetsExcluded + }) +} + +export function expandVariables( + template: string, + project: Project, + variables?: Record, +): string { + if (!variables) { + variables = flattenProjectVariables(project) + } + + return Object.entries(variables).reduce((result, [key, value]) => { + const variable = `%${key}%` + return result.replace(new RegExp(variable, 'g'), value) + }, template) +} + +export function kebabToTitleCase(input: string): string { + return input + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +export function flattenProjectVariables(project: Project): Record { + const vars: Record = {} + + vars['PROJECT_ID'] = project.id + vars['PROJECT_TYPE'] = project.project_type + vars['PROJECT_SLUG'] = project.slug + vars['PROJECT_TITLE'] = project.title + vars['PROJECT_DESCRIPTION'] = project.description + vars['PROJECT_STATUS'] = project.status + vars['PROJECT_REQUESTED_STATUS'] = project.requested_status + vars['PROJECT_MONETIZATION_STATUS'] = project.monetization_status + vars['PROJECT_BODY'] = project.body + + vars['PROJECT_ICON_URL'] = project.icon_url || '' + vars['PROJECT_ISSUES_URL'] = project.issues_url || '' + vars['PROJECT_SOURCE_URL'] = project.source_url || '' + vars['PROJECT_WIKI_URL'] = project.wiki_url || '' + vars['PROJECT_DISCORD_URL'] = project.discord_url || '' + + vars['PROJECT_DOWNLOADS'] = project.downloads.toString() + vars['PROJECT_FOLLOWERS'] = project.followers.toString() + vars['PROJECT_COLOR'] = project.color?.toString() || '' + + vars['PROJECT_CLIENT_SIDE'] = project.client_side + vars['PROJECT_SERVER_SIDE'] = project.server_side + + vars['PROJECT_TEAM'] = project.team + vars['PROJECT_THREAD_ID'] = project.thread_id + vars['PROJECT_ORGANIZATION'] = project.organization + + vars['PROJECT_PUBLISHED'] = project.published + vars['PROJECT_UPDATED'] = project.updated + vars['PROJECT_APPROVED'] = project.approved + vars['PROJECT_QUEUED'] = project.queued + + vars['PROJECT_LICENSE_ID'] = project.license.id + vars['PROJECT_LICENSE_NAME'] = project.license.name + vars['PROJECT_LICENSE_URL'] = project.license.url || '' + + vars['PROJECT_CATEGORIES'] = project.categories.join(', ') + vars['PROJECT_ADDITIONAL_CATEGORIES'] = project.additional_categories.join(', ') + vars['PROJECT_GAME_VERSIONS'] = project.game_versions.join(', ') + vars['PROJECT_LOADERS'] = project.loaders.join(', ') + vars['PROJECT_VERSIONS'] = project.versions.join(', ') + + vars['PROJECT_CATEGORIES_COUNT'] = project.categories.length.toString() + vars['PROJECT_GAME_VERSIONS_COUNT'] = project.game_versions.length.toString() + vars['PROJECT_LOADERS_COUNT'] = project.loaders.length.toString() + vars['PROJECT_VERSIONS_COUNT'] = project.versions.length.toString() + vars['PROJECT_GALLERY_COUNT'] = (project.gallery?.length || 0).toString() + vars['PROJECT_DONATION_URLS_COUNT'] = project.donation_urls.length.toString() + + project.donation_urls.forEach((donation, index) => { + vars[`PROJECT_DONATION_${index}_ID`] = donation.id + vars[`PROJECT_DONATION_${index}_PLATFORM`] = donation.platform + vars[`PROJECT_DONATION_${index}_URL`] = donation.url + }) + + project.gallery?.forEach((image, index) => { + vars[`PROJECT_GALLERY_${index}_URL`] = image.url + vars[`PROJECT_GALLERY_${index}_TITLE`] = image.title || '' + vars[`PROJECT_GALLERY_${index}_DESCRIPTION`] = image.description || '' + vars[`PROJECT_GALLERY_${index}_FEATURED`] = image.featured.toString() + }) + + return vars +} diff --git a/packages/moderation/vite-env.d.ts b/packages/moderation/vite-env.d.ts new file mode 100644 index 000000000..286f0d2d1 --- /dev/null +++ b/packages/moderation/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*.md?raw' { + const content: string + export default content +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 314dadf02..148dfe967 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,14 +30,21 @@ "@codemirror/view": "^6.22.1", "@modrinth/assets": "workspace:*", "@modrinth/utils": "workspace:*", + "@tresjs/cientos": "^4.3.0", + "@tresjs/core": "^4.3.4", + "@tresjs/post-processing": "^2.4.0", "@types/markdown-it": "^14.1.1", + "@types/three": "^0.172.0", "@vintl/how-ago": "^3.0.1", + "@vueuse/core": "^11.1.0", "apexcharts": "^3.44.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "highlight.js": "^11.9.0", "markdown-it": "^13.0.2", + "postprocessing": "^6.37.6", "qrcode.vue": "^3.4.1", + "three": "^0.172.0", "vue-multiselect": "3.0.0", "vue-select": "4.0.0-beta.6", "vue-typed-virtual-list": "^1.0.10", diff --git a/packages/ui/src/components/base/DropdownSelect.vue b/packages/ui/src/components/base/DropdownSelect.vue index 0642c4b57..c87ef340b 100644 --- a/packages/ui/src/components/base/DropdownSelect.vue +++ b/packages/ui/src/components/base/DropdownSelect.vue @@ -103,6 +103,10 @@ const props = defineProps({ type: Function, default: undefined, }, + maxVisibleOptions: { + type: Number, + default: undefined, + }, }) function getOptionLabel(option) { @@ -263,7 +267,7 @@ const isChildOfDropdown = (element) => { .options { z-index: 10; - max-height: 18.75rem; + max-height: v-bind('maxVisibleOptions ? `calc(${maxVisibleOptions} * 3rem)` : "18.75rem"'); overflow-y: auto; box-shadow: var(--shadow-inset-sm), diff --git a/packages/ui/src/components/base/ErrorInformationCard.vue b/packages/ui/src/components/base/ErrorInformationCard.vue new file mode 100644 index 000000000..9edfc0263 --- /dev/null +++ b/packages/ui/src/components/base/ErrorInformationCard.vue @@ -0,0 +1,120 @@ + + + diff --git a/packages/ui/src/components/base/MarkdownEditor.vue b/packages/ui/src/components/base/MarkdownEditor.vue index 932f5870f..65e8b255d 100644 --- a/packages/ui/src/components/base/MarkdownEditor.vue +++ b/packages/ui/src/components/base/MarkdownEditor.vue @@ -254,6 +254,10 @@
@@ -397,9 +401,10 @@ onMounted(() => { const selection = view.state.selection.main const selectionText = view.state.doc.sliceString(selection.from, selection.to) - const linkText = selectionText ? selectionText : url - const linkMarkdown = `[${linkText}](${url})` - return markdownCommands.replaceSelection(view, linkMarkdown) + if (selectionText) { + const linkMarkdown = `[${selectionText}](${url})` + return markdownCommands.replaceSelection(view, linkMarkdown) + } } // Check if the length of the document is greater than the max length. If it is, prevent the paste. diff --git a/packages/ui/src/components/base/OverflowMenu.vue b/packages/ui/src/components/base/OverflowMenu.vue index 59316c5f8..177257e43 100644 --- a/packages/ui/src/components/base/OverflowMenu.vue +++ b/packages/ui/src/components/base/OverflowMenu.vue @@ -87,7 +87,7 @@ interface Item extends BaseOption { tooltip?: string } -type Option = Divider | Item +export type Option = Divider | Item withDefaults( defineProps<{ diff --git a/packages/ui/src/components/base/ScrollablePanel.vue b/packages/ui/src/components/base/ScrollablePanel.vue index 35766efd4..15f7e5355 100644 --- a/packages/ui/src/components/base/ScrollablePanel.vue +++ b/packages/ui/src/components/base/ScrollablePanel.vue @@ -55,6 +55,7 @@ onUnmounted(() => { } }) function updateFade(scrollTop, offsetHeight, scrollHeight) { + console.log(scrollTop, offsetHeight, scrollHeight) scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight scrollableAtTop.value = scrollTop <= 0 } @@ -64,6 +65,18 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) { diff --git a/packages/ui/src/components/skin/CapeButton.vue b/packages/ui/src/components/skin/CapeButton.vue new file mode 100644 index 000000000..cdeafd4df --- /dev/null +++ b/packages/ui/src/components/skin/CapeButton.vue @@ -0,0 +1,108 @@ + + + + diff --git a/packages/ui/src/components/skin/CapeLikeTextButton.vue b/packages/ui/src/components/skin/CapeLikeTextButton.vue new file mode 100644 index 000000000..9d8ebbd6f --- /dev/null +++ b/packages/ui/src/components/skin/CapeLikeTextButton.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinButton.vue b/packages/ui/src/components/skin/SkinButton.vue new file mode 100644 index 000000000..763627e52 --- /dev/null +++ b/packages/ui/src/components/skin/SkinButton.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinLikeTextButton.vue b/packages/ui/src/components/skin/SkinLikeTextButton.vue new file mode 100644 index 000000000..de174234a --- /dev/null +++ b/packages/ui/src/components/skin/SkinLikeTextButton.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinPreviewRenderer.vue b/packages/ui/src/components/skin/SkinPreviewRenderer.vue new file mode 100644 index 000000000..883bdf510 --- /dev/null +++ b/packages/ui/src/components/skin/SkinPreviewRenderer.vue @@ -0,0 +1,628 @@ + + + + + diff --git a/packages/ui/src/composables/dynamic-font-size.ts b/packages/ui/src/composables/dynamic-font-size.ts new file mode 100644 index 000000000..af1cf9ed3 --- /dev/null +++ b/packages/ui/src/composables/dynamic-font-size.ts @@ -0,0 +1,117 @@ +import { computed, onMounted, onUnmounted, type Ref } from 'vue' +import { useElementSize } from '@vueuse/core' + +export interface DynamicFontSizeOptions { + containerElement: Ref + text: Ref + baseFontSize?: number + minFontSize?: number + maxFontSize?: number + availableWidthRatio?: number + maxContainerWidth?: number + padding?: number + fontFamily?: string + fontWeight?: string | number +} + +export function useDynamicFontSize(options: DynamicFontSizeOptions) { + const { + containerElement, + text, + baseFontSize = 1.25, + minFontSize = 0.75, + maxFontSize = 2, + availableWidthRatio = 0.9, + maxContainerWidth = 400, + padding = 24, + fontFamily = 'inherit', + fontWeight = 'inherit', + } = options + + const { width: containerWidth } = useElementSize(containerElement) + let measurementElement: HTMLElement | null = null + + const createMeasurementElement = () => { + if (measurementElement) return measurementElement + + measurementElement = document.createElement('div') + measurementElement.style.cssText = ` + position: absolute; + top: -9999px; + left: -9999px; + opacity: 0; + pointer-events: none; + white-space: nowrap; + font-family: ${fontFamily}; + font-weight: ${fontWeight}; + ` + measurementElement.setAttribute('aria-hidden', 'true') + document.body.appendChild(measurementElement) + + return measurementElement + } + + const cleanupMeasurementElement = () => { + if (measurementElement?.parentNode) { + measurementElement.parentNode.removeChild(measurementElement) + measurementElement = null + } + } + + const measureTextWidth = (textContent: string, fontSize: number): number => { + if (!textContent) return 0 + + const element = createMeasurementElement() + element.style.fontSize = `${fontSize}rem` + element.textContent = textContent + + return element.getBoundingClientRect().width + } + + const findOptimalFontSize = (textContent: string, availableWidth: number): number => { + let low = minFontSize + let high = maxFontSize + let bestSize = minFontSize + + const maxWidth = measureTextWidth(textContent, maxFontSize) + if (maxWidth <= availableWidth) return maxFontSize + + for (let i = 0; i < 8; i++) { + const mid = (low + high) / 2 + const width = measureTextWidth(textContent, mid) + + if (width <= availableWidth) { + bestSize = mid + low = mid + } else { + high = mid + } + + if (high - low < 0.01) break + } + + return Math.max(bestSize, minFontSize) + } + + const fontSize = computed(() => { + if (!text.value || !containerWidth.value) return `${baseFontSize}rem` + + const availableWidth = + Math.min(containerWidth.value * availableWidthRatio, maxContainerWidth) - padding + + const baseWidth = measureTextWidth(text.value, baseFontSize) + if (baseWidth <= availableWidth) return `${baseFontSize}rem` + + const optimalSize = findOptimalFontSize(text.value, availableWidth) + return `${optimalSize}rem` + }) + + onMounted(createMeasurementElement) + onUnmounted(cleanupMeasurementElement) + + return { + fontSize, + containerWidth, + cleanup: cleanupMeasurementElement, + } +} diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index de29c5771..a84dc7fa2 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -1 +1,2 @@ export * from './how-ago' +export * from './dynamic-font-size' diff --git a/packages/ui/src/composables/stripe.ts b/packages/ui/src/composables/stripe.ts index 02b6d2f1e..e3ed0ca94 100644 --- a/packages/ui/src/composables/stripe.ts +++ b/packages/ui/src/composables/stripe.ts @@ -31,6 +31,7 @@ export const useStripe = ( product: Ref, interval: Ref, region: Ref, + project: Ref, initiatePayment: ( body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest, ) => Promise, @@ -222,16 +223,22 @@ export const useStripe = ( let result: BasePaymentIntentResponse + const metadata: CreatePaymentIntentRequest['metadata'] = { + type: 'pyro', + server_region: region.value, + source: project.value + ? { + project_id: project.value, + } + : {}, + } + if (paymentIntentId.value) { result = await updateIntent({ ...requestType, charge, existing_payment_intent: paymentIntentId.value, - metadata: { - type: 'pyro', - server_region: region.value, - source: {}, - }, + metadata, }) console.log(`Updated payment intent: ${interval.value} for ${result.total}`) } else { @@ -242,11 +249,7 @@ export const useStripe = ( } = await createIntent({ ...requestType, charge, - metadata: { - type: 'pyro', - server_region: region.value, - source: {}, - }, + metadata: metadata, })) console.log(`Created payment intent: ${interval.value} for ${result.total}`) } diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index e60885082..23e95048c 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -512,12 +512,12 @@ "servers.purchase.step.review.title": { "defaultMessage": "Review" }, + "servers.region.central-europe": { + "defaultMessage": "Central Europe" + }, "servers.region.custom.prompt": { "defaultMessage": "How much RAM do you want your server to have?" }, - "servers.region.europe": { - "defaultMessage": "Europe" - }, "servers.region.north-america": { "defaultMessage": "North America" }, @@ -527,6 +527,9 @@ "servers.region.region-unsupported": { "defaultMessage": "Region not listed? Let us know where you'd like to see Modrinth Servers next!" }, + "servers.region.western-europe": { + "defaultMessage": "Western Europe" + }, "settings.account.title": { "defaultMessage": "Account and security" }, diff --git a/packages/ui/src/utils/billing.ts b/packages/ui/src/utils/billing.ts index da2c7c0c6..e07f71de1 100644 --- a/packages/ui/src/utils/billing.ts +++ b/packages/ui/src/utils/billing.ts @@ -1,7 +1,14 @@ import type Stripe from 'stripe' +import type { Loaders } from '@modrinth/utils' export type ServerBillingInterval = 'monthly' | 'yearly' | 'quarterly' +export const monthsInInterval: Record = { + monthly: 1, + quarterly: 3, + yearly: 12, +} + export interface ServerPlan { id: string name: string @@ -72,11 +79,18 @@ export type CreatePaymentIntentRequest = PaymentRequestType & { type: 'pyro' server_name?: string server_region?: string - source: { - loader?: string - game_version?: string - loader_version?: string - } + source: + | { + loader: Loaders + game_version?: string + loader_version?: string + } + | { + project_id: string + version_id?: string + } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + | {} } } diff --git a/packages/ui/src/utils/regions.ts b/packages/ui/src/utils/regions.ts index 5167a2911..4401734fb 100644 --- a/packages/ui/src/utils/regions.ts +++ b/packages/ui/src/utils/regions.ts @@ -6,11 +6,11 @@ export const regionOverrides = { flag: 'https://flagcdn.com/us.svg', }, 'eu-lim': { - name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }), - flag: 'https://flagcdn.com/eu.svg', + name: defineMessage({ id: 'servers.region.central-europe', defaultMessage: 'Central Europe' }), + flag: 'https://flagcdn.com/de.svg', }, - 'de-fra': { - name: defineMessage({ id: 'servers.region.europe', defaultMessage: 'Europe' }), - flag: 'https://flagcdn.com/eu.svg', + 'eu-cov': { + name: defineMessage({ id: 'servers.region.western-europe', defaultMessage: 'Western Europe' }), + flag: 'https://flagcdn.com/gb.svg', }, } satisfies Record diff --git a/packages/ui/src/vue-shims.d.ts b/packages/ui/src/vue-shims.d.ts index 41c2ecce6..aae8c737a 100644 --- a/packages/ui/src/vue-shims.d.ts +++ b/packages/ui/src/vue-shims.d.ts @@ -4,3 +4,8 @@ declare module '*.vue' { const component: ReturnType export default component } + +declare module '*.glsl' { + const value: string + export default value +} diff --git a/packages/utils/changelog.ts b/packages/utils/changelog.ts index edcca4c6d..af5284ce0 100644 --- a/packages/utils/changelog.ts +++ b/packages/utils/changelog.ts @@ -10,6 +10,198 @@ export type VersionEntry = { } const VERSIONS: VersionEntry[] = [ + { + date: `2025-07-09T22:15:00-07:00`, + product: 'web', + body: `### Improvements +- Fixed pasted links being unnecessarily wrapped in Markdown formatting in Markdown editor. +- Added a security.txt file to the site. +- Changed the Europe location for Modrinth Servers to show as Central Europe with the flag of Germany to reflect its location better.`, + }, + { + date: `2025-07-08T14:00:00-07:00`, + product: 'web', + body: `### Improvements +- Fixed Modrinth Servers showing as out of stock when navigating to the page directly.`, + }, + { + date: `2025-07-08T11:10:00-07:00`, + product: 'servers', + body: `### Improvements +- Reapplied error handling improvements, with more improvements.`, + }, + { + date: `2025-07-07T22:20:00-07:00`, + product: 'servers', + body: `### Improvements +- Fixed issue with Servers panel failing to load.`, + }, + { + date: `2025-07-07T17:45:00-07:00`, + product: 'servers', + body: `### Improvements +- Reverted error handling improvements.`, + }, + { + date: `2025-07-07T01:10:00-07:00`, + product: 'app', + version: `0.10.3`, + body: `### Improvements +- Added a workaround for Java 8 instances failing to load. + +### Known issues +- Java installations will show as 'Failed' when you test them. This is a visual bug, and does not mean the Java installation is not working.`, + }, + { + date: `2025-07-06T16:30:00-07:00`, + product: 'app', + version: `0.10.2`, + body: `### Improvements +- Added additional default skins from free official Minecraft skin packs. +- Fixed some parts of the player model on Skins page rendering incorrectly. +- Fixed a number of issues with skin images not loading on macOS. +- Fixed old Forge versions not loading properly. +- Fixed a typo in Appearance settings for hiding Skins page nametag. + +### Known issues +- Java installations will show as 'Failed' when you test them. This is a visual bug, and does not mean the Java installation is not working.`, + }, + { + date: `2025-07-05T12:00:00-07:00`, + product: 'app', + version: `0.10.1`, + body: `### Improvements +- News section will now only show up to 4 articles. +- Fixed critical issue with updating on Windows. +- Fixed search being broken after a query that yields no results. +- Fixed 'Jump back in' section on Home page not working. +- Fixed too many Quick Instance items on the sidebar causing the UI to overflow.`, + }, + { + date: `2025-07-04T12:00:00-07:00`, + product: 'app', + version: `0.10.0`, + body: `**Note: This update is no longer available to download due to issues, you should use v0.10.1** + +### Added +- Added Skins page as a beta feature. There may be some minor bugs with it, but we'd love to get user feedback on this feature as it's been one of our most highly requested features. + - Save as many of your own skins as you'd like to swap between them at any moment. + - Pick a default cape, or override the cape on any of your saved skin profiles to tailor each look perfectly. + - Choose between any of the default Minecraft skins. + +### Improvements +- Updated News section to pull data from our new custom news feed. +- Fixed videos from GitHub not working in project descriptions. +- Fixed data related to a world not being deleted from the database when the world was deleted. +- Standardized relative date timestamps across the app. +- Fixed 'Reset icon' button for Singleplayer worlds state not being reset when opening the Edit interface. +- Fixed 'Repair' button showing while an instance is installing. +- Fixed instances with non-UTF8 text files failing to launch or import. +- Fixed launch hooks being unable to be cleared on an instance. +- Fixed search results breaking if page number goes out of bounds. +- Fixed servers running old Minecraft versions not showing last played time.`, + }, + { + date: `2025-07-04T12:00:00-07:00`, + product: 'web', + body: `### Changed +- Changed fallback ad placeholder from promoting Modrinth+ to Modrinth Servers. +- Fixed news section rendering incorrectly in light mode on landing page and Modrinth App page.`, + }, + { + date: `2025-06-30T19:15:00-07:00`, + product: 'web', + body: `### Added +- Added news page, with all our old blog posts now hosted on our website. + +### Improvements +- Changed download count rounding to be more precise. +- Fixed Creator Monetization Program page to show accurate information again.`, + }, + { + date: `2025-06-30T19:15:00-07:00`, + product: 'servers', + body: `### Improvements +- Progress will now show when installing Modrinth Pack (.mrpack) files. +- Fixed storage stats not linking to Files page. +- Fixed missing icons in some places.`, + }, + { + date: `2025-06-29T16:30:00-07:00`, + product: 'web', + body: `### Improvements +- Removed ads for logged in users. +- Fixed tooltips being unreadable sometimes.`, + }, + { + date: `2025-06-26T11:00:00-07:00`, + product: 'servers', + body: `### Improvements +- Fixed support bubble overlapping notifications sometimes. +- Fixed race condition when creating backups.`, + }, + { + date: `2025-06-26T11:00:00-07:00`, + product: 'web', + body: `### Added +- Added a dismissable Modrinth Servers promotion to project Download interface to inform users of the service's availability. + +### Improvements +- Added colors for the newly added legacy mod loaders +- Improved file upload error message in some places.`, + }, + { + date: `2025-06-16T11:00:00-07:00`, + product: 'web', + body: `### Improvements +- Rolled out hotfixes with the previous days' updates. +- Failed subscriptions can now be cancelled.`, + }, + { + date: `2025-06-16T11:00:00-07:00`, + product: 'servers', + body: `### Improvements +- Improved error handling. +- Rolled out hotfixes with the previous days' updates.'`, + }, + { + date: `2025-06-15T16:25:00-07:00`, + product: 'servers', + body: `### Improvements +- Fixed installing modpacks from search. +- Fixed setting subdomains.`, + }, + { + date: `2025-06-15T14:30:00-07:00`, + product: 'servers', + body: `### Improvements +- Fixed various issues with the panel loading improperly in certain cases. +- Fixed CPU icon being smaller than the rest. +- Server panel performance should be a little faster now.`, + }, + { + date: `2025-06-15T14:30:00-07:00`, + product: 'web', + body: `### Improvements +- Creator analytics charts will now show up to 15 projects in a tooltip instead of 5. +- Made certain scrollable containers not have a fixed height, and allow them to be smaller if they have fewer items. (Contributed by [Erb3](https://github.com/modrinth/code/pull/2898)) +- Made organizations sort consistently alphabetically. (Contributed by [WorldWidePixel](https://github.com/modrinth/code/pull/3755)) +- Clarified the 'File too large' error message when uploading an image larger than 1MiB in the text editor. (Contributed by [IThundxr](https://github.com/modrinth/code/pull/3774))`, + }, + { + date: `2025-06-03T14:35:00-07:00`, + product: 'servers', + body: `### Added +- Added support for servers in Europe. +- Added server setup for new servers upon opening the panel for the first time.`, + }, + { + date: `2025-06-03T14:35:00-07:00`, + product: 'web', + body: `### Improvements +- Overhauled Modrinth Servers purchase flow. +- Added the ability to donate creator rewards to charity.`, + }, { date: `2025-05-08T09:00:00-07:00`, product: 'servers', diff --git a/packages/utils/index.ts b/packages/utils/index.ts index dc3e767c9..9369aa69f 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -8,3 +8,4 @@ export * from './types' export * from './users' export * from './utils' export * from './servers' +export * from './three/skin-rendering' diff --git a/packages/utils/package.json b/packages/utils/package.json index 5e6d89c54..d603ddd2c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -20,10 +20,12 @@ "@codemirror/state": "^6.3.2", "@codemirror/view": "^6.22.1", "@types/markdown-it": "^14.1.1", + "@types/three": "^0.172.0", "dayjs": "^1.11.10", "highlight.js": "^11.9.0", "markdown-it": "^14.1.0", "ofetch": "^1.3.4", + "three": "^0.172.0", "xss": "^1.0.14" } } diff --git a/packages/utils/servers/errors/modrinth-server-error.ts b/packages/utils/servers/errors/modrinth-server-error.ts index ece8825c8..c0a548a77 100644 --- a/packages/utils/servers/errors/modrinth-server-error.ts +++ b/packages/utils/servers/errors/modrinth-server-error.ts @@ -54,6 +54,6 @@ export class ModrinthServerError extends Error { } super(errorMessage) - this.name = 'PyroServersFetchError' + this.name = 'ModrinthServersFetchError' } } diff --git a/packages/utils/servers/errors/modrinth-servers-fetch-error.ts b/packages/utils/servers/errors/modrinth-servers-fetch-error.ts index ce01e737f..a2df7baa3 100644 --- a/packages/utils/servers/errors/modrinth-servers-fetch-error.ts +++ b/packages/utils/servers/errors/modrinth-servers-fetch-error.ts @@ -5,6 +5,6 @@ export class ModrinthServersFetchError extends Error { public originalError?: Error, ) { super(message) - this.name = 'PyroFetchError' + this.name = 'ModrinthFetchError' } } diff --git a/packages/utils/three/skin-rendering.ts b/packages/utils/three/skin-rendering.ts new file mode 100644 index 000000000..998785aff --- /dev/null +++ b/packages/utils/three/skin-rendering.ts @@ -0,0 +1,177 @@ +import * as THREE from 'three' +import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' + +export interface SkinRendererConfig { + textureColorSpace?: THREE.ColorSpace + textureFlipY?: boolean + textureMagFilter?: THREE.MagnificationTextureFilter + textureMinFilter?: THREE.MinificationTextureFilter +} + +const modelCache: Map = new Map() +const textureCache: Map = new Map() + +export async function loadModel(modelUrl: string): Promise { + if (modelCache.has(modelUrl)) { + return modelCache.get(modelUrl)! + } + + const loader = new GLTFLoader() + return new Promise((resolve, reject) => { + loader.load( + modelUrl, + (gltf) => { + modelCache.set(modelUrl, gltf) + resolve(gltf) + }, + undefined, + reject, + ) + }) +} + +export async function loadTexture( + textureUrl: string, + config: SkinRendererConfig = {}, +): Promise { + const cacheKey = `${textureUrl}_${JSON.stringify(config)}` + + if (textureCache.has(cacheKey)) { + return textureCache.get(cacheKey)! + } + + return new Promise((resolve) => { + const textureLoader = new THREE.TextureLoader() + textureLoader.load(textureUrl, (texture) => { + texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace + texture.flipY = config.textureFlipY ?? false + texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter + texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter + + textureCache.set(cacheKey, texture) + resolve(texture) + }) + }) +} + +export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void { + model.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + + materials.forEach((mat: THREE.Material) => { + if (mat instanceof THREE.MeshStandardMaterial) { + if (mat.name !== 'cape') { + mat.map = texture + mat.metalness = 0 + mat.color.set(0xffffff) + mat.toneMapped = false + mat.flatShading = true + mat.roughness = 1 + mat.needsUpdate = true + mat.depthTest = true + mat.side = THREE.DoubleSide + mat.alphaTest = 0.1 + mat.depthWrite = true + } + } + }) + } + }) +} + +export function applyCapeTexture( + model: THREE.Object3D, + texture: THREE.Texture | null, + transparentTexture?: THREE.Texture, +): void { + model.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + + materials.forEach((mat: THREE.Material) => { + if (mat instanceof THREE.MeshStandardMaterial) { + if (mat.name === 'cape') { + mat.map = texture || transparentTexture || null + mat.transparent = !texture || transparentTexture ? true : false + mat.metalness = 0 + mat.color.set(0xffffff) + mat.toneMapped = false + mat.flatShading = true + mat.roughness = 1 + mat.needsUpdate = true + mat.depthTest = true + mat.depthWrite = true + mat.side = THREE.DoubleSide + mat.alphaTest = 0.1 + mat.visible = !!texture + } + } + }) + } + }) +} + +export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null { + let bodyNode: THREE.Object3D | null = null + + model.traverse((node) => { + if (node.name === 'Body') { + bodyNode = node + } + }) + + return bodyNode +} + +export function createTransparentTexture(): THREE.Texture { + const canvas = document.createElement('canvas') + canvas.width = canvas.height = 1 + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D + ctx.clearRect(0, 0, 1, 1) + + const texture = new THREE.CanvasTexture(canvas) + texture.needsUpdate = true + texture.colorSpace = THREE.SRGBColorSpace + texture.flipY = false + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + + return texture +} + +export async function setupSkinModel( + modelUrl: string, + textureUrl: string, + capeTextureUrl?: string, + config: SkinRendererConfig = {}, +): Promise<{ + model: THREE.Object3D + bodyNode: THREE.Object3D | null +}> { + const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)]) + + const model = gltf.scene.clone() + applyTexture(model, texture) + + if (capeTextureUrl) { + const capeTexture = await loadTexture(capeTextureUrl, config) + applyCapeTexture(model, capeTexture) + } + + const bodyNode = findBodyNode(model) + + return { model, bodyNode } +} + +export function disposeCaches(): void { + Array.from(textureCache.values()).forEach((texture) => { + texture.dispose() + }) + + textureCache.clear() + modelCache.clear() +} diff --git a/packages/utils/types.ts b/packages/utils/types.ts index b5ccbd503..437fda2d6 100644 --- a/packages/utils/types.ts +++ b/packages/utils/types.ts @@ -294,3 +294,71 @@ export type Report = { created: string body: string } + +// Moderation +export interface ModerationModpackPermissionApprovalType { + id: + | 'yes' + | 'no' + | 'with-attribution' + | 'unidentified' + | 'with-attribution-and-source' + | 'permanent-no' + name: string +} + +export interface ModerationPermissionType { + id: 'yes' | 'no' + name: string +} + +export interface ModerationBaseModpackItem { + sha1: string + file_name: string + type: 'unknown' | 'flame' + status: ModerationModpackPermissionApprovalType['id'] | null + approved: ModerationPermissionType['id'] | null +} + +export interface ModerationUnknownModpackItem extends ModerationBaseModpackItem { + type: 'unknown' + proof: string + url: string + title: string +} + +export interface ModerationFlameModpackItem extends ModerationBaseModpackItem { + type: 'flame' + id: string + title: string + url: string +} + +export type ModerationModpackItem = ModerationUnknownModpackItem | ModerationFlameModpackItem + +export interface ModerationModpackResponse { + unknown_files?: Record + flame_files?: Record< + string, + { + file_name: string + id: string + title?: string + url?: string + } + > +} + +export interface ModerationJudgement { + type: 'flame' | 'unknown' + status: string + id?: string + link?: string + title?: string + proof?: string + file_name?: string +} + +export interface ModerationJudgements { + [sha1: string]: ModerationJudgement +} diff --git a/packages/utils/utils.ts b/packages/utils/utils.ts index 546358c3f..937827c85 100644 --- a/packages/utils/utils.ts +++ b/packages/utils/utils.ts @@ -185,6 +185,12 @@ export const formatCategory = (name) => { return 'Java Agent' } else if (name === 'nilloader') { return 'NilLoader' + } else if (name === 'mrpack') { + return 'Modpack' + } else if (name === 'minecraft') { + return 'Resource Pack' + } else if (name === 'vanilla') { + return 'Vanilla Shader' } return capitalizeString(name) } @@ -362,3 +368,8 @@ export function getPingLevel(ping: number) { return 1 } } + +export function arrayBufferToBase64(buffer: Uint8Array | ArrayBuffer): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + return btoa(String.fromCharCode(...bytes)) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fdd34863..38e1b869c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,15 +13,18 @@ importers: .: devDependencies: + if-ci: + specifier: ^3.0.0 + version: 3.0.0 prettier: specifier: ^3.3.2 version: 3.3.2 turbo: - specifier: ^2.2.3 - version: 2.2.3 + specifier: ^2.5.4 + version: 2.5.4 vue: specifier: ^3.5.13 - version: 3.5.13(typescript@5.8.2) + version: 3.5.13(typescript@5.8.3) apps/app: dependencies: @@ -62,6 +65,9 @@ importers: '@tauri-apps/plugin-dialog': specifier: ^2.2.1 version: 2.2.1 + '@tauri-apps/plugin-http': + specifier: ^2.5.0 + version: 2.5.0 '@tauri-apps/plugin-opener': specifier: ^2.2.6 version: 2.2.6 @@ -74,15 +80,21 @@ importers: '@tauri-apps/plugin-window-state': specifier: ^2.2.2 version: 2.2.2 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 '@vintl/vintl': specifier: ^4.4.1 version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@vueuse/core': + specifier: ^11.1.0 + version: 11.1.0(vue@3.5.13(typescript@5.5.4)) dayjs: specifier: ^1.11.10 version: 1.11.11 floating-vue: specifier: ^5.2.2 - version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4)) + version: 5.2.2(@nuxt/kit@3.17.5(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)) ofetch: specifier: ^1.3.4 version: 1.4.1 @@ -92,6 +104,9 @@ importers: posthog-js: specifier: ^1.158.2 version: 1.158.2 + three: + specifier: ^0.172.0 + version: 0.172.0 vite-svg-loader: specifier: ^5.1.0 version: 5.1.0(vue@3.5.13(typescript@5.5.4)) @@ -110,31 +125,31 @@ importers: devDependencies: '@eslint/compat': specifier: ^1.1.1 - version: 1.2.1(eslint@9.13.0(jiti@2.4.1)) + version: 1.2.1(eslint@9.13.0(jiti@2.4.2)) '@formatjs/cli': specifier: ^6.2.12 version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4)) '@nuxt/eslint-config': specifier: ^0.5.6 - version: 0.5.7(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + version: 0.5.7(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@taijased/vue-render-tracker': specifier: ^1.0.7 version: 1.0.7(vue@3.5.13(typescript@5.5.4)) '@vitejs/plugin-vue': specifier: ^5.0.4 - version: 5.2.1(vite@5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4)) + version: 5.2.1(vite@5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4)) autoprefixer: specifier: ^10.4.19 version: 10.4.20(postcss@8.4.49) eslint: specifier: ^9.9.1 - version: 9.13.0(jiti@2.4.1) + version: 9.13.0(jiti@2.4.2) eslint-config-custom: specifier: workspace:* version: link:../../packages/eslint-config-custom eslint-plugin-turbo: - specifier: ^2.1.1 - version: 2.2.0(eslint@9.13.0(jiti@2.4.1)) + specifier: ^2.5.4 + version: 2.5.4(eslint@9.13.0(jiti@2.4.2))(turbo@2.5.4) postcss: specifier: ^8.4.39 version: 8.4.49 @@ -155,7 +170,7 @@ importers: version: 5.5.4 vite: specifier: ^5.4.6 - version: 5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6) + version: 5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0) vue-tsc: specifier: ^2.1.6 version: 2.1.6(typescript@5.5.4) @@ -172,22 +187,22 @@ importers: dependencies: '@astrojs/check': specifier: ^0.9.4 - version: 0.9.4(prettier@3.3.2)(typescript@5.8.2) + version: 0.9.4(prettier@3.6.2)(typescript@5.8.2) '@astrojs/starlight': specifier: ^0.32.2 - version: 0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1)) + version: 0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0)) '@modrinth/assets': specifier: workspace:* version: link:../../packages/assets astro: specifier: ^5.4.1 - version: 5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1) + version: 5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0) sharp: specifier: ^0.33.5 version: 0.33.5 starlight-openapi: specifier: ^0.14.0 - version: 0.14.0(@astrojs/markdown-remark@6.2.0)(@astrojs/starlight@0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1)))(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1))(openapi-types@12.1.3) + version: 0.14.0(@astrojs/markdown-remark@6.2.0)(@astrojs/starlight@0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0)))(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0))(openapi-types@12.1.3) typescript: specifier: ^5.8.2 version: 5.8.2 @@ -206,6 +221,12 @@ importers: '@modrinth/assets': specifier: workspace:* version: link:../../packages/assets + '@modrinth/blog': + specifier: workspace:* + version: link:../../packages/blog + '@modrinth/moderation': + specifier: workspace:* + version: link:../../packages/moderation '@modrinth/ui': specifier: workspace:* version: link:../../packages/ui @@ -238,7 +259,7 @@ importers: version: 3.1.7 floating-vue: specifier: ^5.2.2 - version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(vue@3.5.13(typescript@5.5.4)) + version: 5.2.2(@nuxt/kit@3.17.5(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)) fuse.js: specifier: ^6.6.2 version: 6.6.2 @@ -263,6 +284,9 @@ importers: pinia: specifier: ^2.1.7 version: 2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 qrcode.vue: specifier: ^3.4.0 version: 3.4.1(vue@3.5.13(typescript@5.5.4)) @@ -293,7 +317,7 @@ importers: version: 6.2.12(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4)) '@nuxt/devtools': specifier: ^1.3.3 - version: 1.6.3(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4)) + version: 1.6.3(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4)) '@types/dompurify': specifier: ^3.0.5 version: 3.0.5 @@ -308,7 +332,7 @@ importers: version: 3.0.1(@formatjs/intl@2.10.4(typescript@5.5.4)) '@vintl/nuxt': specifier: ^1.9.2 - version: 1.9.2(@vue/compiler-core@3.5.13)(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1) + version: 1.9.2(@vue/compiler-core@3.5.13)(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1) autoprefixer: specifier: ^10.4.19 version: 10.4.20(postcss@8.4.49) @@ -320,13 +344,13 @@ importers: version: 10.4.2 nuxt: specifier: ^3.14.1592 - version: 3.14.1592(@parcel/watcher@2.4.1)(@types/node@20.14.11)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.31.6)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue-tsc@2.1.6(typescript@5.5.4)) + version: 3.14.1592(@parcel/watcher@2.4.1)(@types/node@20.14.11)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.42.0)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue-tsc@2.1.6(typescript@5.5.4))(xml2js@0.6.2) postcss: specifier: ^8.4.39 version: 8.4.49 prettier-plugin-tailwindcss: specifier: ^0.6.5 - version: 0.6.5(prettier@3.3.2) + version: 0.6.5(prettier@3.6.2) sass: specifier: ^1.58.0 version: 1.77.6 @@ -355,12 +379,58 @@ importers: eslint-config-custom: specifier: workspace:* version: link:../eslint-config-custom + jiti: + specifier: ^2.4.2 + version: 2.4.2 tsconfig: specifier: workspace:* version: link:../tsconfig vue: specifier: ^3.5.13 - version: 3.5.13(typescript@5.8.2) + version: 3.5.13(typescript@5.8.3) + + packages/blog: + dependencies: + '@modrinth/utils': + specifier: workspace:* + version: link:../utils + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + html-minifier-terser: + specifier: ^7.2.0 + version: 7.2.0 + rss: + specifier: ^1.2.2 + version: 1.2.2 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + '@types/html-minifier-terser': + specifier: ^7.0.2 + version: 7.0.2 + '@types/rss': + specifier: ^0.0.32 + version: 0.0.32 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-custom: + specifier: workspace:* + version: link:../eslint-config-custom + jiti: + specifier: ^2.4.2 + version: 2.4.2 + tsconfig: + specifier: workspace:* + version: link:../tsconfig packages/daedalus: {} @@ -368,26 +438,48 @@ importers: devDependencies: '@nuxtjs/eslint-config-typescript': specifier: ^12.1.0 - version: 12.1.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + version: 12.1.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@vue/eslint-config-typescript': specifier: ^13.0.0 - version: 13.0.0(eslint-plugin-vue@9.29.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + version: 13.0.0(eslint-plugin-vue@9.29.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.0(eslint@9.13.0(jiti@2.4.1)) + version: 9.1.0(eslint@9.13.0(jiti@2.4.2)) eslint-config-turbo: specifier: ^2.0.7 - version: 2.0.7(eslint@9.13.0(jiti@2.4.1)) + version: 2.0.7(eslint@9.13.0(jiti@2.4.2)) eslint-plugin-prettier: specifier: ^5.2.1 - version: 5.2.1(@types/eslint@9.6.0)(eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))(prettier@3.3.2) + version: 5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))(prettier@3.6.2) eslint-plugin-unicorn: specifier: ^54.0.0 - version: 54.0.0(eslint@9.13.0(jiti@2.4.1)) + version: 54.0.0(eslint@9.13.0(jiti@2.4.2)) typescript: specifier: ^5.5.3 version: 5.5.4 + packages/moderation: + dependencies: + '@modrinth/assets': + specifier: workspace:* + version: link:../assets + '@modrinth/utils': + specifier: workspace:* + version: link:../utils + vue: + specifier: ^3.5.13 + version: 3.5.13(typescript@5.8.3) + devDependencies: + eslint: + specifier: ^8.57.0 + version: 8.57.0 + eslint-config-custom: + specifier: workspace:* + version: link:../eslint-config-custom + tsconfig: + specifier: workspace:* + version: link:../tsconfig + packages/tsconfig: devDependencies: '@vue/tsconfig': @@ -417,12 +509,27 @@ importers: '@modrinth/utils': specifier: workspace:* version: link:../utils + '@tresjs/cientos': + specifier: ^4.3.0 + version: 4.3.1(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(@types/three@0.172.0)(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@tresjs/core': + specifier: ^4.3.4 + version: 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@tresjs/post-processing': + specifier: ^2.4.0 + version: 2.4.0(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) '@types/markdown-it': specifier: ^14.1.1 version: 14.1.1 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 '@vintl/how-ago': specifier: ^3.0.1 version: 3.0.1(@formatjs/intl@2.10.4(typescript@5.5.4)) + '@vueuse/core': + specifier: ^11.1.0 + version: 11.1.0(vue@3.5.13(typescript@5.5.4)) apexcharts: specifier: ^3.44.0 version: 3.49.2 @@ -431,16 +538,22 @@ importers: version: 1.11.11 floating-vue: specifier: ^5.2.2 - version: 5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@3.29.4))(vue@3.5.13(typescript@5.5.4)) + version: 5.2.2(@nuxt/kit@3.17.5(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)) highlight.js: specifier: ^11.9.0 version: 11.9.0 markdown-it: specifier: ^13.0.2 version: 13.0.2 + postprocessing: + specifier: ^6.37.6 + version: 6.37.6(three@0.172.0) qrcode.vue: specifier: ^3.4.1 version: 3.4.1(vue@3.5.13(typescript@5.5.4)) + three: + specifier: ^0.172.0 + version: 0.172.0 vue-multiselect: specifier: 3.0.0 version: 3.0.0 @@ -465,7 +578,7 @@ importers: version: 7.3.1 '@vintl/unplugin': specifier: ^1.5.1 - version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1) + version: 1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1) '@vintl/vintl': specifier: ^4.4.1 version: 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) @@ -511,6 +624,9 @@ importers: '@types/markdown-it': specifier: ^14.1.1 version: 14.1.1 + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 dayjs: specifier: ^1.11.10 version: 1.11.11 @@ -523,6 +639,9 @@ importers: ofetch: specifier: ^1.3.4 version: 1.4.1 + three: + specifier: ^0.172.0 + version: 0.172.0 xss: specifier: ^1.0.14 version: 1.0.15 @@ -543,6 +662,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@alvarosabu/utils@3.2.0': + resolution: {integrity: sha512-aoGWRfaQjOo9TUwrBA6W0zwTHktgrXy69GIFNILT4gHsqscw6+X8P6uoSlZVQFr887SPm8x3aDin5EBVq8y4pw==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -1836,6 +1958,10 @@ packages: resolution: {integrity: sha512-r9r8bISBBisvfcNgNL3dSIQHSBe0v5YkX5zwNblIC2T0CIEgxEVoM5rq9O5wqgb5OEydsHTtT2hL57vdv6VT2w==} engines: {node: ^14.18.0 || >=16.10.0} + '@nuxt/kit@3.17.5': + resolution: {integrity: sha512-NdCepmA+S/SzgcaL3oYUeSlXGYO6BXGr9K/m1D0t0O9rApF8CSq/QQ+ja5KYaYMO1kZAEWH4s2XVcE3uPrrAVg==} + engines: {node: '>=18.12.0'} + '@nuxt/schema@3.14.1592': resolution: {integrity: sha512-A1d/08ueX8stTXNkvGqnr1eEXZgvKn+vj6s7jXhZNWApUSqMgItU4VK28vrrdpKbjIPwq2SwhnGOHUYvN9HwCQ==} engines: {node: ^14.18.0 || >=16.10.0} @@ -2365,6 +2491,9 @@ packages: '@tauri-apps/api@2.5.0': resolution: {integrity: sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA==} + '@tauri-apps/api@2.6.0': + resolution: {integrity: sha512-hRNcdercfgpzgFrMXWwNDBN0B7vNzOzRepy6ZAmhxi5mDLVPNrTpo9MGg2tN/F7JRugj4d2aF7E1rtPXAHaetg==} + '@tauri-apps/cli-darwin-arm64@2.5.0': resolution: {integrity: sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==} engines: {node: '>= 10'} @@ -2439,6 +2568,9 @@ packages: '@tauri-apps/plugin-dialog@2.2.1': resolution: {integrity: sha512-wZmCouo4PgTosh/UoejPw9DPs6RllS5Pp3fuOV2JobCu36mR5AXU2MzU9NZiVaFi/5Zfc8RN0IhcZHnksJ1o8A==} + '@tauri-apps/plugin-http@2.5.0': + resolution: {integrity: sha512-l4M2DUIsOBIMrbj4dJZwrB4mJiB7OA/2Tj3gEbX2fjq5MOpETklJPKfDvzUTDwuq4lIKCKKykz8E8tpOgvi0EQ==} + '@tauri-apps/plugin-opener@2.2.6': resolution: {integrity: sha512-bSdkuP71ZQRepPOn8BOEdBKYJQvl6+jb160QtJX/i2H9BF6ZySY/kYljh76N2Ne5fJMQRge7rlKoStYQY5Jq1w==} @@ -2451,6 +2583,26 @@ packages: '@tauri-apps/plugin-window-state@2.2.2': resolution: {integrity: sha512-7pFwmMtGhhhE/WgmM7PUrj0BSSWVAQMfDdYbRalphIqqF1tWBvxtlxclx8bTutpXHLJTQoCpIeWtBEIXsoAlGw==} + '@tresjs/cientos@4.3.1': + resolution: {integrity: sha512-3qp6lEtMrFdhxDuASP1Sz/hEi8+xcEpM6Vd6uDJysCh4uRAzyJLlBSbPoR7gVjN12wrhwJIF1AfYEFz/Vhz5ZQ==} + peerDependencies: + '@tresjs/core': '>=4.2.1' + three: '>=0.133' + vue: '>=3.3' + + '@tresjs/core@4.3.6': + resolution: {integrity: sha512-CCk4+jwbiTl7Hj3REZqweglUQQdA3cF29TqJ4dEWunaBPyfsAGLTlJExK5lGIS10ptJkr8DqPvHQT41iTIb0Yg==} + peerDependencies: + three: '>=0.133' + vue: '>=3.4' + + '@tresjs/post-processing@2.4.0': + resolution: {integrity: sha512-4l18DTLkn0Y/abyn+FD/gSJ6/SC01oXn+/qPgUxMgxZ8zGaw4PZbOi4yorhbSbOTp0gO4D1X7lNOvNUokqJwFw==} + peerDependencies: + '@tresjs/core': '>=4.0' + three: '>=0.169' + vue: '>=3.4' + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -2470,11 +2622,14 @@ packages: '@types/dompurify@3.0.5': resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==} + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - '@types/eslint@9.6.0': - resolution: {integrity: sha512-gi6WQJ7cHRgZxtkQEoyHMppPjq9Kxo5Tjn2prSKDSmZrCz8TZ3jSRCeTJm+WoM+oB0WG37bRqLzaaU3q7JypGg==} + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2482,12 +2637,18 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/html-minifier-terser@7.0.2': + resolution: {integrity: sha512-mm2HqV22l8lFQh4r2oSsOEVea+m0qqxEmwpc9kC1p/XzmjLWrReR9D/GRs8Pex2NX/imyEH9c5IU/7tMBQCHOA==} + '@types/http-proxy@1.17.15': resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==} @@ -2536,9 +2697,15 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/rss@0.0.32': + resolution: {integrity: sha512-2oKNqKyUY4RSdvl5eZR1n2Q9yvw3XTe3mQHsFPn9alaNBxfPnbXBtGP8R0SV8pK1PrVnLul0zx7izbm5/gF5Qw==} + '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -2566,9 +2733,15 @@ packages: '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/webxr@0.5.21': resolution: {integrity: sha512-geZIAtLzjGmgY2JUi6VxXdCrTb99A7yP49lxLr2Nm/uIK0PkkxcEi4OGhoGDO4pxCf3JwGz2GiJL2Ej4K2bKaA==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@typescript-eslint/eslint-plugin@6.21.0': resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2960,65 +3133,74 @@ packages: '@vueuse/core@11.1.0': resolution: {integrity: sha512-P6dk79QYA6sKQnghrUz/1tHi0n9mrb/iO1WTMk/ElLmTyNqgDeSZ3wcDf6fRBGzRJbeG1dxzEOvLENMjr+E3fg==} + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + '@vueuse/core@9.13.0': resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==} '@vueuse/metadata@11.1.0': resolution: {integrity: sha512-l9Q502TBTaPYGanl1G+hPgd3QX5s4CGnpXriVBR5fEZ/goI6fvDaVmIl3Td8oKFurOxTmbXvBPSsgrd6eu6HYg==} + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + '@vueuse/metadata@9.13.0': resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==} '@vueuse/shared@11.1.0': resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==} + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@vueuse/shared@9.13.0': resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==} - '@webassemblyjs/ast@1.12.1': - resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - '@webassemblyjs/floating-point-hex-parser@1.11.6': - resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - '@webassemblyjs/helper-api-error@1.11.6': - resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - '@webassemblyjs/helper-buffer@1.12.1': - resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - '@webassemblyjs/helper-numbers@1.11.6': - resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - '@webassemblyjs/helper-wasm-bytecode@1.11.6': - resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - '@webassemblyjs/helper-wasm-section@1.12.1': - resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - '@webassemblyjs/ieee754@1.11.6': - resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - '@webassemblyjs/leb128@1.11.6': - resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - '@webassemblyjs/utf8@1.11.6': - resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - '@webassemblyjs/wasm-edit@1.12.1': - resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - '@webassemblyjs/wasm-gen@1.12.1': - resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - '@webassemblyjs/wasm-opt@1.12.1': - resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - '@webassemblyjs/wasm-parser@1.12.1': - resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - '@webassemblyjs/wast-printer@1.12.1': - resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} '@webgpu/types@0.1.54': resolution: {integrity: sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg==} @@ -3057,6 +3239,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3073,11 +3260,24 @@ packages: ajv: optional: true + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: ajv: ^6.9.1 + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -3293,6 +3493,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.25.0: + resolution: {integrity: sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} @@ -3322,6 +3527,14 @@ packages: magicast: optional: true + c12@3.0.4: + resolution: {integrity: sha512-t5FaZTYbbCtvxuZq9xxIruYydrAGsJ+8UdP0pZzMiK2xl/gNiSOy0OxhLzHUEEb0m1QXYqfzfvyIFEmz/g9lqg==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3345,6 +3558,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -3353,12 +3569,20 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} + camera-controls@2.10.1: + resolution: {integrity: sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==} + peerDependencies: + three: '>=0.126.1' + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} caniuse-lite@1.0.30001687: resolution: {integrity: sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==} + caniuse-lite@1.0.30001723: + resolution: {integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3405,6 +3629,9 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -3420,6 +3647,10 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + clean-regexp@1.0.0: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} @@ -3481,6 +3712,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3522,10 +3757,17 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + consola@3.2.3: resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} engines: {node: ^14.18.0 || >=16.10.0} + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} @@ -3572,6 +3814,10 @@ packages: resolution: {integrity: sha512-NKgHbWkSZXJUcaBHSsyzC8eegD6bBd4O0oCI6XMIJ+y4Bq3v4w7sY3wfWoKPuVlq9pQHRB6od0lmKpIqi8TlKA==} hasBin: true + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -3678,6 +3924,29 @@ packages: mysql2: optional: true + db0@0.3.2: + resolution: {integrity: sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw==} + peerDependencies: + '@electric-sql/pglite': '*' + '@libsql/client': '*' + better-sqlite3: '*' + drizzle-orm: '*' + mysql2: '*' + sqlite3: '*' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + better-sqlite3: + optional: true + drizzle-orm: + optional: true + mysql2: + optional: true + sqlite3: + optional: true + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -3764,6 +4033,9 @@ packages: destr@2.0.3: resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -3833,6 +4105,9 @@ packages: domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dot-prop@9.0.0: resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==} engines: {node: '>=18'} @@ -3845,6 +4120,13 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -3862,6 +4144,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.167: + resolution: {integrity: sha512-LxcRvnYO5ez2bMOFpbuuVuAI5QNeY1ncVytE/KXaL6ZNfzX1yPlAO0nSOyIHx2fVAuUprMqPs/TdVhUFZy7SIQ==} + electron-to-chromium@1.5.71: resolution: {integrity: sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==} @@ -3888,10 +4173,17 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + enhanced-resolve@5.17.1: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} @@ -3934,6 +4226,9 @@ packages: es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.0.0: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} engines: {node: '>= 0.4'} @@ -4134,10 +4429,11 @@ packages: peerDependencies: eslint: '>6.6.0' - eslint-plugin-turbo@2.2.0: - resolution: {integrity: sha512-Xu99K8R6/wEVX34WreTbItyX25YYqW7VZU7/nyvKtlusHdsO6Be8IfhWIzIOgThigq4IiBsZXk/lhdcG1KzaNQ==} + eslint-plugin-turbo@2.5.4: + resolution: {integrity: sha512-IZsW61DFj5mLMMaCJxhh1VE4HvNhfdnHnAaXajgne+LUzdyHk2NvYT0ECSa/1SssArcqgTvV74MrLL68hWLLFw==} peerDependencies: eslint: '>6.6.0' + turbo: '>2.0.0' eslint-plugin-unicorn@44.0.2: resolution: {integrity: sha512-GLIDX1wmeEqpGaKcnMcqRvMVsoabeF0Ton0EX4Th5u6Kmf7RM9WBl705AXFEsns56ESkEs0uyelLuUTvz9Tr0w==} @@ -4289,6 +4585,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + execa@1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} + execa@7.2.0: resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} @@ -4300,6 +4600,13 @@ packages: expressive-code@0.40.2: resolution: {integrity: sha512-1zIda2rB0qiDZACawzw2rbdBQiWHBT56uBctS+ezFe5XMAaFaHLnnSYND/Kd+dVzO9HfCXRDpzH3d+3fvOWRcw==} + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -4319,6 +4626,10 @@ packages: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -4350,9 +4661,20 @@ packages: picomatch: optional: true + fdir@6.4.6: + resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.6.10: + resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} + fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} @@ -4490,6 +4812,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -4509,6 +4835,10 @@ packages: resolution: {integrity: sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==} hasBin: true + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + git-config-path@2.0.0: resolution: {integrity: sha512-qc8h1KIQbJpp+241id3GuAtkdyJ+IK+LIVtkiFTRKRrmddDzs3SI9CvP1QYmWBFvm1I/PWRwj//of8bgAc0ltA==} engines: {node: '>=4'} @@ -4574,6 +4904,15 @@ packages: resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==} engines: {node: '>=18'} + glsl-token-functions@1.0.1: + resolution: {integrity: sha512-EigGhp1g+aUVeUNY7H1o5tL/bnwIB3/FcRREPr2E7Du+/UDXN24hDkaZ3e4aWHDjHr9lJ6YHXMISkwhUYg9UOg==} + + glsl-token-string@1.0.1: + resolution: {integrity: sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==} + + glsl-tokenizer@2.1.5: + resolution: {integrity: sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==} + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -4590,6 +4929,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + gzip-size@7.0.0: resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4716,6 +5059,11 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-minifier-terser@7.2.0: + resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} + engines: {node: ^14.13.1 || >=16.0.0} + hasBin: true + html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} @@ -4762,6 +5110,11 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + if-ci@3.0.0: + resolution: {integrity: sha512-/0vPNr5ZDR3znyP83SFMqe6oUVeiAeGt5QnPanVJjPGH7Z7JrZnYUEt7gpkK7C8NTIH5BLSqnA5IPdUnYof6pA==} + engines: {node: '>=6'} + hasBin: true + ignore@5.3.1: resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} engines: {node: '>= 4'} @@ -4770,6 +5123,10 @@ packages: resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + image-meta@0.2.1: resolution: {integrity: sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==} @@ -4870,6 +5227,10 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} + is-ci@2.0.0: + resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==} + hasBin: true + is-core-module@2.15.0: resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==} engines: {node: '>= 0.4'} @@ -4895,6 +5256,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -4960,6 +5325,10 @@ packages: is-ssh@1.4.0: resolution: {integrity: sha512-x7+VxdxOdlV3CYpjvRLBv5Lo9OJerlYanjwFrPR9fuGPjCiNiCzFgAWpiLAohSbsnH4ZAys3SBh+hq5rJosxUQ==} + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -4999,6 +5368,9 @@ packages: resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} engines: {node: '>=18'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -5023,8 +5395,8 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true - jiti@2.4.1: - resolution: {integrity: sha512-yPBThwecp1wS9DmoA4x4KR2h3QoslacnDR8ypuFM962kI4/456Iy1oHx2RAgh4jfZNdn0bctsdadceiBUgpU1g==} + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true js-levenshtein@1.1.6: @@ -5112,6 +5484,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -5127,6 +5503,9 @@ packages: knitwork@1.1.0: resolution: {integrity: sha512-oHnmiBUVHz1V+URE77PNot2lv3QiYU2zQf1JjOVkMt3YDKGbu8NAFr+c4mcNOhdsGrB/VpVbRwPwhiXrPhxQbw==} + knitwork@1.2.0: + resolution: {integrity: sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==} + kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -5185,6 +5564,10 @@ packages: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} + local-pkg@1.1.1: + resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==} + engines: {node: '>=14'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -5222,6 +5605,9 @@ packages: resolution: {integrity: sha512-S0FayMXku80toa5sZ6Ro4C+s+EtFDCsyJNG/AzFMfX3AxD5Si4dZsgzm/kKnbOxHl5Cv8jBlno8+3XYIh2pNjQ==} engines: {node: '>=8'} + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -5403,9 +5789,15 @@ packages: micromark-util-character@2.1.0: resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + micromark-util-chunked@2.0.0: resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + micromark-util-classify-character@2.0.0: resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} @@ -5433,9 +5825,15 @@ packages: micromark-util-resolve-all@2.0.0: resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + micromark-util-sanitize-uri@2.0.0: resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + micromark-util-subtokenize@2.0.1: resolution: {integrity: sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==} @@ -5452,10 +5850,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.25.0: + resolution: {integrity: sha512-5k547tI4Cy+Lddr/hdjNbBEWBwSl8EBc5aSdKvedav8DReADgWJzcYiktaRIw3GtGC1jjwldXtTzvqJZmtvC7w==} + engines: {node: '>= 0.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-types@2.1.13: + resolution: {integrity: sha512-ryBDp1Z/6X90UvjUK3RksH0IBPM137T7cmg4OgD5wQBojlAiUwuok0QeELkim/72EtcYuNlmbkrcGuxj3Kl0YQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} @@ -5531,6 +5937,9 @@ packages: mlly@1.7.3: resolution: {integrity: sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==} + mlly@1.7.4: + resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -5551,6 +5960,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@3.3.7: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5579,6 +5993,9 @@ packages: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + nitropack@2.10.4: resolution: {integrity: sha512-sJiG/MIQlZCVSw2cQrFG1H6mLeSqHlYfFerRjLKz69vUfdu0EL2l0WdOxlQbzJr3mMv/l4cOlCCLzVRzjzzF/g==} engines: {node: ^16.11.0 || >=17.0.0} @@ -5592,6 +6009,9 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-addon-api@7.1.0: resolution: {integrity: sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==} engines: {node: ^16 || ^18 || >= 20} @@ -5625,6 +6045,9 @@ packages: node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -5644,6 +6067,10 @@ packages: not@0.1.0: resolution: {integrity: sha512-5PDmaAsVfnWUgTUbJ3ERwn7u79Z0dYxN9ErxCpVJJqe2RK0PJ3z+iFUxuqjwtlDDegXvtWoxD/3Fzxox7tFGWA==} + npm-run-path@2.0.2: + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -5687,6 +6114,11 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true + nypm@0.6.0: + resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5729,6 +6161,9 @@ packages: ohash@1.1.4: resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -5764,6 +6199,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -5809,6 +6248,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5848,6 +6290,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -5859,6 +6304,10 @@ packages: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} + path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -5885,6 +6334,9 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -5930,6 +6382,12 @@ packages: pkg-types@1.2.1: resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.1.1: + resolution: {integrity: sha512-eY0QFb6eSwc9+0d/5D2lFFUq+A3n3QNGSy/X2Nvp+6MfzGw2u6EbA7S80actgjY1lkvvI0pqB+a4hioMh443Ew==} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -6145,9 +6603,21 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.5: + resolution: {integrity: sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==} + engines: {node: ^10 || ^12 || >=14} + posthog-js@1.158.2: resolution: {integrity: sha512-ovb7GHHRNDf6vmuL+8lbDukewzDzQlLZXg3d475hrfHSBgidYeTxtLGtoBcUz4x6558BLDFjnSip+f3m4rV9LA==} + postprocessing@6.37.6: + resolution: {integrity: sha512-KrdKLf1257RkoIk3z3nhRS0aToKrX2xJgtR0lbnOQUjd+1I4GVNv1gQYsQlfRglvEXjpzrwqOA5fXfoDBimadg==} + peerDependencies: + three: '>= 0.157.0 < 0.179.0' + + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + preact@10.23.2: resolution: {integrity: sha512-kKYfePf9rzKnxOAKDpsWhg/ysrHPqT+yQ7UW4JjdnqjFIeNUnNcEJvhuA8fDenxAGWzUqtd51DfVg7xp/8T9NA==} @@ -6225,6 +6695,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + pretty-bytes@6.1.1: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} @@ -6253,6 +6728,9 @@ packages: protocols@2.0.1: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -6270,6 +6748,9 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + quansync@0.2.10: + resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6300,6 +6781,9 @@ packages: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} + readable-stream@1.0.34: + resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -6408,6 +6892,10 @@ packages: rehype@13.0.2: resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + remark-directive@3.0.0: resolution: {integrity: sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==} @@ -6514,6 +7002,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rss@1.2.2: + resolution: {integrity: sha512-xUhRTgslHeCBeHAqaWSbOYTydN2f0tAzNXvzh3stjz7QDhQMzdgHf3pfgNIngeytQflrFPfy6axHilTETr6gDg==} + run-applescript@7.0.0: resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} engines: {node: '>=18'} @@ -6550,6 +7041,10 @@ packages: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} + schema-utils@4.3.2: + resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} + engines: {node: '>= 10.13.0'} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -6557,6 +7052,10 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -6575,6 +7074,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -6610,10 +7114,18 @@ packages: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} @@ -6741,6 +7253,15 @@ packages: '@astrojs/starlight': '>=0.30.0' astro: '>=5.1.5' + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -6748,6 +7269,9 @@ packages: std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} @@ -6777,6 +7301,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -6794,10 +7321,18 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-eof@1.0.0: + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} + strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -6813,6 +7348,9 @@ packages: strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + stripe@18.1.1: resolution: {integrity: sha512-hlF0ripc2nJrihpsJZQDl3xirS7tpdpS7DlmSNLEDRW8j7Qr215y5DHOI3+aEY/lq6PG8y4GR1RZPtEoIoAs/g==} engines: {node: '>=12.*'} @@ -6918,6 +7456,10 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tapable@2.2.2: + resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -6925,8 +7467,8 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - terser-webpack-plugin@5.3.10: - resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + terser-webpack-plugin@5.3.14: + resolution: {integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==} engines: {node: '>= 10.13.0'} peerDependencies: '@swc/core': '*' @@ -6941,8 +7483,8 @@ packages: uglify-js: optional: true - terser@5.31.6: - resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==} + terser@5.42.0: + resolution: {integrity: sha512-UYCvU9YQW2f/Vwl+P0GfhxJxbUGLwd+5QrrGgLajzWAtC/23AX0vcise32kkP7Eu0Wu9VlzzHAXkLObgjQfFlQ==} engines: {node: '>=10'} hasBin: true @@ -6959,9 +7501,29 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three-custom-shader-material@5.4.0: + resolution: {integrity: sha512-Yn1lFlKOk3Vul3npEGAmbbFUZ5S2+yjPgM2XqJEZEYRSUUH2vk+WVYrtTB6Bcq15wa7hLUXAKoctAvbRmBmbYA==} + peerDependencies: + '@react-three/fiber': '>=8.0' + react: '>=18.0' + three: '>=0.154' + peerDependenciesMeta: + '@react-three/fiber': + optional: true + react: + optional: true + + three-stdlib@2.36.0: + resolution: {integrity: sha512-kv0Byb++AXztEGsULgMAs8U2jgUdz6HPpAB/wDJnLiLlaWQX2APHhiTJIN7rqW+Of0eRgcp7jn05U1BsCP3xBA==} + peerDependencies: + three: '>=0.128.0' + three@0.172.0: resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==} + through2@0.6.5: + resolution: {integrity: sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -6979,6 +7541,10 @@ packages: resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -7025,38 +7591,38 @@ packages: tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} - turbo-darwin-64@2.2.3: - resolution: {integrity: sha512-Rcm10CuMKQGcdIBS3R/9PMeuYnv6beYIHqfZFeKWVYEWH69sauj4INs83zKMTUiZJ3/hWGZ4jet9AOwhsssLyg==} + turbo-darwin-64@2.5.4: + resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.2.3: - resolution: {integrity: sha512-+EIMHkuLFqUdJYsA3roj66t9+9IciCajgj+DVek+QezEdOJKcRxlvDOS2BUaeN8kEzVSsNiAGnoysFWYw4K0HA==} + turbo-darwin-arm64@2.5.4: + resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.2.3: - resolution: {integrity: sha512-UBhJCYnqtaeOBQLmLo8BAisWbc9v9daL9G8upLR+XGj6vuN/Nz6qUAhverN4Pyej1g4Nt1BhROnj6GLOPYyqxQ==} + turbo-linux-64@2.5.4: + resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.2.3: - resolution: {integrity: sha512-hJYT9dN06XCQ3jBka/EWvvAETnHRs3xuO/rb5bESmDfG+d9yQjeTMlhRXKrr4eyIMt6cLDt1LBfyi+6CQ+VAwQ==} + turbo-linux-arm64@2.5.4: + resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==} cpu: [arm64] os: [linux] - turbo-windows-64@2.2.3: - resolution: {integrity: sha512-NPrjacrZypMBF31b4HE4ROg4P3nhMBPHKS5WTpMwf7wydZ8uvdEHpESVNMOtqhlp857zbnKYgP+yJF30H3N2dQ==} + turbo-windows-64@2.5.4: + resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.2.3: - resolution: {integrity: sha512-fnNrYBCqn6zgKPKLHu4sOkihBI/+0oYFr075duRxqUZ+1aLWTAGfHZLgjVeLh3zR37CVzuerGIPWAEkNhkWEIw==} + turbo-windows-arm64@2.5.4: + resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==} cpu: [arm64] os: [win32] - turbo@2.2.3: - resolution: {integrity: sha512-5lDvSqIxCYJ/BAd6rQGK/AzFRhBkbu4JHVMLmGh/hCb7U3CqSnr5Tjwfy9vc+/5wG2DJ6wttgAaA7MoCgvBKZQ==} + turbo@2.5.4: + resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==} hasBin: true type-check@0.4.0: @@ -7115,6 +7681,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} @@ -7124,6 +7695,9 @@ packages: ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + ultrahtml@1.5.3: resolution: {integrity: sha512-GykOvZwgDWZlTQMtp5jrD4BVL+gNn2NVlVafjcFUJ7taY20tqYdwdoWBFy6GBJsNTZe1GkGPkSl5knQAjtgceg==} @@ -7136,11 +7710,14 @@ packages: unctx@2.3.1: resolution: {integrity: sha512-PhKke8ZYauiqh3FEMVNm7ljvzQiph0Mt3GBRve03IJm7ukfaON2OBK795tLwhbyfzknuRRkW0+Ze+CQUmzOZ+A==} + unctx@2.4.1: + resolution: {integrity: sha512-AbaYw0Nm4mK4qjhns67C+kgxR2YWiwlDBPzxrN8h8C6VtAdCgditAY5Dezu3IJy4XVqAnbrXt9oQJvsn3fyozg==} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - undici-types@6.19.6: - resolution: {integrity: sha512-e/vggGopEfTKSvj4ihnOLTsqhrKRN3LeO6qSN/GxohhuRv8qH9bNQ4B8W7e/vFL+0XTnmHPB4/kegunZGA4Org==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} unenv@1.10.0: resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} @@ -7158,6 +7735,10 @@ packages: unimport@3.14.4: resolution: {integrity: sha512-90jQsiS2D0vIrWg4U58do7B5Hr4q0qt9o/rS0TrDMzrvNuAQ7XF1sQ47Pe2zjVlvFWNkoPBb/2l2GJFy5XjqDg==} + unimport@5.1.0: + resolution: {integrity: sha512-wMmuG+wkzeHh2KCE6yiDlHmKelN8iE/maxkUYMbmrS6iV8+n6eP1TH3yKKlepuF4hrkepinEGmBXdfo9XZUvAw==} + engines: {node: '>=18.12.0'} + unist-util-find-after@5.0.0: resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} @@ -7192,6 +7773,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unplugin-utils@0.2.4: + resolution: {integrity: sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA==} + engines: {node: '>=18.12.0'} + unplugin-vue-router@0.10.9: resolution: {integrity: sha512-DXmC0GMcROOnCmN56GRvi1bkkG1BnVs4xJqNvucBUeZkmB245URvtxOfbo3H6q4SOUQQbLPYWd6InzvjRh363A==} peerDependencies: @@ -7208,6 +7793,10 @@ packages: resolution: {integrity: sha512-2qzQo5LN2DmUZXkWDHvGKLF5BP0WN+KthD6aPnPJ8plRBIjv4lh5O07eYcSxgO2znNw9s4MNhEO1sB+JDllDbQ==} engines: {node: '>=18.12.0'} + unplugin@2.3.5: + resolution: {integrity: sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==} + engines: {node: '>=18.12.0'} + unstorage@1.13.1: resolution: {integrity: sha512-ELexQHUrG05QVIM/iUeQNdl9FXDZhqLJ4yP59fnmn2jGUh0TEulwOgov1ubOb3Gt2ZGK/VMchJwPDNVEGWQpRg==} peerDependencies: @@ -7319,6 +7908,10 @@ packages: resolution: {integrity: sha512-reBOnkJBFfBZ8pCKaeHgfZLcehXtM6UTxc+vqs1JvCps0c4amLNp3fhdGBZwYp+VLyoY9n3X5KOP7lCyWBUX9A==} hasBin: true + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + unwasm@0.3.9: resolution: {integrity: sha512-LDxTx/2DkFURUd+BU1vUsF/moj0JsoTvl+2tcg2AUOiEzVturhGGx17/IMgGvKUYdZwr33EJHtChCJuhu9Ouvg==} @@ -7328,6 +7921,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uqr@0.1.2: resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} @@ -7738,8 +8337,8 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - watchpack@2.4.2: - resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} web-namespaces@2.0.1: @@ -7751,8 +8350,8 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webpack-sources@3.2.3: - resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + webpack-sources@3.3.2: + resolution: {integrity: sha512-ykKKus8lqlgXX/1WjudpIEjqsafjOTcOJqxnAbMLAu/KCsDCJ6GBtvscewvTkrn24HsnvFwrSCbenFrhtcCsAA==} engines: {node: '>=10.13.0'} webpack-virtual-modules@0.6.2: @@ -7786,6 +8385,10 @@ packages: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7838,11 +8441,26 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} hasBin: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -7872,6 +8490,11 @@ packages: engines: {node: '>= 14'} hasBin: true + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -7927,6 +8550,8 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@alvarosabu/utils@3.2.0': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -7938,9 +8563,9 @@ snapshots: '@apidevtools/swagger-methods@3.0.2': {} - '@astrojs/check@0.9.4(prettier@3.3.2)(typescript@5.8.2)': + '@astrojs/check@0.9.4(prettier@3.6.2)(typescript@5.8.2)': dependencies: - '@astrojs/language-server': 2.15.4(prettier@3.3.2)(typescript@5.8.2) + '@astrojs/language-server': 2.15.4(prettier@3.6.2)(typescript@5.8.2) chokidar: 4.0.1 kleur: 4.1.5 typescript: 5.8.2 @@ -7955,7 +8580,7 @@ snapshots: '@astrojs/internal-helpers@0.6.0': {} - '@astrojs/language-server@2.15.4(prettier@3.3.2)(typescript@5.8.2)': + '@astrojs/language-server@2.15.4(prettier@3.6.2)(typescript@5.8.2)': dependencies: '@astrojs/compiler': 2.10.3 '@astrojs/yaml2ts': 0.2.2 @@ -7969,14 +8594,14 @@ snapshots: volar-service-css: 0.0.62(@volar/language-service@2.4.11) volar-service-emmet: 0.0.62(@volar/language-service@2.4.11) volar-service-html: 0.0.62(@volar/language-service@2.4.11) - volar-service-prettier: 0.0.62(@volar/language-service@2.4.11)(prettier@3.3.2) + volar-service-prettier: 0.0.62(@volar/language-service@2.4.11)(prettier@3.6.2) volar-service-typescript: 0.0.62(@volar/language-service@2.4.11) volar-service-typescript-twoslash-queries: 0.0.62(@volar/language-service@2.4.11) volar-service-yaml: 0.0.62(@volar/language-service@2.4.11) vscode-html-languageservice: 5.3.1 vscode-uri: 3.0.8 optionalDependencies: - prettier: 3.3.2 + prettier: 3.6.2 transitivePeerDependencies: - typescript @@ -8006,12 +8631,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/mdx@4.1.0(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1))': + '@astrojs/mdx@4.1.0(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0))': dependencies: '@astrojs/markdown-remark': 6.2.0 '@mdx-js/mdx': 3.1.0(acorn@8.14.0) acorn: 8.14.0 - astro: 5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1) + astro: 5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0) es-module-lexer: 1.6.0 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -8035,16 +8660,16 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.23.8 - '@astrojs/starlight@0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1))': + '@astrojs/starlight@0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0))': dependencies: - '@astrojs/mdx': 4.1.0(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1)) + '@astrojs/mdx': 4.1.0(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0)) '@astrojs/sitemap': 3.2.1 '@pagefind/default-ui': 1.3.0 '@types/hast': 3.0.4 '@types/js-yaml': 4.0.9 '@types/mdast': 4.0.4 - astro: 5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1) - astro-expressive-code: 0.40.2(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1)) + astro: 5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0) + astro-expressive-code: 0.40.2(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0)) bcp-47: 2.1.0 hast-util-from-html: 2.0.2 hast-util-select: 6.0.2 @@ -8687,16 +9312,16 @@ snapshots: eslint: 8.57.0 eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.4.0(eslint@9.13.0(jiti@2.4.1))': + '@eslint-community/eslint-utils@4.4.0(eslint@9.13.0(jiti@2.4.2))': dependencies: - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.11.0': {} - '@eslint/compat@1.2.1(eslint@9.13.0(jiti@2.4.1))': + '@eslint/compat@1.2.1(eslint@9.13.0(jiti@2.4.2))': optionalDependencies: - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) '@eslint/config-array@0.18.0': dependencies: @@ -8750,7 +9375,7 @@ snapshots: dependencies: '@ctrl/tinycolor': 4.1.0 hast-util-select: 6.0.2 - hast-util-to-html: 9.0.2 + hast-util-to-html: 9.0.5 hast-util-to-text: 4.0.2 hastscript: 9.0.0 postcss: 8.4.49 @@ -9125,12 +9750,12 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@1.6.3(magicast@0.3.5)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))': + '@nuxt/devtools-kit@1.6.3(magicast@0.3.5)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))': dependencies: '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) execa: 7.2.0 - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) transitivePeerDependencies: - magicast - rollup @@ -9149,13 +9774,13 @@ snapshots: rc9: 2.1.2 semver: 7.7.1 - '@nuxt/devtools@1.6.3(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))': + '@nuxt/devtools@1.6.3(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))': dependencies: '@antfu/utils': 0.7.10 - '@nuxt/devtools-kit': 1.6.3(magicast@0.3.5)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6)) + '@nuxt/devtools-kit': 1.6.3(magicast@0.3.5)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0)) '@nuxt/devtools-wizard': 1.6.3 '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) - '@vue/devtools-core': 7.6.4(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4)) + '@vue/devtools-core': 7.6.4(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4)) '@vue/devtools-kit': 7.6.4 birpc: 0.2.19 consola: 3.2.3 @@ -9184,9 +9809,9 @@ snapshots: sirv: 3.0.0 tinyglobby: 0.2.10 unimport: 3.14.4(rollup@4.28.1) - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) - vite-plugin-inspect: 0.8.9(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6)) - vite-plugin-vue-inspector: 5.1.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6)) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) + vite-plugin-inspect: 0.8.9(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0)) + vite-plugin-vue-inspector: 5.1.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0)) which: 3.0.1 ws: 8.18.0 transitivePeerDependencies: @@ -9196,66 +9821,38 @@ snapshots: - utf-8-validate - vue - '@nuxt/eslint-config@0.5.7(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@nuxt/eslint-config@0.5.7(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@eslint/js': 9.13.0 - '@nuxt/eslint-plugin': 0.5.7(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@stylistic/eslint-plugin': 2.9.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@typescript-eslint/eslint-plugin': 8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@typescript-eslint/parser': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) - eslint-config-flat-gitignore: 0.3.0(eslint@9.13.0(jiti@2.4.1)) + '@nuxt/eslint-plugin': 0.5.7(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@stylistic/eslint-plugin': 2.9.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@typescript-eslint/eslint-plugin': 8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@typescript-eslint/parser': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint: 9.13.0(jiti@2.4.2) + eslint-config-flat-gitignore: 0.3.0(eslint@9.13.0(jiti@2.4.2)) eslint-flat-config-utils: 0.4.0 - eslint-plugin-import-x: 4.3.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint-plugin-jsdoc: 50.4.3(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-regexp: 2.6.0(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-unicorn: 55.0.0(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.1)) + eslint-plugin-import-x: 4.3.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint-plugin-jsdoc: 50.4.3(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-regexp: 2.6.0(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-unicorn: 55.0.0(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.2)) globals: 15.11.0 local-pkg: 0.5.1 pathe: 1.1.2 - vue-eslint-parser: 9.4.3(eslint@9.13.0(jiti@2.4.1)) + vue-eslint-parser: 9.4.3(eslint@9.13.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - typescript - '@nuxt/eslint-plugin@0.5.7(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@nuxt/eslint-plugin@0.5.7(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@typescript-eslint/types': 8.10.0 - '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) + '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint: 9.13.0(jiti@2.4.2) transitivePeerDependencies: - supports-color - typescript - '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@3.29.4)': - dependencies: - '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@3.29.4) - c12: 2.0.1(magicast@0.3.5) - consola: 3.2.3 - defu: 6.1.4 - destr: 2.0.3 - globby: 14.0.2 - hash-sum: 2.0.0 - ignore: 6.0.2 - jiti: 2.4.1 - klona: 2.0.6 - knitwork: 1.1.0 - mlly: 1.7.3 - pathe: 1.1.2 - pkg-types: 1.2.1 - scule: 1.3.0 - semver: 7.7.1 - ufo: 1.5.4 - unctx: 2.3.1 - unimport: 3.14.4(rollup@3.29.4) - untyped: 1.5.1 - transitivePeerDependencies: - - magicast - - rollup - - supports-color - optional: true - '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1)': dependencies: '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) @@ -9266,7 +9863,7 @@ snapshots: globby: 14.0.2 hash-sum: 2.0.0 ignore: 6.0.2 - jiti: 2.4.1 + jiti: 2.4.2 klona: 2.0.6 knitwork: 1.1.0 mlly: 1.7.3 @@ -9283,53 +9880,32 @@ snapshots: - rollup - supports-color - '@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9)': + '@nuxt/kit@3.17.5(magicast@0.3.5)': dependencies: - '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.34.9) - c12: 2.0.1(magicast@0.3.5) - consola: 3.2.3 + c12: 3.0.4(magicast@0.3.5) + consola: 3.4.2 defu: 6.1.4 - destr: 2.0.3 - globby: 14.0.2 - hash-sum: 2.0.0 - ignore: 6.0.2 - jiti: 2.4.1 + destr: 2.0.5 + errx: 0.1.0 + exsolve: 1.0.7 + ignore: 7.0.5 + jiti: 2.4.2 klona: 2.0.6 - knitwork: 1.1.0 - mlly: 1.7.3 - pathe: 1.1.2 - pkg-types: 1.2.1 + knitwork: 1.2.0 + mlly: 1.7.4 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.1.1 scule: 1.3.0 - semver: 7.7.1 - ufo: 1.5.4 - unctx: 2.3.1 - unimport: 3.14.4(rollup@4.34.9) - untyped: 1.5.1 + semver: 7.7.2 + std-env: 3.9.0 + tinyglobby: 0.2.14 + ufo: 1.6.1 + unctx: 2.4.1 + unimport: 5.1.0 + untyped: 2.0.0 transitivePeerDependencies: - magicast - - rollup - - supports-color - optional: true - - '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@3.29.4)': - dependencies: - c12: 2.0.1(magicast@0.3.5) - compatx: 0.1.8 - consola: 3.2.3 - defu: 6.1.4 - hookable: 5.5.3 - pathe: 1.1.2 - pkg-types: 1.2.1 - scule: 1.3.0 - std-env: 3.8.0 - ufo: 1.5.4 - uncrypto: 0.1.3 - unimport: 3.14.4(rollup@3.29.4) - untyped: 1.5.1 - transitivePeerDependencies: - - magicast - - rollup - - supports-color optional: true '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.28.1)': @@ -9352,27 +9928,6 @@ snapshots: - rollup - supports-color - '@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.34.9)': - dependencies: - c12: 2.0.1(magicast@0.3.5) - compatx: 0.1.8 - consola: 3.2.3 - defu: 6.1.4 - hookable: 5.5.3 - pathe: 1.1.2 - pkg-types: 1.2.1 - scule: 1.3.0 - std-env: 3.8.0 - ufo: 1.5.4 - uncrypto: 0.1.3 - unimport: 3.14.4(rollup@4.34.9) - untyped: 1.5.1 - transitivePeerDependencies: - - magicast - - rollup - - supports-color - optional: true - '@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@4.28.1)': dependencies: '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) @@ -9398,12 +9953,12 @@ snapshots: - rollup - supports-color - '@nuxt/vite-builder@3.14.1592(@types/node@20.14.11)(eslint@8.57.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.31.6)(typescript@5.5.4)(vue-tsc@2.1.6(typescript@5.5.4))(vue@3.5.13(typescript@5.5.4))': + '@nuxt/vite-builder@3.14.1592(@types/node@20.14.11)(eslint@8.57.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.42.0)(typescript@5.5.4)(vue-tsc@2.1.6(typescript@5.5.4))(vue@3.5.13(typescript@5.5.4))': dependencies: '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) '@rollup/plugin-replace': 6.0.1(rollup@4.28.1) - '@vitejs/plugin-vue': 5.2.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4)) - '@vitejs/plugin-vue-jsx': 4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4)) + '@vitejs/plugin-vue': 5.2.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4)) + '@vitejs/plugin-vue-jsx': 4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4)) autoprefixer: 10.4.20(postcss@8.4.49) clear: 0.1.0 consola: 3.2.3 @@ -9415,7 +9970,7 @@ snapshots: externality: 1.0.2 get-port-please: 3.1.2 h3: 1.13.0 - jiti: 2.4.1 + jiti: 2.4.2 knitwork: 1.1.0 magic-string: 0.30.14 mlly: 1.7.3 @@ -9430,9 +9985,9 @@ snapshots: ufo: 1.5.4 unenv: 1.10.0 unplugin: 1.16.0 - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) - vite-node: 2.1.8(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) - vite-plugin-checker: 0.8.0(eslint@8.57.0)(optionator@0.9.4)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue-tsc@2.1.6(typescript@5.5.4)) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) + vite-node: 2.1.8(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) + vite-plugin-checker: 0.8.0(eslint@8.57.0)(optionator@0.9.4)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue-tsc@2.1.6(typescript@5.5.4)) vue: 3.5.13(typescript@5.5.4) vue-bundle-renderer: 2.1.1 transitivePeerDependencies: @@ -9457,31 +10012,31 @@ snapshots: - vti - vue-tsc - '@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: - '@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)) - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.1)) + '@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint: 9.13.0(jiti@2.4.2) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.2)) transitivePeerDependencies: - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color - typescript - '@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1))': + '@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2))': dependencies: - eslint: 9.13.0(jiti@2.4.1) - eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-unicorn: 44.0.2(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.1)) + eslint: 9.13.0(jiti@2.4.2) + eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-node: 11.1.0(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-unicorn: 44.0.2(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.2)) local-pkg: 0.4.3 transitivePeerDependencies: - '@typescript-eslint/parser' @@ -9695,7 +10250,7 @@ snapshots: dependencies: serialize-javascript: 6.0.2 smob: 1.5.0 - terser: 5.31.6 + terser: 5.42.0 optionalDependencies: rollup: 4.28.1 @@ -9715,15 +10270,6 @@ snapshots: optionalDependencies: rollup: 4.28.1 - '@rollup/pluginutils@5.1.3(rollup@4.34.9)': - dependencies: - '@types/estree': 1.0.6 - estree-walker: 2.0.2 - picomatch: 4.0.2 - optionalDependencies: - rollup: 4.34.9 - optional: true - '@rollup/pluginutils@5.1.4(rollup@4.34.9)': dependencies: '@types/estree': 1.0.6 @@ -9940,10 +10486,10 @@ snapshots: '@stripe/stripe-js@7.3.1': {} - '@stylistic/eslint-plugin@2.9.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@stylistic/eslint-plugin@2.9.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: - '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) + '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint: 9.13.0(jiti@2.4.2) eslint-visitor-keys: 4.1.0 espree: 10.2.0 estraverse: 5.3.0 @@ -9958,6 +10504,8 @@ snapshots: '@tauri-apps/api@2.5.0': {} + '@tauri-apps/api@2.6.0': {} + '@tauri-apps/cli-darwin-arm64@2.5.0': optional: true @@ -10009,6 +10557,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.5.0 + '@tauri-apps/plugin-http@2.5.0': + dependencies: + '@tauri-apps/api': 2.6.0 + '@tauri-apps/plugin-opener@2.2.6': dependencies: '@tauri-apps/api': 2.5.0 @@ -10025,6 +10577,43 @@ snapshots: dependencies: '@tauri-apps/api': 2.5.0 + '@tresjs/cientos@4.3.1(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(@types/three@0.172.0)(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))': + dependencies: + '@tresjs/core': 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@vueuse/core': 12.8.2(typescript@5.5.4) + camera-controls: 2.10.1(three@0.172.0) + stats-gl: 2.4.2(@types/three@0.172.0)(three@0.172.0) + stats.js: 0.17.0 + three: 0.172.0 + three-custom-shader-material: 5.4.0(three@0.172.0) + three-stdlib: 2.36.0(three@0.172.0) + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - '@react-three/fiber' + - '@types/three' + - react + - typescript + + '@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))': + dependencies: + '@alvarosabu/utils': 3.2.0 + '@vue/devtools-api': 6.6.4 + '@vueuse/core': 12.8.2(typescript@5.5.4) + three: 0.172.0 + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + + '@tresjs/post-processing@2.4.0(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))': + dependencies: + '@tresjs/core': 4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) + '@vueuse/core': 12.8.2(typescript@5.5.4) + postprocessing: 6.37.6(three@0.172.0) + three: 0.172.0 + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@trysound/sax@0.2.0': {} '@tweenjs/tween.js@23.1.3': {} @@ -10043,15 +10632,17 @@ snapshots: dependencies: '@types/trusted-types': 2.0.7 + '@types/draco3d@1.4.10': {} + '@types/eslint-scope@3.7.7': dependencies: - '@types/eslint': 9.6.0 - '@types/estree': 1.0.6 + '@types/eslint': 9.6.1 + '@types/estree': 1.0.8 optional: true - '@types/eslint@9.6.0': + '@types/eslint@9.6.1': dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 optional: true @@ -10061,6 +10652,9 @@ snapshots: '@types/estree@1.0.6': {} + '@types/estree@1.0.8': + optional: true + '@types/fs-extra@9.0.13': dependencies: '@types/node': 20.14.11 @@ -10069,6 +10663,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/html-minifier-terser@7.0.2': {} + '@types/http-proxy@1.17.15': dependencies: '@types/node': 20.14.11 @@ -10110,13 +10706,17 @@ snapshots: '@types/node@22.4.1': dependencies: - undici-types: 6.19.6 + undici-types: 6.19.8 optional: true '@types/normalize-package-data@2.4.4': {} + '@types/offscreencanvas@2019.7.3': {} + '@types/resolve@1.20.2': {} + '@types/rss@0.0.32': {} + '@types/sax@1.2.7': dependencies: '@types/node': 20.14.11 @@ -10144,18 +10744,24 @@ snapshots: '@types/web-bluetooth@0.0.20': {} + '@types/web-bluetooth@0.0.21': {} + '@types/webxr@0.5.21': {} - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 20.14.11 + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@typescript-eslint/utils': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@typescript-eslint/utils': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.0(supports-color@9.4.0) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 @@ -10166,15 +10772,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@7.16.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@7.16.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/scope-manager': 7.16.1 - '@typescript-eslint/type-utils': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@typescript-eslint/utils': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/type-utils': 7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@typescript-eslint/utils': 7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.16.1 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 @@ -10184,15 +10790,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/eslint-plugin@8.10.0(@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/parser': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/scope-manager': 8.10.0 - '@typescript-eslint/type-utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/type-utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) '@typescript-eslint/visitor-keys': 8.10.0 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 @@ -10202,40 +10808,40 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.5.4) '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.4.0(supports-color@9.4.0) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 7.16.1 '@typescript-eslint/types': 7.16.1 '@typescript-eslint/typescript-estree': 7.16.1(typescript@5.5.4) '@typescript-eslint/visitor-keys': 7.16.1 debug: 4.4.0(supports-color@9.4.0) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/parser@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@typescript-eslint/scope-manager': 8.10.0 '@typescript-eslint/types': 8.10.0 '@typescript-eslint/typescript-estree': 8.10.0(typescript@5.5.4) '@typescript-eslint/visitor-keys': 8.10.0 debug: 4.4.0(supports-color@9.4.0) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: @@ -10256,34 +10862,34 @@ snapshots: '@typescript-eslint/types': 8.10.0 '@typescript-eslint/visitor-keys': 8.10.0 - '@typescript-eslint/type-utils@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.5.4) - '@typescript-eslint/utils': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/utils': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) debug: 4.4.0(supports-color@9.4.0) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/type-utils@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@typescript-eslint/typescript-estree': 7.16.1(typescript@5.5.4) - '@typescript-eslint/utils': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/utils': 7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) debug: 4.4.0(supports-color@9.4.0) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/type-utils@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: '@typescript-eslint/typescript-estree': 8.10.0(typescript@5.5.4) - '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) debug: 4.4.0(supports-color@9.4.0) ts-api-utils: 1.3.0(typescript@5.5.4) optionalDependencies: @@ -10343,38 +10949,38 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/utils@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) semver: 7.7.1 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/utils@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 7.16.1 '@typescript-eslint/types': 7.16.1 '@typescript-eslint/typescript-estree': 7.16.1(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@typescript-eslint/utils@8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@typescript-eslint/scope-manager': 8.10.0 '@typescript-eslint/types': 8.10.0 '@typescript-eslint/typescript-estree': 8.10.0(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) transitivePeerDependencies: - supports-color - typescript @@ -10455,12 +11061,12 @@ snapshots: '@formatjs/intl': 2.10.4(typescript@5.5.4) intl-messageformat: 10.5.14 - '@vintl/nuxt@1.9.2(@vue/compiler-core@3.5.13)(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)': + '@vintl/nuxt@1.9.2(@vue/compiler-core@3.5.13)(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)': dependencies: '@formatjs/intl': 2.10.4(typescript@5.5.4) '@formatjs/intl-localematcher': 0.5.4 '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) - '@vintl/unplugin': 2.0.0(@vue/compiler-core@3.5.13)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1) + '@vintl/unplugin': 2.0.0(@vue/compiler-core@3.5.13)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1) '@vintl/vintl': 4.4.1(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)) astring: 1.8.6 consola: 3.2.3 @@ -10487,7 +11093,7 @@ snapshots: - vue - webpack - '@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)': + '@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.13)(rollup@3.29.4)(vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)': dependencies: '@formatjs/cli-lib': 6.4.2(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4)) '@formatjs/icu-messageformat-parser': 2.7.8 @@ -10498,7 +11104,7 @@ snapshots: unplugin: 1.16.0 optionalDependencies: rollup: 3.29.4 - vite: 4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6) + vite: 4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0) webpack: 5.92.1 transitivePeerDependencies: - '@glimmer/env' @@ -10511,7 +11117,7 @@ snapshots: - ts-jest - vue - '@vintl/unplugin@2.0.0(@vue/compiler-core@3.5.13)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)': + '@vintl/unplugin@2.0.0(@vue/compiler-core@3.5.13)(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))(webpack@5.92.1)': dependencies: '@formatjs/cli-lib': 6.4.2(@vue/compiler-core@3.5.13)(vue@3.5.13(typescript@5.5.4)) '@formatjs/icu-messageformat-parser': 2.7.8 @@ -10522,7 +11128,7 @@ snapshots: unplugin: 1.16.0 optionalDependencies: rollup: 4.28.1 - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) webpack: 5.92.1 transitivePeerDependencies: - '@glimmer/env' @@ -10546,24 +11152,24 @@ snapshots: transitivePeerDependencies: - typescript - '@vitejs/plugin-vue-jsx@4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))': + '@vitejs/plugin-vue-jsx@4.1.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-typescript': 7.26.3(@babel/core@7.26.0) '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.26.0) - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) vue: 3.5.13(typescript@5.5.4) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@5.2.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))': + '@vitejs/plugin-vue@5.2.1(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))': dependencies: - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) vue: 3.5.13(typescript@5.5.4) - '@vitejs/plugin-vue@5.2.1(vite@5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))': + '@vitejs/plugin-vue@5.2.1(vite@5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))': dependencies: - vite: 5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0) vue: 3.5.13(typescript@5.5.4) '@volar/kit@2.4.11(typescript@5.8.2)': @@ -10708,14 +11314,14 @@ snapshots: '@vue/devtools-api@6.6.4': {} - '@vue/devtools-core@7.6.4(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4))': + '@vue/devtools-core@7.6.4(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4))': dependencies: '@vue/devtools-kit': 7.6.4 '@vue/devtools-shared': 7.6.7 mitt: 3.0.1 nanoid: 3.3.7 pathe: 1.1.2 - vite-hot-client: 0.2.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6)) + vite-hot-client: 0.2.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0)) vue: 3.5.13(typescript@5.5.4) transitivePeerDependencies: - vite @@ -10734,13 +11340,13 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.29.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4)': + '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.29.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4)': dependencies: - '@typescript-eslint/eslint-plugin': 7.16.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) - eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.1)) - vue-eslint-parser: 9.4.3(eslint@9.13.0(jiti@2.4.1)) + '@typescript-eslint/eslint-plugin': 7.16.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint: 9.13.0(jiti@2.4.2) + eslint-plugin-vue: 9.29.0(eslint@9.13.0(jiti@2.4.2)) + vue-eslint-parser: 9.4.3(eslint@9.13.0(jiti@2.4.2)) optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: @@ -10781,11 +11387,11 @@ snapshots: '@vue/shared': 3.5.13 vue: 3.5.13(typescript@5.5.4) - '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.2))': + '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.3))': dependencies: '@vue/compiler-ssr': 3.5.13 '@vue/shared': 3.5.13 - vue: 3.5.13(typescript@5.8.2) + vue: 3.5.13(typescript@5.8.3) '@vue/shared@3.5.13': {} @@ -10801,6 +11407,15 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/core@12.8.2(typescript@5.5.4)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.5.4) + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@vueuse/core@9.13.0(vue@3.5.13(typescript@5.5.4))': dependencies: '@types/web-bluetooth': 0.0.16 @@ -10813,6 +11428,8 @@ snapshots: '@vueuse/metadata@11.1.0': {} + '@vueuse/metadata@12.8.2': {} + '@vueuse/metadata@9.13.0': {} '@vueuse/shared@11.1.0(vue@3.5.13(typescript@5.5.4))': @@ -10822,6 +11439,12 @@ snapshots: - '@vue/composition-api' - vue + '@vueuse/shared@12.8.2(typescript@5.5.4)': + dependencies: + vue: 3.5.13(typescript@5.5.4) + transitivePeerDependencies: + - typescript + '@vueuse/shared@9.13.0(vue@3.5.13(typescript@5.5.4))': dependencies: vue-demi: 0.14.10(vue@3.5.13(typescript@5.5.4)) @@ -10829,94 +11452,94 @@ snapshots: - '@vue/composition-api' - vue - '@webassemblyjs/ast@1.12.1': + '@webassemblyjs/ast@1.14.1': dependencies: - '@webassemblyjs/helper-numbers': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 optional: true - '@webassemblyjs/floating-point-hex-parser@1.11.6': + '@webassemblyjs/floating-point-hex-parser@1.13.2': optional: true - '@webassemblyjs/helper-api-error@1.11.6': + '@webassemblyjs/helper-api-error@1.13.2': optional: true - '@webassemblyjs/helper-buffer@1.12.1': + '@webassemblyjs/helper-buffer@1.14.1': optional: true - '@webassemblyjs/helper-numbers@1.11.6': + '@webassemblyjs/helper-numbers@1.13.2': dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.11.6 - '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 '@xtuc/long': 4.2.2 optional: true - '@webassemblyjs/helper-wasm-bytecode@1.11.6': + '@webassemblyjs/helper-wasm-bytecode@1.13.2': optional: true - '@webassemblyjs/helper-wasm-section@1.12.1': + '@webassemblyjs/helper-wasm-section@1.14.1': dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 optional: true - '@webassemblyjs/ieee754@1.11.6': + '@webassemblyjs/ieee754@1.13.2': dependencies: '@xtuc/ieee754': 1.2.0 optional: true - '@webassemblyjs/leb128@1.11.6': + '@webassemblyjs/leb128@1.13.2': dependencies: '@xtuc/long': 4.2.2 optional: true - '@webassemblyjs/utf8@1.11.6': + '@webassemblyjs/utf8@1.13.2': optional: true - '@webassemblyjs/wasm-edit@1.12.1': + '@webassemblyjs/wasm-edit@1.14.1': dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/helper-wasm-section': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-opt': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - '@webassemblyjs/wast-printer': 1.12.1 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 optional: true - '@webassemblyjs/wasm-gen@1.12.1': + '@webassemblyjs/wasm-gen@1.14.1': dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 optional: true - '@webassemblyjs/wasm-opt@1.12.1': + '@webassemblyjs/wasm-opt@1.14.1': dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 optional: true - '@webassemblyjs/wasm-parser@1.12.1': + '@webassemblyjs/wasm-parser@1.14.1': dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-api-error': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 optional: true - '@webassemblyjs/wast-printer@1.12.1': + '@webassemblyjs/wast-printer@1.14.1': dependencies: - '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 optional: true @@ -10942,12 +11565,23 @@ snapshots: dependencies: acorn: 8.14.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + optional: true + acorn-jsx@5.3.2(acorn@8.14.0): dependencies: acorn: 8.14.0 + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn@8.14.0: {} + acorn@8.15.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.0(supports-color@9.4.0) @@ -10960,11 +11594,22 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + optional: true + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 optional: true + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + optional: true + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -11125,12 +11770,12 @@ snapshots: astring@1.8.6: {} - astro-expressive-code@0.40.2(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1)): + astro-expressive-code@0.40.2(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0)): dependencies: - astro: 5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1) + astro: 5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0) rehype-expressive-code: 0.40.2 - astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1): + astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0): dependencies: '@astrojs/compiler': 2.10.4 '@astrojs/internal-helpers': 0.6.0 @@ -11180,10 +11825,10 @@ snapshots: tsconfck: 3.1.5(typescript@5.8.2) ultrahtml: 1.5.3 unist-util-visit: 5.0.0 - unstorage: 1.15.0(db0@0.2.1) + unstorage: 1.15.0(db0@0.3.2) vfile: 6.0.3 - vite: 6.2.0(@types/node@22.4.1)(jiti@2.4.1)(sass@1.77.6)(terser@5.31.6)(yaml@2.6.1) - vitefu: 1.0.6(vite@6.2.0(@types/node@22.4.1)(jiti@2.4.1)(sass@1.77.6)(terser@5.31.6)(yaml@2.6.1)) + vite: 6.2.0(@types/node@22.4.1)(jiti@2.4.2)(sass@1.77.6)(terser@5.42.0)(yaml@2.8.0) + vitefu: 1.0.6(vite@6.2.0(@types/node@22.4.1)(jiti@2.4.2)(sass@1.77.6)(terser@5.42.0)(yaml@2.8.0)) which-pm: 3.0.1 xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 @@ -11309,6 +11954,14 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) + browserslist@4.25.0: + dependencies: + caniuse-lite: 1.0.30001723 + electron-to-chromium: 1.5.167 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.25.0) + optional: true + buffer-crc32@1.0.0: {} buffer-from@1.1.2: {} @@ -11335,7 +11988,7 @@ snapshots: defu: 6.1.4 dotenv: 16.4.5 giget: 1.2.3 - jiti: 2.4.1 + jiti: 2.4.2 mlly: 1.7.3 ohash: 1.1.4 pathe: 1.1.2 @@ -11345,6 +11998,24 @@ snapshots: optionalDependencies: magicast: 0.3.5 + c12@3.0.4(magicast@0.3.5): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.7 + giget: 2.0.0 + jiti: 2.4.2 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.1.1 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + optional: true + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -11369,10 +12040,19 @@ snapshots: callsites@3.1.0: {} + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.6.3 + camelcase-css@2.0.1: {} camelcase@8.0.0: {} + camera-controls@2.10.1(three@0.172.0): + dependencies: + three: 0.172.0 + caniuse-api@3.0.0: dependencies: browserslist: 4.24.2 @@ -11382,6 +12062,9 @@ snapshots: caniuse-lite@1.0.30001687: {} + caniuse-lite@1.0.30001723: + optional: true + ccount@2.0.1: {} chalk@4.1.2: @@ -11426,6 +12109,8 @@ snapshots: chrome-trace-event@1.0.4: optional: true + ci-info@2.0.0: {} + ci-info@3.9.0: {} ci-info@4.0.0: {} @@ -11436,6 +12121,10 @@ snapshots: dependencies: consola: 3.2.3 + clean-css@5.3.3: + dependencies: + source-map: 0.6.1 + clean-regexp@1.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -11488,6 +12177,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -11518,8 +12209,13 @@ snapshots: confbox@0.1.8: {} + confbox@0.2.2: + optional: true + consola@3.2.3: {} + consola@3.4.2: {} + console-control-strings@1.1.0: {} convert-source-map@2.0.0: {} @@ -11553,6 +12249,14 @@ snapshots: cronstrue@2.52.0: {} + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -11673,6 +12377,9 @@ snapshots: db0@0.2.1: {} + db0@0.3.2: + optional: true + de-indent@1.0.2: {} debounce@1.2.1: {} @@ -11734,6 +12441,9 @@ snapshots: destr@2.0.3: {} + destr@2.0.5: + optional: true + destroy@1.2.0: {} detect-libc@1.0.3: {} @@ -11792,6 +12502,11 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.6.3 + dot-prop@9.0.0: dependencies: type-fest: 4.30.0 @@ -11800,6 +12515,11 @@ snapshots: dotenv@16.4.5: {} + dotenv@16.6.1: + optional: true + + draco3d@1.5.7: {} + dset@3.1.4: {} dunder-proto@1.0.1: @@ -11814,6 +12534,9 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.5.167: + optional: true + electron-to-chromium@1.5.71: {} emmet@2.4.7: @@ -11833,11 +12556,21 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 tapable: 2.2.1 + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.2 + optional: true + entities@2.2.0: {} entities@3.0.1: {} @@ -11913,6 +12646,9 @@ snapshots: es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: + optional: true + es-object-atoms@1.0.0: dependencies: es-errors: 1.3.0 @@ -11947,7 +12683,7 @@ snapshots: esast-util-from-js@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - acorn: 8.14.0 + acorn: 8.15.0 esast-util-from-estree: 2.0.0 vfile-message: 4.0.2 @@ -12068,27 +12804,27 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-flat-gitignore@0.3.0(eslint@9.13.0(jiti@2.4.1)): + eslint-config-flat-gitignore@0.3.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - '@eslint/compat': 1.2.1(eslint@9.13.0(jiti@2.4.1)) - eslint: 9.13.0(jiti@2.4.1) + '@eslint/compat': 1.2.1(eslint@9.13.0(jiti@2.4.2)) + eslint: 9.13.0(jiti@2.4.2) find-up-simple: 1.0.0 - eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.1)): + eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) - eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1)): + eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)))(eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.1)) + eslint: 9.13.0(jiti@2.4.2) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-n: 15.7.0(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-promise: 6.4.0(eslint@9.13.0(jiti@2.4.2)) - eslint-config-turbo@2.0.7(eslint@9.13.0(jiti@2.4.1)): + eslint-config-turbo@2.0.7(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) - eslint-plugin-turbo: 2.0.7(eslint@9.13.0(jiti@2.4.1)) + eslint: 9.13.0(jiti@2.4.2) + eslint-plugin-turbo: 2.0.7(eslint@9.13.0(jiti@2.4.2)) eslint-flat-config-utils@0.4.0: dependencies: @@ -12102,13 +12838,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)): dependencies: debug: 4.4.0(supports-color@9.4.0) enhanced-resolve: 5.17.1 - eslint: 9.13.0(jiti@2.4.1) - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1)) + eslint: 9.13.0(jiti@2.4.2) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.15.0 @@ -12119,45 +12855,45 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) + '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint: 9.13.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.1)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@9.13.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.1)): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) - eslint: 9.13.0(jiti@2.4.1) + '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) + eslint: 9.13.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-es@3.0.1(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-es@3.0.1(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-es@4.1.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-es@4.1.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import-x@4.3.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4): + eslint-plugin-import-x@4.3.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4): dependencies: - '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/utils': 8.10.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) debug: 4.4.0(supports-color@9.4.0) doctrine: 3.0.0 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 get-tsconfig: 4.7.5 is-glob: 4.0.3 @@ -12169,7 +12905,7 @@ snapshots: - supports-color - typescript - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@9.13.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -12177,9 +12913,9 @@ snapshots: array.prototype.flatmap: 1.3.2 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.1)) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.15.0 is-glob: 4.0.3 @@ -12190,13 +12926,13 @@ snapshots: semver: 6.3.1 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/parser': 6.21.0(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint@9.13.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -12204,9 +12940,9 @@ snapshots: array.prototype.flatmap: 1.3.2 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.1)) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint@9.13.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.15.0 is-glob: 4.0.3 @@ -12217,20 +12953,20 @@ snapshots: semver: 6.3.1 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.1))(typescript@5.5.4) + '@typescript-eslint/parser': 7.16.1(eslint@9.13.0(jiti@2.4.2))(typescript@5.5.4) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsdoc@50.4.3(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-jsdoc@50.4.3(eslint@9.13.0(jiti@2.4.2)): dependencies: '@es-joy/jsdoccomment': 0.49.0 are-docs-informative: 0.0.2 comment-parser: 1.4.1 debug: 4.4.0(supports-color@9.4.0) escape-string-regexp: 4.0.0 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) espree: 10.2.0 esquery: 1.6.0 parse-imports: 2.2.1 @@ -12240,70 +12976,71 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-n@15.7.0(eslint@9.13.0(jiti@2.4.2)): dependencies: builtins: 5.1.0 - eslint: 9.13.0(jiti@2.4.1) - eslint-plugin-es: 4.1.0(eslint@9.13.0(jiti@2.4.1)) - eslint-utils: 3.0.0(eslint@9.13.0(jiti@2.4.1)) + eslint: 9.13.0(jiti@2.4.2) + eslint-plugin-es: 4.1.0(eslint@9.13.0(jiti@2.4.2)) + eslint-utils: 3.0.0(eslint@9.13.0(jiti@2.4.2)) ignore: 5.3.1 is-core-module: 2.15.0 minimatch: 3.1.2 resolve: 1.22.8 semver: 7.7.1 - eslint-plugin-node@11.1.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-node@11.1.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) - eslint-plugin-es: 3.0.1(eslint@9.13.0(jiti@2.4.1)) + eslint: 9.13.0(jiti@2.4.2) + eslint-plugin-es: 3.0.1(eslint@9.13.0(jiti@2.4.2)) eslint-utils: 2.1.0 ignore: 5.3.1 minimatch: 3.1.2 resolve: 1.22.8 semver: 6.3.1 - eslint-plugin-prettier@5.2.1(@types/eslint@9.6.0)(eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.1)))(eslint@9.13.0(jiti@2.4.1))(prettier@3.3.2): + eslint-plugin-prettier@5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.13.0(jiti@2.4.2)))(eslint@9.13.0(jiti@2.4.2))(prettier@3.6.2): dependencies: - eslint: 9.13.0(jiti@2.4.1) - prettier: 3.3.2 + eslint: 9.13.0(jiti@2.4.2) + prettier: 3.6.2 prettier-linter-helpers: 1.0.0 synckit: 0.9.1 optionalDependencies: - '@types/eslint': 9.6.0 - eslint-config-prettier: 9.1.0(eslint@9.13.0(jiti@2.4.1)) + '@types/eslint': 9.6.1 + eslint-config-prettier: 9.1.0(eslint@9.13.0(jiti@2.4.2)) - eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-promise@6.4.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) - eslint-plugin-regexp@2.6.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-regexp@2.6.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.11.0 comment-parser: 1.4.1 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) jsdoc-type-pratt-parser: 4.1.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-turbo@2.0.7(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-turbo@2.0.7(eslint@9.13.0(jiti@2.4.2)): dependencies: dotenv: 16.0.3 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) - eslint-plugin-turbo@2.2.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-turbo@2.5.4(eslint@9.13.0(jiti@2.4.2))(turbo@2.5.4): dependencies: dotenv: 16.0.3 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) + turbo: 2.5.4 - eslint-plugin-unicorn@44.0.2(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-unicorn@44.0.2(eslint@9.13.0(jiti@2.4.2)): dependencies: '@babel/helper-validator-identifier': 7.25.9 ci-info: 3.9.0 clean-regexp: 1.0.0 - eslint: 9.13.0(jiti@2.4.1) - eslint-utils: 3.0.0(eslint@9.13.0(jiti@2.4.1)) + eslint: 9.13.0(jiti@2.4.2) + eslint-utils: 3.0.0(eslint@9.13.0(jiti@2.4.2)) esquery: 1.6.0 indent-string: 4.0.0 is-builtin-module: 3.2.1 @@ -12315,15 +13052,15 @@ snapshots: semver: 7.7.1 strip-indent: 3.0.0 - eslint-plugin-unicorn@54.0.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-unicorn@54.0.0(eslint@9.13.0(jiti@2.4.2)): dependencies: '@babel/helper-validator-identifier': 7.25.9 - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@eslint/eslintrc': 3.1.0 ci-info: 4.0.0 clean-regexp: 1.0.0 core-js-compat: 3.37.1 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) esquery: 1.6.0 indent-string: 4.0.0 is-builtin-module: 3.2.1 @@ -12337,14 +13074,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-unicorn@55.0.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-unicorn@55.0.0(eslint@9.13.0(jiti@2.4.2)): dependencies: '@babel/helper-validator-identifier': 7.25.9 - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) ci-info: 4.0.0 clean-regexp: 1.0.0 core-js-compat: 3.37.1 - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) esquery: 1.6.0 globals: 15.11.0 indent-string: 4.0.0 @@ -12357,16 +13094,16 @@ snapshots: semver: 7.6.3 strip-indent: 3.0.0 - eslint-plugin-vue@9.29.0(eslint@9.13.0(jiti@2.4.1)): + eslint-plugin-vue@9.29.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) - eslint: 9.13.0(jiti@2.4.1) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) + eslint: 9.13.0(jiti@2.4.2) globals: 13.24.0 natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.2 semver: 7.6.3 - vue-eslint-parser: 9.4.3(eslint@9.13.0(jiti@2.4.1)) + vue-eslint-parser: 9.4.3(eslint@9.13.0(jiti@2.4.2)) xml-name-validator: 4.0.0 transitivePeerDependencies: - supports-color @@ -12391,9 +13128,9 @@ snapshots: dependencies: eslint-visitor-keys: 1.3.0 - eslint-utils@3.0.0(eslint@9.13.0(jiti@2.4.1)): + eslint-utils@3.0.0(eslint@9.13.0(jiti@2.4.2)): dependencies: - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-visitor-keys: 2.1.0 eslint-visitor-keys@1.3.0: {} @@ -12447,9 +13184,9 @@ snapshots: transitivePeerDependencies: - supports-color - eslint@9.13.0(jiti@2.4.1): + eslint@9.13.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.1)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.13.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.11.0 '@eslint/config-array': 0.18.0 '@eslint/core': 0.7.0 @@ -12485,7 +13222,7 @@ snapshots: optionator: 0.9.4 text-table: 0.2.0 optionalDependencies: - jiti: 2.4.1 + jiti: 2.4.2 transitivePeerDependencies: - supports-color @@ -12561,6 +13298,16 @@ snapshots: events@3.3.0: {} + execa@1.0.0: + dependencies: + cross-spawn: 6.0.6 + get-stream: 4.1.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + execa@7.2.0: dependencies: cross-spawn: 7.0.3 @@ -12592,6 +13339,13 @@ snapshots: '@expressive-code/plugin-shiki': 0.40.2 '@expressive-code/plugin-text-markers': 0.40.2 + exsolve@1.0.7: + optional: true + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} externality@1.0.2: @@ -12615,6 +13369,14 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -12635,8 +13397,15 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.6(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + optional: true + fflate@0.4.8: {} + fflate@0.6.10: {} + fflate@0.8.2: {} file-entry-cache@6.0.1: @@ -12685,29 +13454,13 @@ snapshots: flattie@1.1.1: {} - floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@3.29.4))(vue@3.5.13(typescript@5.5.4)): + floating-vue@5.2.2(@nuxt/kit@3.17.5(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4)): dependencies: '@floating-ui/dom': 1.1.1 vue: 3.5.13(typescript@5.5.4) vue-resize: 2.0.0-alpha.1(vue@3.5.13(typescript@5.5.4)) optionalDependencies: - '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@3.29.4) - - floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(vue@3.5.13(typescript@5.5.4)): - dependencies: - '@floating-ui/dom': 1.1.1 - vue: 3.5.13(typescript@5.5.4) - vue-resize: 2.0.0-alpha.1(vue@3.5.13(typescript@5.5.4)) - optionalDependencies: - '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) - - floating-vue@5.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.34.9))(vue@3.5.13(typescript@5.5.4)): - dependencies: - '@floating-ui/dom': 1.1.1 - vue: 3.5.13(typescript@5.5.4) - vue-resize: 2.0.0-alpha.1(vue@3.5.13(typescript@5.5.4)) - optionalDependencies: - '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.34.9) + '@nuxt/kit': 3.17.5(magicast@0.3.5) for-each@0.3.3: dependencies: @@ -12802,6 +13555,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@4.1.0: + dependencies: + pump: 3.0.2 + get-stream@6.0.1: {} get-stream@8.0.1: {} @@ -12827,6 +13584,16 @@ snapshots: pathe: 1.1.2 tar: 6.2.1 + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.6 + nypm: 0.6.0 + pathe: 2.0.3 + optional: true + git-config-path@2.0.0: {} git-up@7.0.0: @@ -12906,6 +13673,14 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.1.0 + glsl-token-functions@1.0.1: {} + + glsl-token-string@1.0.1: {} + + glsl-tokenizer@2.1.5: + dependencies: + through2: 0.6.5 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -12918,6 +13693,13 @@ snapshots: graphemer@1.4.0: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + gzip-size@7.0.0: dependencies: duplexer: 0.1.2 @@ -13186,6 +13968,16 @@ snapshots: html-escaper@3.0.3: {} + html-minifier-terser@7.2.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 10.0.1 + entities: 4.5.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.42.0 + html-tags@3.3.1: {} html-void-elements@3.0.0: {} @@ -13230,10 +14022,18 @@ snapshots: ieee754@1.2.1: {} + if-ci@3.0.0: + dependencies: + execa: 1.0.0 + is-ci: 2.0.0 + ignore@5.3.1: {} ignore@6.0.2: {} + ignore@7.0.5: + optional: true + image-meta@0.2.1: {} immediate@3.0.6: {} @@ -13342,6 +14142,10 @@ snapshots: is-callable@1.2.7: {} + is-ci@2.0.0: + dependencies: + ci-info: 2.0.0 + is-core-module@2.15.0: dependencies: hasown: 2.0.2 @@ -13360,6 +14164,8 @@ snapshots: is-docker@3.0.0: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -13412,6 +14218,8 @@ snapshots: dependencies: protocols: 2.0.1 + is-stream@1.1.0: {} + is-stream@2.0.1: {} is-stream@3.0.0: {} @@ -13446,6 +14254,8 @@ snapshots: dependencies: system-architecture: 0.1.0 + isarray@0.0.1: {} + isarray@1.0.0: {} isarray@2.0.5: {} @@ -13469,7 +14279,7 @@ snapshots: jiti@1.21.6: {} - jiti@2.4.1: {} + jiti@2.4.2: {} js-levenshtein@1.1.6: {} @@ -13545,6 +14355,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -13553,6 +14365,9 @@ snapshots: knitwork@1.1.0: {} + knitwork@1.2.0: + optional: true + kolorist@1.8.0: {} launch-editor@2.9.1: @@ -13595,18 +14410,18 @@ snapshots: '@parcel/watcher-wasm': 2.4.1 citty: 0.1.6 clipboardy: 4.0.0 - consola: 3.2.3 - crossws: 0.3.1 + consola: 3.4.2 + crossws: 0.3.4 defu: 6.1.4 get-port-please: 3.1.2 - h3: 1.13.0 + h3: 1.15.1 http-shutdown: 1.2.2 - jiti: 2.4.1 + jiti: 2.4.2 mlly: 1.7.3 node-forge: 1.3.1 pathe: 1.1.2 std-env: 3.8.0 - ufo: 1.5.4 + ufo: 1.6.1 untun: 0.1.3 uqr: 0.1.2 @@ -13627,6 +14442,13 @@ snapshots: mlly: 1.7.3 pkg-types: 1.2.1 + local-pkg@1.1.1: + dependencies: + mlly: 1.7.4 + pkg-types: 2.1.1 + quansync: 0.2.10 + optional: true + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -13656,6 +14478,10 @@ snapshots: currently-unhandled: 0.4.1 signal-exit: 3.0.7 + lower-case@2.0.2: + dependencies: + tslib: 2.6.3 + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -13867,7 +14693,7 @@ snapshots: '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.2.0 devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.0 + micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 unist-util-position: 5.0.0 unist-util-visit: 5.0.0 @@ -13911,12 +14737,12 @@ snapshots: micromark-factory-space: 2.0.0 micromark-factory-title: 2.0.0 micromark-factory-whitespace: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-chunked: 2.0.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 micromark-util-classify-character: 2.0.0 micromark-util-html-tag-name: 2.0.0 micromark-util-normalize-identifier: 2.0.0 - micromark-util-resolve-all: 2.0.0 + micromark-util-resolve-all: 2.0.1 micromark-util-subtokenize: 2.0.1 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 @@ -14032,8 +14858,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.14.0 - acorn-jsx: 5.3.2(acorn@8.14.0) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) micromark-extension-mdx-expression: 3.0.0 micromark-extension-mdx-jsx: 3.0.1 micromark-extension-mdx-md: 2.0.0 @@ -14043,14 +14869,14 @@ snapshots: micromark-factory-destination@2.0.0: dependencies: - micromark-util-character: 2.1.0 + micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 micromark-factory-label@2.0.0: dependencies: devlop: 1.1.0 - micromark-util-character: 2.1.0 + micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 @@ -14068,13 +14894,13 @@ snapshots: micromark-factory-space@2.0.0: dependencies: - micromark-util-character: 2.1.0 + micromark-util-character: 2.1.1 micromark-util-types: 2.0.0 micromark-factory-title@2.0.0: dependencies: micromark-factory-space: 2.0.0 - micromark-util-character: 2.1.0 + micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 @@ -14090,19 +14916,28 @@ snapshots: micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + micromark-util-chunked@2.0.0: dependencies: micromark-util-symbol: 2.0.0 + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-classify-character@2.0.0: dependencies: - micromark-util-character: 2.1.0 + micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 micromark-util-combine-extensions@2.0.0: dependencies: - micromark-util-chunked: 2.0.0 + micromark-util-chunked: 2.0.1 micromark-util-types: 2.0.0 micromark-util-decode-numeric-character-reference@2.0.1: @@ -14139,16 +14974,26 @@ snapshots: dependencies: micromark-util-types: 2.0.0 + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.0 + micromark-util-sanitize-uri@2.0.0: dependencies: micromark-util-character: 2.1.0 micromark-util-encode: 2.0.0 micromark-util-symbol: 2.0.0 + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-subtokenize@2.0.1: dependencies: devlop: 1.1.0 - micromark-util-chunked: 2.0.0 + micromark-util-chunked: 2.0.1 micromark-util-symbol: 2.0.0 micromark-util-types: 2.0.0 @@ -14183,9 +15028,15 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.25.0: {} + mime-db@1.52.0: optional: true + mime-types@2.1.13: + dependencies: + mime-db: 1.25.0 + mime-types@2.1.35: dependencies: mime-db: 1.52.0 @@ -14245,6 +15096,14 @@ snapshots: pkg-types: 1.2.1 ufo: 1.5.4 + mlly@1.7.4: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + optional: true + mri@1.2.0: {} mrmime@2.0.0: {} @@ -14261,6 +15120,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoid@3.3.11: + optional: true + nanoid@3.3.7: {} nanoid@3.3.8: {} @@ -14276,7 +15138,9 @@ snapshots: neotraverse@0.6.18: {} - nitropack@2.10.4(typescript@5.5.4): + nice-try@1.0.5: {} + + nitropack@2.10.4(typescript@5.5.4)(xml2js@0.6.2): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@netlify/functions': 2.8.2 @@ -14314,7 +15178,7 @@ snapshots: hookable: 5.5.3 httpxy: 0.1.5 ioredis: 5.4.1 - jiti: 2.4.1 + jiti: 2.4.2 klona: 2.0.6 knitwork: 1.1.0 listhen: 1.9.0 @@ -14346,6 +15210,8 @@ snapshots: unstorage: 1.13.1(ioredis@5.4.1) untyped: 1.5.1 unwasm: 0.3.9 + optionalDependencies: + xml2js: 0.6.2 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -14372,6 +15238,11 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.6.3 + node-addon-api@7.1.0: {} node-fetch-native@1.6.4: {} @@ -14390,6 +15261,9 @@ snapshots: node-releases@2.0.18: {} + node-releases@2.0.19: + optional: true + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -14407,6 +15281,10 @@ snapshots: not@0.1.0: {} + npm-run-path@2.0.2: + dependencies: + path-key: 2.0.1 + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -14428,14 +15306,14 @@ snapshots: nuxi@3.16.0: {} - nuxt@3.14.1592(@parcel/watcher@2.4.1)(@types/node@20.14.11)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.31.6)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue-tsc@2.1.6(typescript@5.5.4)): + nuxt@3.14.1592(@parcel/watcher@2.4.1)(@types/node@20.14.11)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.42.0)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue-tsc@2.1.6(typescript@5.5.4))(xml2js@0.6.2): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 1.6.3(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue@3.5.13(typescript@5.5.4)) + '@nuxt/devtools': 1.6.3(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue@3.5.13(typescript@5.5.4)) '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) '@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) '@nuxt/telemetry': 2.6.0(magicast@0.3.5)(rollup@4.28.1) - '@nuxt/vite-builder': 3.14.1592(@types/node@20.14.11)(eslint@8.57.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.31.6)(typescript@5.5.4)(vue-tsc@2.1.6(typescript@5.5.4))(vue@3.5.13(typescript@5.5.4)) + '@nuxt/vite-builder': 3.14.1592(@types/node@20.14.11)(eslint@8.57.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.28.1)(sass@1.77.6)(terser@5.42.0)(typescript@5.5.4)(vue-tsc@2.1.6(typescript@5.5.4))(vue@3.5.13(typescript@5.5.4)) '@unhead/dom': 1.11.13 '@unhead/shared': 1.11.13 '@unhead/ssr': 1.11.13 @@ -14459,13 +15337,13 @@ snapshots: hookable: 5.5.3 ignore: 6.0.2 impound: 0.2.0(rollup@4.28.1) - jiti: 2.4.1 + jiti: 2.4.2 klona: 2.0.6 knitwork: 1.1.0 magic-string: 0.30.14 mlly: 1.7.3 nanotar: 0.1.1 - nitropack: 2.10.4(typescript@5.5.4) + nitropack: 2.10.4(typescript@5.5.4)(xml2js@0.6.2) nuxi: 3.16.0 nypm: 0.3.12 ofetch: 1.4.1 @@ -14559,6 +15437,15 @@ snapshots: tinyexec: 0.3.1 ufo: 1.5.4 + nypm@0.6.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 2.1.1 + tinyexec: 0.3.2 + optional: true + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -14603,6 +15490,9 @@ snapshots: ohash@1.1.4: {} + ohash@2.0.11: + optional: true + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -14657,6 +15547,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-finally@1.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -14700,6 +15592,11 @@ snapshots: pako@1.0.11: {} + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.6.3 + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -14761,12 +15658,19 @@ snapshots: parseurl@1.3.3: {} + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.6.3 + path-browserify@1.0.1: {} path-exists@4.0.0: {} path-is-absolute@1.0.1: {} + path-key@2.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -14784,6 +15688,9 @@ snapshots: pathe@1.1.2: {} + pathe@2.0.3: + optional: true + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -14816,6 +15723,20 @@ snapshots: mlly: 1.7.3 pathe: 1.1.2 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.7.4 + pathe: 2.0.3 + optional: true + + pkg-types@2.1.1: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.7 + pathe: 2.0.3 + optional: true + pluralize@8.0.0: {} possible-typed-array-names@1.0.0: {} @@ -15012,12 +15933,25 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.5: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + optional: true + posthog-js@1.158.2: dependencies: fflate: 0.4.8 preact: 10.23.2 web-vitals: 4.2.3 + postprocessing@6.37.6(three@0.172.0): + dependencies: + three: 0.172.0 + + potpack@1.0.2: {} + preact@10.23.2: {} preferred-pm@4.1.1: @@ -15032,15 +15966,17 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier-plugin-tailwindcss@0.6.5(prettier@3.3.2): + prettier-plugin-tailwindcss@0.6.5(prettier@3.6.2): dependencies: - prettier: 3.3.2 + prettier: 3.6.2 prettier@2.8.7: optional: true prettier@3.3.2: {} + prettier@3.6.2: {} + pretty-bytes@6.1.1: {} prismjs@1.29.0: {} @@ -15060,6 +15996,11 @@ snapshots: protocols@2.0.1: {} + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -15072,6 +16013,9 @@ snapshots: dependencies: side-channel: 1.1.0 + quansync@0.2.10: + optional: true + queue-microtask@1.2.3: {} queue-tick@1.0.1: {} @@ -15106,6 +16050,13 @@ snapshots: parse-json: 5.2.0 type-fest: 0.6.0 + readable-stream@1.0.34: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + readable-stream@2.3.8(patch_hash=h52dazg37p4h3yox67pw36akse): dependencies: core-util-is: 1.0.3 @@ -15282,6 +16233,8 @@ snapshots: rehype-stringify: 10.0.0 unified: 11.0.5 + relateurl@0.2.7: {} + remark-directive@3.0.0: dependencies: '@types/mdast': 4.0.4 @@ -15466,6 +16419,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.9 fsevents: 2.3.3 + rss@1.2.2: + dependencies: + mime-types: 2.1.13 + xml: 1.0.1 + run-applescript@7.0.0: {} run-parallel@1.2.0: @@ -15508,6 +16466,14 @@ snapshots: ajv-keywords: 3.5.2(ajv@6.12.6) optional: true + schema-utils@4.3.2: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + optional: true + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.11.0 @@ -15516,6 +16482,11 @@ snapshots: scule@1.3.0: {} + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@5.7.2: {} semver@6.3.1: {} @@ -15524,6 +16495,9 @@ snapshots: semver@7.7.1: {} + semver@7.7.2: + optional: true + send@0.19.0: dependencies: debug: 2.6.9 @@ -15607,10 +16581,16 @@ snapshots: '@img/sharp-win32-ia32': 0.33.5 '@img/sharp-win32-x64': 0.33.5 + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 + shebang-regex@1.0.0: {} + shebang-regex@3.0.0: {} shell-quote@1.8.1: {} @@ -15742,21 +16722,31 @@ snapshots: standard-as-callback@2.1.0: {} - starlight-openapi@0.14.0(@astrojs/markdown-remark@6.2.0)(@astrojs/starlight@0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1)))(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1))(openapi-types@12.1.3): + starlight-openapi@0.14.0(@astrojs/markdown-remark@6.2.0)(@astrojs/starlight@0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0)))(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0))(openapi-types@12.1.3): dependencies: '@astrojs/markdown-remark': 6.2.0 - '@astrojs/starlight': 0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1)) + '@astrojs/starlight': 0.32.2(astro@5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0)) '@readme/openapi-parser': 2.5.0(openapi-types@12.1.3) - astro: 5.4.1(@types/node@22.4.1)(db0@0.2.1)(jiti@2.4.1)(rollup@4.34.9)(sass@1.77.6)(terser@5.31.6)(typescript@5.8.2)(yaml@2.6.1) + astro: 5.4.1(@types/node@22.4.1)(db0@0.3.2)(jiti@2.4.2)(rollup@4.34.9)(sass@1.77.6)(terser@5.42.0)(typescript@5.8.2)(yaml@2.8.0) github-slugger: 2.0.0 url-template: 3.1.1 transitivePeerDependencies: - openapi-types + stats-gl@2.4.2(@types/three@0.172.0)(three@0.172.0): + dependencies: + '@types/three': 0.172.0 + three: 0.172.0 + + stats.js@0.17.0: {} + statuses@2.0.1: {} std-env@3.8.0: {} + std-env@3.9.0: + optional: true + stream-replace-string@2.0.0: {} streamx@2.18.0: @@ -15804,6 +16794,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + string_decoder@0.10.31: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -15825,8 +16817,12 @@ snapshots: dependencies: ansi-regex: 6.0.1 + strip-bom-string@1.0.0: {} + strip-bom@3.0.0: {} + strip-eof@1.0.0: {} + strip-final-newline@3.0.0: {} strip-indent@3.0.0: @@ -15839,6 +16835,11 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + optional: true + stripe@18.1.1(@types/node@22.4.1): dependencies: qs: 6.14.0 @@ -15967,6 +16968,9 @@ snapshots: tapable@2.2.1: {} + tapable@2.2.2: + optional: true + tar-stream@3.1.7: dependencies: b4a: 1.6.6 @@ -15982,20 +16986,20 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - terser-webpack-plugin@5.3.10(webpack@5.92.1): + terser-webpack-plugin@5.3.14(webpack@5.92.1): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 - schema-utils: 3.3.0 + schema-utils: 4.3.2 serialize-javascript: 6.0.2 - terser: 5.31.6 + terser: 5.42.0 webpack: 5.92.1 optional: true - terser@5.31.6: + terser@5.42.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -16013,8 +17017,31 @@ snapshots: dependencies: any-promise: 1.3.0 + three-custom-shader-material@5.4.0(three@0.172.0): + dependencies: + glsl-token-functions: 1.0.1 + glsl-token-string: 1.0.1 + glsl-tokenizer: 2.1.5 + object-hash: 3.0.0 + three: 0.172.0 + + three-stdlib@2.36.0(three@0.172.0): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.21 + draco3d: 1.5.7 + fflate: 0.6.10 + potpack: 1.0.2 + three: 0.172.0 + three@0.172.0: {} + through2@0.6.5: + dependencies: + readable-stream: 1.0.34 + xtend: 4.0.2 + tiny-invariant@1.3.3: {} tinyexec@0.3.1: {} @@ -16031,6 +17058,12 @@ snapshots: fdir: 6.4.3(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.6(picomatch@4.0.2) + picomatch: 4.0.2 + optional: true + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -16064,32 +17097,32 @@ snapshots: tslib@2.6.3: {} - turbo-darwin-64@2.2.3: + turbo-darwin-64@2.5.4: optional: true - turbo-darwin-arm64@2.2.3: + turbo-darwin-arm64@2.5.4: optional: true - turbo-linux-64@2.2.3: + turbo-linux-64@2.5.4: optional: true - turbo-linux-arm64@2.2.3: + turbo-linux-arm64@2.5.4: optional: true - turbo-windows-64@2.2.3: + turbo-windows-64@2.5.4: optional: true - turbo-windows-arm64@2.2.3: + turbo-windows-arm64@2.5.4: optional: true - turbo@2.2.3: + turbo@2.5.4: optionalDependencies: - turbo-darwin-64: 2.2.3 - turbo-darwin-arm64: 2.2.3 - turbo-linux-64: 2.2.3 - turbo-linux-arm64: 2.2.3 - turbo-windows-64: 2.2.3 - turbo-windows-arm64: 2.2.3 + turbo-darwin-64: 2.5.4 + turbo-darwin-arm64: 2.5.4 + turbo-linux-64: 2.5.4 + turbo-linux-arm64: 2.5.4 + turbo-windows-64: 2.5.4 + turbo-windows-arm64: 2.5.4 type-check@0.4.0: dependencies: @@ -16147,12 +17180,17 @@ snapshots: typescript@5.8.2: {} + typescript@5.8.3: + optional: true + uc.micro@1.0.6: {} uc.micro@2.1.0: {} ufo@1.5.4: {} + ufo@1.6.1: {} + ultrahtml@1.5.3: {} unbox-primitive@1.0.2: @@ -16171,9 +17209,17 @@ snapshots: magic-string: 0.30.17 unplugin: 1.16.0 + unctx@2.4.1: + dependencies: + acorn: 8.15.0 + estree-walker: 3.0.3 + magic-string: 0.30.17 + unplugin: 2.3.5 + optional: true + undici-types@5.26.5: {} - undici-types@6.19.6: + undici-types@6.19.8: optional: true unenv@1.10.0: @@ -16203,26 +17249,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unimport@3.14.4(rollup@3.29.4): - dependencies: - '@rollup/pluginutils': 5.1.3(rollup@3.29.4) - acorn: 8.14.0 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - local-pkg: 0.5.1 - magic-string: 0.30.14 - mlly: 1.7.3 - pathe: 1.1.2 - picomatch: 4.0.2 - pkg-types: 1.2.1 - scule: 1.3.0 - strip-literal: 2.1.1 - tinyglobby: 0.2.10 - unplugin: 1.16.0 - transitivePeerDependencies: - - rollup - optional: true - unimport@3.14.4(rollup@4.28.1): dependencies: '@rollup/pluginutils': 5.1.3(rollup@4.28.1) @@ -16242,24 +17268,22 @@ snapshots: transitivePeerDependencies: - rollup - unimport@3.14.4(rollup@4.34.9): + unimport@5.1.0: dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.34.9) - acorn: 8.14.0 + acorn: 8.15.0 escape-string-regexp: 5.0.0 estree-walker: 3.0.3 - local-pkg: 0.5.1 - magic-string: 0.30.14 - mlly: 1.7.3 - pathe: 1.1.2 + local-pkg: 1.1.1 + magic-string: 0.30.17 + mlly: 1.7.4 + pathe: 2.0.3 picomatch: 4.0.2 - pkg-types: 1.2.1 + pkg-types: 2.1.1 scule: 1.3.0 - strip-literal: 2.1.1 - tinyglobby: 0.2.10 - unplugin: 1.16.0 - transitivePeerDependencies: - - rollup + strip-literal: 3.0.0 + tinyglobby: 0.2.14 + unplugin: 2.3.5 + unplugin-utils: 0.2.4 optional: true unist-util-find-after@5.0.0: @@ -16310,6 +17334,12 @@ snapshots: universalify@2.0.1: {} + unplugin-utils@0.2.4: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.2 + optional: true + unplugin-vue-router@0.10.9(rollup@4.28.1)(vue-router@4.5.0(vue@3.5.13(typescript@5.5.4)))(vue@3.5.13(typescript@5.5.4)): dependencies: '@babel/types': 7.26.3 @@ -16342,6 +17372,13 @@ snapshots: acorn: 8.14.0 webpack-virtual-modules: 0.6.2 + unplugin@2.3.5: + dependencies: + acorn: 8.15.0 + picomatch: 4.0.2 + webpack-virtual-modules: 0.6.2 + optional: true + unstorage@1.13.1(ioredis@5.4.1): dependencies: anymatch: 3.1.3 @@ -16357,7 +17394,7 @@ snapshots: optionalDependencies: ioredis: 5.4.1 - unstorage@1.15.0(db0@0.2.1): + unstorage@1.15.0(db0@0.3.2): dependencies: anymatch: 3.1.3 chokidar: 4.0.3 @@ -16368,12 +17405,12 @@ snapshots: ofetch: 1.4.1 ufo: 1.5.4 optionalDependencies: - db0: 0.2.1 + db0: 0.3.2 untun@0.1.3: dependencies: citty: 0.1.6 - consola: 3.2.3 + consola: 3.4.2 pathe: 1.1.2 untyped@1.5.1: @@ -16382,12 +17419,21 @@ snapshots: '@babel/standalone': 7.26.4 '@babel/types': 7.26.3 defu: 6.1.4 - jiti: 2.4.1 + jiti: 2.4.2 mri: 1.2.0 scule: 1.3.0 transitivePeerDependencies: - supports-color + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.4 + jiti: 2.4.2 + knitwork: 1.2.0 + scule: 1.3.0 + optional: true + unwasm@0.3.9: dependencies: knitwork: 1.1.0 @@ -16403,6 +17449,13 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.1.3(browserslist@4.25.0): + dependencies: + browserslist: 4.25.0 + escalade: 3.2.0 + picocolors: 1.1.1 + optional: true + uqr@0.1.2: {} uri-js-replace@1.0.1: {} @@ -16437,17 +17490,17 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-hot-client@0.2.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6)): + vite-hot-client@0.2.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0)): dependencies: - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) - vite-node@2.1.8(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6): + vite-node@2.1.8(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0): dependencies: cac: 6.7.14 debug: 4.4.0(supports-color@9.4.0) es-module-lexer: 1.5.4 pathe: 1.1.2 - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) transitivePeerDependencies: - '@types/node' - less @@ -16459,7 +17512,7 @@ snapshots: - supports-color - terser - vite-plugin-checker@0.8.0(eslint@8.57.0)(optionator@0.9.4)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6))(vue-tsc@2.1.6(typescript@5.5.4)): + vite-plugin-checker@0.8.0(eslint@8.57.0)(optionator@0.9.4)(typescript@5.5.4)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0))(vue-tsc@2.1.6(typescript@5.5.4)): dependencies: '@babel/code-frame': 7.26.2 ansi-escapes: 4.3.2 @@ -16471,7 +17524,7 @@ snapshots: npm-run-path: 4.0.1 strip-ansi: 6.0.1 tiny-invariant: 1.3.3 - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) vscode-languageclient: 7.0.0 vscode-languageserver: 7.0.0 vscode-languageserver-textdocument: 1.0.12 @@ -16482,7 +17535,7 @@ snapshots: typescript: 5.5.4 vue-tsc: 2.1.6(typescript@5.5.4) - vite-plugin-inspect@0.8.9(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6)): + vite-plugin-inspect@0.8.9(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(rollup@4.28.1)(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0)): dependencies: '@antfu/utils': 0.7.10 '@rollup/pluginutils': 5.1.3(rollup@4.28.1) @@ -16493,14 +17546,14 @@ snapshots: perfect-debounce: 1.0.0 picocolors: 1.1.1 sirv: 3.0.0 - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) optionalDependencies: '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1) transitivePeerDependencies: - rollup - supports-color - vite-plugin-vue-inspector@5.1.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6)): + vite-plugin-vue-inspector@5.1.3(vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0)): dependencies: '@babel/core': 7.26.0 '@babel/plugin-proposal-decorators': 7.24.7(@babel/core@7.26.0) @@ -16511,7 +17564,7 @@ snapshots: '@vue/compiler-dom': 3.5.13 kolorist: 1.8.0 magic-string: 0.30.14 - vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6) + vite: 5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0) transitivePeerDependencies: - supports-color @@ -16520,19 +17573,19 @@ snapshots: svgo: 3.3.2 vue: 3.5.13(typescript@5.5.4) - vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6): + vite@4.5.3(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0): dependencies: esbuild: 0.18.20 - postcss: 8.5.3 + postcss: 8.5.5 rollup: 3.29.4 optionalDependencies: '@types/node': 22.4.1 fsevents: 2.3.3 sass: 1.77.6 - terser: 5.31.6 + terser: 5.42.0 optional: true - vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.31.6): + vite@5.4.11(@types/node@20.14.11)(sass@1.77.6)(terser@5.42.0): dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -16541,9 +17594,9 @@ snapshots: '@types/node': 20.14.11 fsevents: 2.3.3 sass: 1.77.6 - terser: 5.31.6 + terser: 5.42.0 - vite@5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.31.6): + vite@5.4.11(@types/node@22.4.1)(sass@1.77.6)(terser@5.42.0): dependencies: esbuild: 0.21.5 postcss: 8.4.49 @@ -16552,9 +17605,9 @@ snapshots: '@types/node': 22.4.1 fsevents: 2.3.3 sass: 1.77.6 - terser: 5.31.6 + terser: 5.42.0 - vite@6.2.0(@types/node@22.4.1)(jiti@2.4.1)(sass@1.77.6)(terser@5.31.6)(yaml@2.6.1): + vite@6.2.0(@types/node@22.4.1)(jiti@2.4.2)(sass@1.77.6)(terser@5.42.0)(yaml@2.8.0): dependencies: esbuild: 0.25.0 postcss: 8.5.3 @@ -16562,14 +17615,14 @@ snapshots: optionalDependencies: '@types/node': 22.4.1 fsevents: 2.3.3 - jiti: 2.4.1 + jiti: 2.4.2 sass: 1.77.6 - terser: 5.31.6 - yaml: 2.6.1 + terser: 5.42.0 + yaml: 2.8.0 - vitefu@1.0.6(vite@6.2.0(@types/node@22.4.1)(jiti@2.4.1)(sass@1.77.6)(terser@5.31.6)(yaml@2.6.1)): + vitefu@1.0.6(vite@6.2.0(@types/node@22.4.1)(jiti@2.4.2)(sass@1.77.6)(terser@5.42.0)(yaml@2.8.0)): optionalDependencies: - vite: 6.2.0(@types/node@22.4.1)(jiti@2.4.1)(sass@1.77.6)(terser@5.31.6)(yaml@2.6.1) + vite: 6.2.0(@types/node@22.4.1)(jiti@2.4.2)(sass@1.77.6)(terser@5.42.0)(yaml@2.8.0) volar-service-css@0.0.62(@volar/language-service@2.4.11): dependencies: @@ -16596,12 +17649,12 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.11 - volar-service-prettier@0.0.62(@volar/language-service@2.4.11)(prettier@3.3.2): + volar-service-prettier@0.0.62(@volar/language-service@2.4.11)(prettier@3.6.2): dependencies: vscode-uri: 3.0.8 optionalDependencies: '@volar/language-service': 2.4.11 - prettier: 3.3.2 + prettier: 3.6.2 volar-service-typescript-twoslash-queries@0.0.62(@volar/language-service@2.4.11): dependencies: @@ -16699,10 +17752,10 @@ snapshots: vue-devtools-stub@0.1.0: {} - vue-eslint-parser@9.4.3(eslint@9.13.0(jiti@2.4.1)): + vue-eslint-parser@9.4.3(eslint@9.13.0(jiti@2.4.2)): dependencies: debug: 4.4.0(supports-color@9.4.0) - eslint: 9.13.0(jiti@2.4.1) + eslint: 9.13.0(jiti@2.4.2) eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 @@ -16781,19 +17834,19 @@ snapshots: optionalDependencies: typescript: 5.5.4 - vue@3.5.13(typescript@5.8.2): + vue@3.5.13(typescript@5.8.3): dependencies: '@vue/compiler-dom': 3.5.13 '@vue/compiler-sfc': 3.5.13 '@vue/runtime-dom': 3.5.13 - '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.8.2)) + '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.8.3)) '@vue/shared': 3.5.13 optionalDependencies: - typescript: 5.8.2 + typescript: 5.8.3 w3c-keyname@2.2.8: {} - watchpack@2.4.2: + watchpack@2.4.4: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 @@ -16805,7 +17858,7 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-sources@3.2.3: + webpack-sources@3.3.2: optional: true webpack-virtual-modules@0.6.2: {} @@ -16813,16 +17866,16 @@ snapshots: webpack@5.92.1: dependencies: '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.14.0 - acorn-import-attributes: 1.9.5(acorn@8.14.0) - browserslist: 4.24.2 + '@types/estree': 1.0.8 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + browserslist: 4.25.0 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.6.0 + enhanced-resolve: 5.18.1 + es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -16832,10 +17885,10 @@ snapshots: mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.92.1) - watchpack: 2.4.2 - webpack-sources: 3.2.3 + tapable: 2.2.2 + terser-webpack-plugin: 5.3.14(webpack@5.92.1) + watchpack: 2.4.4 + webpack-sources: 3.3.2 transitivePeerDependencies: - '@swc/core' - esbuild @@ -16869,6 +17922,10 @@ snapshots: gopd: 1.0.1 has-tostringtag: 1.0.2 + which@1.3.1: + dependencies: + isexe: 2.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -16911,11 +17968,22 @@ snapshots: xml-name-validator@4.0.0: {} + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xml@1.0.1: {} + + xmlbuilder@11.0.1: {} + xss@1.0.15: dependencies: commander: 2.20.3 cssfilter: 0.0.10 + xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {} @@ -16945,6 +18013,9 @@ snapshots: yaml@2.6.1: {} + yaml@2.8.0: + optional: true + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..e88baf106 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.88.0" diff --git a/turbo.json b/turbo.json deleted file mode 100644 index 71cd9ce3b..000000000 --- a/turbo.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://turbo.build/schema.json", - "tasks": { - "build": { - "dependsOn": ["^build"], - "inputs": ["$TURBO_DEFAULT$", ".env*"], - "outputs": [".nuxt/**", "dist/**", ".output/**", "target/**"], - "env": [ - "NODE_ENV", - "SITE_URL", - "BASE_URL", - "FLAG_OVERRIDES", - "BROWSER_BASE_URL", - "RATE_LIMIT_IGNORE_KEY", - "VERCEL_*", - "CF_PAGES_*", - "HEROKU_APP_NAME", - "STRIPE_PUBLISHABLE_KEY", - "PYRO_BASE_URL", - "PROD_OVERRIDE", - "PYRO_MASTER_KEY", - "PORT", - "SQLX_OFFLINE" - ] - }, - "lint": { - "env": ["SQLX_OFFLINE"] - }, - "dev": { - "cache": false, - "persistent": true, - "inputs": ["$TURBO_DEFAULT$", ".env*"], - "env": ["DISPLAY", "WEBKIT_DISABLE_DMABUF_RENDERER"] - }, - "test": { - "env": ["SQLX_OFFLINE", "DATABASE_URL"] - }, - "fix": { - "cache": false - } - } -} diff --git a/turbo.jsonc b/turbo.jsonc new file mode 100644 index 000000000..77696a173 --- /dev/null +++ b/turbo.jsonc @@ -0,0 +1,66 @@ +{ + "$schema": "./node_modules/turbo/schema.json", + "concurrency": "100%", + "tasks": { + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": [".nuxt/**", "dist/**", ".output/**", "$TURBO_ROOT$/target/**"], + "env": [ + "NODE_ENV", + "SITE_URL", + "BASE_URL", + "FLAG_OVERRIDES", + "BROWSER_BASE_URL", + "RATE_LIMIT_IGNORE_KEY", + "VERCEL_*", + "CF_PAGES_*", + "HEROKU_APP_NAME", + "STRIPE_PUBLISHABLE_KEY", + "PYRO_BASE_URL", + "PROD_OVERRIDE", + "PYRO_MASTER_KEY", + "PORT", + "SQLX_OFFLINE", + "DATABASE_URL", + "CARGO_*", + "RUST_*", + "RUSTFLAGS", + "FORCE_COLOR", + "NEXTEST_*" + ] + }, + "lint": { + "env": [ + "DATABASE_URL", + "SQLX_OFFLINE", + "CARGO_*", + "RUST_*", + "RUSTFLAGS", + "FORCE_COLOR", + "NEXTEST_*" + ] + }, + "dev": { + "cache": false, + "persistent": true, + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "env": ["CARGO_*", "RUST_*", "RUSTFLAGS", "FORCE_COLOR", "NEXTEST_*"], + "passThroughEnv": ["DISPLAY", "WEBKIT_DISABLE_DMABUF_RENDERER"] + }, + "test": { + "env": [ + "SQLX_OFFLINE", + "DATABASE_URL", + "CARGO_*", + "RUST_*", + "RUSTFLAGS", + "FORCE_COLOR", + "NEXTEST_*" + ] + }, + "fix": { + "cache": false + } + } +}