diff --git a/.github/scripts/detect-component-changes.sh b/.github/scripts/detect-component-changes.sh index 845287e9..321691eb 100755 --- a/.github/scripts/detect-component-changes.sh +++ b/.github/scripts/detect-component-changes.sh @@ -80,6 +80,8 @@ changed_files="$(git diff --name-only --no-renames "$base_sha" "$head_sha")" frontend=false backend=false +launcher=false +launcher_build=false while IFS= read -r path; do [[ -z "$path" ]] && continue @@ -91,6 +93,18 @@ while IFS= read -r path; do backend/*) backend=true ;; + launcher/*) + launcher=true + launcher_build=true + ;; + .github/workflows/tests.yml|.github/scripts/detect-component-changes.sh|.github/scripts/should-run-tests-workflow.sh) + frontend=true + backend=true + launcher=true + ;; + .github/workflows/launcher-release.yml) + launcher=true + ;; esac done <<< "$changed_files" @@ -98,6 +112,8 @@ done <<< "$changed_files" echo "Change detection range: $range_label" echo "Frontend changed: $frontend" echo "Backend changed: $backend" + echo "Launcher changed: $launcher" + echo "Launcher build needed: $launcher_build" echo "Changed files:" if [[ -n "$changed_files" ]]; then printf '%s\n' "$changed_files" @@ -109,4 +125,6 @@ done <<< "$changed_files" { echo "frontend=$frontend" echo "backend=$backend" + echo "launcher=$launcher" + echo "launcher_build=$launcher_build" } >> "${GITHUB_OUTPUT:-/dev/stdout}" diff --git a/.github/scripts/should-run-tests-workflow.sh b/.github/scripts/should-run-tests-workflow.sh index 37d0dc92..ae0b4b6d 100755 --- a/.github/scripts/should-run-tests-workflow.sh +++ b/.github/scripts/should-run-tests-workflow.sh @@ -5,10 +5,6 @@ event_name="${GITHUB_EVENT_NAME:-}" repo="${GITHUB_REPOSITORY:-}" repo_owner="${GITHUB_REPOSITORY_OWNER:-${repo%%/*}}" ref_name="${GITHUB_REF_NAME:-}" -pr_action="${PR_ACTION:-}" -pr_head_repo="${PR_HEAD_REPO:-}" -pr_head_sha="${PR_HEAD_SHA:-}" -workflow_file="${WORKFLOW_FILE:-tests.yml}" should_run=true reason="This workflow run owns the work." @@ -24,14 +20,8 @@ open_pr_count_for_branch() { --jq 'length' } -covering_push_run_for_pr_head() { - gh api --method GET "repos/$repo/actions/workflows/$workflow_file/runs" \ - -f event=push \ - -f head_sha="$pr_head_sha" \ - --jq '.workflow_runs[] | select((.status != "completed") or (.conclusion != "cancelled" and .conclusion != "skipped")) | .html_url' | - head -n 1 -} - +# PR runs always own their tests. A queued push may itself skip because a PR +# exists, so its presence cannot prove that the commit has test coverage. if [[ "$event_name" == "push" ]]; then if gh_available && [[ -n "$repo" && -n "$repo_owner" && -n "$ref_name" ]]; then if open_pr_count="$(open_pr_count_for_branch)"; then @@ -45,15 +35,6 @@ if [[ "$event_name" == "push" ]]; then else echo "::warning::GitHub CLI or token unavailable; running tests to avoid missing coverage." fi -elif [[ "$event_name" == pull_request* ]]; then - if [[ "$pr_action" == "opened" || "$pr_action" == "reopened" ]]; then - if gh_available && [[ "$pr_head_repo" == "$repo" && -n "$pr_head_sha" ]]; then - if push_run_url="$(covering_push_run_for_pr_head)" && [[ -n "$push_run_url" ]]; then - should_run=false - reason="Skipping PR workflow because an existing push run already covers this commit: $push_run_url" - fi - fi - fi fi echo "$reason" diff --git a/.github/tests/workflow-scripts.test.mjs b/.github/tests/workflow-scripts.test.mjs new file mode 100644 index 00000000..90a2993e --- /dev/null +++ b/.github/tests/workflow-scripts.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, test } from 'node:test'; + +const scripts = fileURLToPath(new URL('../scripts/', import.meta.url)); +let directory; +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'modtale-workflow-')); +}); +afterEach(() => fs.rmSync(directory, { recursive: true, force: true })); + +function run(script, overrides = {}) { + const output = path.join(directory, 'output'); + fs.writeFileSync(output, ''); + const result = spawnSync('bash', [path.join(scripts, script)], { + cwd: directory, + encoding: 'utf8', + env: { + ...process.env, + GITHUB_OUTPUT: output, + GITHUB_REPOSITORY: 'Modtale/modtale', + GITHUB_REPOSITORY_OWNER: 'Modtale', + GITHUB_REF_NAME: 'audit', + GH_TOKEN: 'test-token', + ...overrides, + }, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + return fs.readFileSync(output, 'utf8'); +} + +test('PR creation and synchronization always retain their own test coverage', () => { + for (const action of ['opened', 'reopened', 'synchronize']) { + const output = run('should-run-tests-workflow.sh', { + GITHUB_EVENT_NAME: 'pull_request', PR_ACTION: action, + }); + assert.match(output, /^should_run=true$/m); + } +}); + +test('push skips only when the GitHub API confirms an open PR', () => { + const bin = path.join(directory, 'bin'); + fs.mkdirSync(bin); + const gh = path.join(bin, 'gh'); + fs.writeFileSync(gh, '#!/bin/sh\nprintf "1\\n"\n', { mode: 0o755 }); + const env = { GITHUB_EVENT_NAME: 'push', PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + assert.match(run('should-run-tests-workflow.sh', env), /^should_run=false$/m); + fs.writeFileSync(gh, '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + assert.match(run('should-run-tests-workflow.sh', env), /^should_run=true$/m); +}); + +test('test-workflow changes select all components without requesting launcher packaging', () => { + const git = (...args) => { + const result = spawnSync('git', args, { cwd: directory, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + git('init', '--quiet'); + git('config', 'user.name', 'Audit Test'); + git('config', 'user.email', 'audit@example.test'); + git('config', 'commit.gpgsign', 'false'); + fs.writeFileSync(path.join(directory, 'README.md'), 'test'); + git('add', '.'); + git('commit', '--quiet', '-m', 'Initial fixture'); + const base = git('rev-parse', 'HEAD'); + fs.mkdirSync(path.join(directory, '.github/workflows'), { recursive: true }); + fs.writeFileSync(path.join(directory, '.github/workflows/tests.yml'), 'name: tests'); + git('add', '.'); + git('commit', '--quiet', '-m', 'Change test workflow'); + const output = run('detect-component-changes.sh', { + GITHUB_EVENT_NAME: 'push', GITHUB_SHA: git('rev-parse', 'HEAD'), PUSH_BEFORE_SHA: base, + }); + for (const component of ['frontend', 'backend', 'launcher']) { + assert.match(output, new RegExp(`^${component}=true$`, 'm')); + } + assert.match(output, /^launcher_build=false$/m); +}); diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 02139ee3..c2b4cd74 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -25,6 +25,12 @@ jobs: permissions: contents: 'read' id-token: 'write' + outputs: + launcher_build: ${{ steps.filter.outputs.launcher_build }} + launcher_site_base_url: ${{ steps.launcher_metadata.outputs.site_base_url }} + launcher_api_base_url: ${{ steps.launcher_metadata.outputs.api_base_url }} + launcher_version: ${{ steps.launcher_metadata.outputs.version }} + launcher_artifact_prefix: ${{ steps.launcher_metadata.outputs.artifact_prefix }} steps: - uses: actions/checkout@v4 @@ -721,6 +727,21 @@ jobs: fi fi + - name: Export launcher build metadata + id: launcher_metadata + run: | + short_sha="${GITHUB_SHA:0:7}" + suffix="${TAG:-preview}-$short_sha" + suffix="$(printf '%s' "$suffix" | sed 's/[^A-Za-z0-9.-]/-/g' | sed 's/--*/-/g' | cut -c 1-50 | sed 's/^[.-]*//;s/[.-]*$//')" + if [ -z "$suffix" ]; then + suffix="preview-$short_sha" + fi + + echo "site_base_url=$FINAL_FRONTEND_URL" >> "$GITHUB_OUTPUT" + echo "api_base_url=$API_URL" >> "$GITHUB_OUTPUT" + echo "version=0.1.0-$suffix" >> "$GITHUB_OUTPUT" + echo "artifact_prefix=launcher-$suffix" >> "$GITHUB_OUTPUT" + - name: Notify Admin Bot of Backend Production Deploy if: env.ENV_TYPE == 'prod' && (steps.update_backend_self_awareness.outputs.revision != '' || steps.deploy_backend.outputs.revision != '') env: @@ -835,6 +856,154 @@ jobs: echo "---" >> $GITHUB_STEP_SUMMARY echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY + package-launcher: + name: Package Staging Launcher (${{ matrix.name }}) + needs: deploy + if: github.repository == 'Modtale/modtale' && needs.deploy.outputs.launcher_build == 'true' + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - name: Linux Packages + os: ubuntu-latest + artifact: linux + - name: Windows Installer + os: windows-latest + artifact: windows + - name: macOS DMG + os: macos-latest + artifact: macos + defaults: + run: + working-directory: launcher + shell: bash + env: + LAUNCHER_SITE_BASE_URL: ${{ needs.deploy.outputs.launcher_site_base_url }} + LAUNCHER_API_BASE_URL: ${{ needs.deploy.outputs.launcher_api_base_url }} + LAUNCHER_VERSION: ${{ needs.deploy.outputs.launcher_version }} + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Validate launcher target URLs + run: | + : "${LAUNCHER_SITE_BASE_URL:?Launcher site URL was not produced by the deploy job.}" + : "${LAUNCHER_API_BASE_URL:?Launcher API URL was not produced by the deploy job.}" + : "${LAUNCHER_VERSION:?Launcher version was not produced by the deploy job.}" + + echo "Launcher site URL: $LAUNCHER_SITE_BASE_URL" + echo "Launcher API URL: $LAUNCHER_API_BASE_URL" + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Set up Linux packaging tooling + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + binutils \ + flatpak \ + rpm \ + tar \ + xz-utils \ + zstd + sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + sudo flatpak install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 + curl -L \ + -o "$RUNNER_TEMP/appimagetool" \ + https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x "$RUNNER_TEMP/appimagetool" + echo "APPIMAGETOOL=$RUNNER_TEMP/appimagetool" >> "$GITHUB_ENV" + echo "APPIMAGE_EXTRACT_AND_RUN=1" >> "$GITHUB_ENV" + + - name: Set up Windows installer tooling + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install wixtoolset -y --no-progress + $wix = Get-ChildItem "C:\Program Files (x86)" -Directory -Filter "WiX Toolset*" | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($null -eq $wix) { + throw "WiX Toolset was not installed." + } + "$($wix.FullName)\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + + - name: Ensure Gradle wrapper is executable + run: chmod +x gradlew + + - name: Build staging launcher package + run: | + package_task="packageAll" + if [ "$RUNNER_OS" = "Linux" ]; then + package_task="packageLinuxAll" + fi + + ./gradlew clean "$package_task" \ + -PlauncherVersion="$LAUNCHER_VERSION" \ + -PmodtaleSiteBaseUrl="$LAUNCHER_SITE_BASE_URL" \ + -PmodtaleApiBaseUrl="$LAUNCHER_API_BASE_URL" + + - name: Upload Linux AppImage package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-appimage + path: launcher/build/distributions/*.AppImage + if-no-files-found: error + + - name: Upload Linux Debian package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-deb + path: launcher/build/distributions/*.deb + if-no-files-found: error + + - name: Upload Linux RPM package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-rpm + path: launcher/build/distributions/*.rpm + if-no-files-found: error + + - name: Upload Linux Flatpak package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-flatpak + path: launcher/build/distributions/*.flatpak + if-no-files-found: error + + - name: Upload Linux pacman package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-linux-pacman + path: launcher/build/distributions/*.pkg.tar.zst + if-no-files-found: error + + - name: Upload staging launcher package + if: runner.os != 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.deploy.outputs.launcher_artifact_prefix }}-${{ matrix.artifact }} + path: launcher/build/distributions/* + if-no-files-found: error + cleanup-branch-preview: name: Clean Up Deleted Branch Preview if: github.repository == 'Modtale/modtale' && github.event.deleted == true && startsWith(github.ref, 'refs/heads/') && github.ref_name != 'main' && github.ref_name != 'develop' diff --git a/.github/workflows/launcher-release.yml b/.github/workflows/launcher-release.yml new file mode 100644 index 00000000..a00616e5 --- /dev/null +++ b/.github/workflows/launcher-release.yml @@ -0,0 +1,213 @@ +name: Launcher Release + +on: + push: + tags: + - 'v*' + - 'launcher-v*' + workflow_dispatch: + inputs: + version: + description: Launcher version to package, for example 0.2.0 + required: true + type: string + +permissions: + contents: write + +jobs: + metadata: + name: Resolve Release Metadata + if: github.repository == 'Modtale/modtale' + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + steps: + - name: Resolve launcher version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + raw_version="${{ inputs.version }}" + version="${raw_version#launcher-v}" + version="${version#v}" + tag="launcher-v$version" + else + tag="${GITHUB_REF_NAME}" + version="${tag#launcher-v}" + version="${version#v}" + fi + + if ! [[ "$version" =~ ^[0-9]+(\.[0-9]+){0,2}([.-][A-Za-z0-9]+([.-][A-Za-z0-9]+)*)?$ ]]; then + echo "::error::Launcher version '$version' is not a valid package version." + exit 1 + fi + + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + package: + name: Package Launcher (${{ matrix.name }}) + needs: metadata + if: github.repository == 'Modtale/modtale' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux Packages + os: ubuntu-latest + artifact: launcher-linux + - name: Windows Installer + os: windows-latest + artifact: launcher-windows + - name: macOS DMG + os: macos-latest + artifact: launcher-macos + defaults: + run: + working-directory: launcher + shell: bash + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Set up Linux packaging tooling + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + binutils \ + flatpak \ + rpm \ + tar \ + xz-utils \ + zstd + sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true + sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + sudo flatpak install -y flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 + curl -L \ + -o "$RUNNER_TEMP/appimagetool" \ + https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x "$RUNNER_TEMP/appimagetool" + echo "APPIMAGETOOL=$RUNNER_TEMP/appimagetool" >> "$GITHUB_ENV" + echo "APPIMAGE_EXTRACT_AND_RUN=1" >> "$GITHUB_ENV" + + - name: Set up Windows installer tooling + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install wixtoolset -y --no-progress + $wix = Get-ChildItem "C:\Program Files (x86)" -Directory -Filter "WiX Toolset*" | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($null -eq $wix) { + throw "WiX Toolset was not installed." + } + "$($wix.FullName)\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + + - name: Ensure Gradle wrapper is executable + run: chmod +x gradlew + + - name: Build native launcher package + run: | + package_task="packageAll" + if [ "$RUNNER_OS" = "Linux" ]; then + package_task="packageLinuxAll" + fi + + ./gradlew clean "$package_task" -PlauncherVersion="${{ needs.metadata.outputs.version }}" + + - name: Upload Linux AppImage package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-appimage + path: launcher/build/distributions/*.AppImage + if-no-files-found: error + + - name: Upload Linux Debian package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-deb + path: launcher/build/distributions/*.deb + if-no-files-found: error + + - name: Upload Linux RPM package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-rpm + path: launcher/build/distributions/*.rpm + if-no-files-found: error + + - name: Upload Linux Flatpak package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-flatpak + path: launcher/build/distributions/*.flatpak + if-no-files-found: error + + - name: Upload Linux pacman package + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: launcher-linux-pacman + path: launcher/build/distributions/*.pkg.tar.zst + if-no-files-found: error + + - name: Upload launcher package + if: runner.os != 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: launcher/build/distributions/* + if-no-files-found: error + + publish: + name: Publish Launcher Release + needs: + - metadata + - package + if: github.repository == 'Modtale/modtale' + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.metadata.outputs.tag }} + LAUNCHER_VERSION: ${{ needs.metadata.outputs.version }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Download packaged launchers + uses: actions/download-artifact@v4 + with: + path: launcher-dist + + - name: Create checksums + run: | + find launcher-dist -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS + + - name: Publish GitHub release + run: | + if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release create "$RELEASE_TAG" \ + --target "$GITHUB_SHA" \ + --title "Modtale Launcher $LAUNCHER_VERSION" \ + --generate-notes + fi + + mapfile -d '' release_files < <(find launcher-dist -type f -print0) + gh release upload "$RELEASE_TAG" "${release_files[@]}" SHA256SUMS --clobber diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 564565a8..e682dbc3 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -28,16 +28,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} - PR_ACTION: ${{ github.event.action }} - PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - WORKFLOW_FILE: lighthouse.yml run: bash .github/scripts/should-run-tests-workflow.sh detect-changes: name: Detect Changes needs: dedupe - if: needs.dedupe.outputs.should_run == 'true' + if: needs.dedupe.outputs.should_run == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') runs-on: ubuntu-latest outputs: frontend: ${{ steps.filter.outputs.frontend }} @@ -59,7 +55,7 @@ jobs: audit: name: Lighthouse Audit (non-blocking) needs: detect-changes - if: needs.detect-changes.outputs.frontend == 'true' + if: needs.detect-changes.outputs.frontend == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') runs-on: ubuntu-latest continue-on-error: true defaults: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 06ea2a9f..c120484d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,12 +28,25 @@ jobs: env: GH_TOKEN: ${{ github.token }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} - PR_ACTION: ${{ github.event.action }} - PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - WORKFLOW_FILE: tests.yml run: bash .github/scripts/should-run-tests-workflow.sh + scripts: + name: Repository Script Tests + needs: dedupe + if: needs.dedupe.outputs.should_run == 'true' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.12.0 + + - name: Test workflow and fixture scripts + run: node --test .github/tests/*.test.mjs mock-db/tests/*.test.mjs + detect-changes: name: Detect Changes needs: dedupe @@ -42,6 +55,7 @@ jobs: outputs: frontend: ${{ steps.filter.outputs.frontend }} backend: ${{ steps.filter.outputs.backend }} + launcher: ${{ steps.filter.outputs.launcher }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -60,7 +74,7 @@ jobs: frontend: name: Frontend Tests needs: detect-changes - if: needs.detect-changes.outputs.frontend == 'true' + if: needs.detect-changes.outputs.frontend == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') runs-on: ubuntu-latest defaults: run: @@ -80,6 +94,9 @@ jobs: - name: Install frontend dependencies run: npm ci + - name: Check frontend types + run: npm run check + - name: Run frontend tests run: npm run test @@ -122,3 +139,31 @@ jobs: - name: Run backend tests run: ./gradlew test + + launcher: + name: Launcher Tests + needs: detect-changes + if: needs.detect-changes.outputs.launcher == 'true' && github.repository == 'Modtale/modtale' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Modtale/modtale') + runs-on: ubuntu-latest + defaults: + run: + working-directory: launcher + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Ensure Gradle wrapper is executable + run: chmod +x gradlew + + - name: Run launcher tests + run: ./gradlew test diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 00000000..236196b4 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,87 @@ +# Codebase audit — 2026-09-06 + +The audit covers the tracked monorepo snapshot at `b54cb0cf` and the changes on `codebase-audit`. Work was performed in a separate checkout because the original `launcher` branch was being edited concurrently. Subsequent changes on that other branch are outside this snapshot. + +## Review coverage + +| Area | Review and improvements | +| --- | --- | +| Backend security | Authentication filters, session identity, MFA entry points, API-key scopes, CSRF, CORS, cookie configuration, signing defaults, error responses, and one-use tokens | +| Backend services | Project and organization access boundaries, upload/archive validation, storage and download flows, external wiki integration, social-preview image fetching, and detached status persistence | +| Frontend | API and cookie handling, session restoration, OAuth error rendering, prefetch lifetime/concurrency, modal scroll ownership, markdown/SSR serialization boundaries, type diagnostics, browser imports, and production build | +| Launcher | Session/settings/cache persistence, archive and override destinations, download response ownership and interrupted transfers, update downloads, provider verification, existing UI tests, and packaging configuration | +| Supporting code | Fixture generation/loading/validation, shared fixture paths and collection definitions, test deduplication, component selection, preview orchestration, dependency installation, and contributor instructions | + +This was a source and automated-test audit, not a production penetration test or proof that every defect has been eliminated. Small, targeted changes were preferred over wholesale rewrites of established services and UI components. + +## Implemented findings + +### Authentication and security + +- Disabled the unused legacy form-login endpoint, which could authenticate outside the application's MFA flow. A test builds the real security filter chain and checks that the password-login filter is absent while CSRF remains active. +- API-key authentication rejects blank, invalid, and orphaned credentials instead of falling back to browser-session identity. Successful API authentication uses a new security context rather than mutating the shared session context. +- Stable account IDs no longer fall back to usernames when the original account disappears. This prevents an old principal from resolving to an account that later reuses the name. +- Credentialed CORS is confined to trusted frontend origins. Third-party API-key clients keep noncredentialed access, and unrelated Cloud Run sites are not trusted as previews. +- Session mutations, including account and API-key operations, require CSRF tokens in preview environments as well as production. Only explicitly identified public POST operations and API-key requests are exempt. +- Added a noncacheable CSRF-token endpoint for trusted frontends whose API cookies reside on another host. Concurrent client refreshes share one request. +- Session restoration queries the API instead of assuming the absence of JavaScript-readable cookies means the user is signed out. +- Localhost checks compare parsed hosts, and permissive Cloud Run substring checks were removed. Provider/method normalization is independent of the server locale. +- Removed publicly known fallback signing secrets. An unset `PRE_AUTH_SECRET` creates a random per-process secret; explicitly configured secrets remain supported across replicas. Pre-auth signature comparison uses `MessageDigest.isEqual`. +- Internal server errors return public fallback messages instead of raw exception details. Server logs retain diagnostic exceptions. +- One-use download tokens are claimed with atomic map removal, so concurrent requests cannot both consume them. Dependency selections are copied when tokens are issued. + +CORS and CSRF changes follow the [Spring Framework CORS guidance](https://docs.spring.io/spring-framework/reference/web/webmvc-cors.html) and [Spring Security CSRF documentation](https://www.springframework.org/spring-security/reference/servlet/exploits/csrf.html). + +### Data integrity and resource handling + +- Launcher settings, installed-project records, sessions, and disk API cache share a JSON writer that writes and flushes a temporary file before replacing the prior document. Serialization-failure tests verify preservation of existing data and temporary-file cleanup. Filesystems without atomic moves use a replacement fallback. +- Locked modpack paths reject dot-segment aliases, and override destinations reject existing symbolic links. Tests exercise traversal and writes through links outside the instance. +- Download response bodies close on HTTP failures as well as success. Failed transfers remove incomplete temporary files. Installer updates finish downloading before replacing an existing installer. +- Social-preview images are fetched only from known site origins, the configured storage origin, or restricted local asset routes. Redirects are disabled, downloads are capped at 10 MiB, and raster dimensions are checked against a 16-million-pixel limit before decoding. +- Null or corrupt detached-status snapshots return an empty history instead of preventing startup. +- All mock fixture files are parsed before a template database connection or collection deletion. Fixture paths and collection names are shared, and path handling supports spaces and encoded filesystem characters. + +### Frontend and maintainability + +- Cookie parsing preserves embedded equals signs and handles malformed encoding without throwing. +- OAuth errors are no longer URL-decoded twice. +- Project prefetching has a 50-entry cache, one-minute lifetime, eight-request concurrency limit, and request timeout. +- Sign-in, mobile filters, and project previews use the existing shared scroll-lock hook. The last lock restores the previous overflow style. +- Frontend tests use a repository-owned launcher rather than rewriting Vitest's installed executable. An isolated `npm ci` and complete test run verified the replacement. +- Browser-import integration checks have individual HTTP deadlines and a longer cold-compilation allowance; their test names now identify the source and import correctly. +- Fixed stale framework badges, repository structure, contributor links, preview instructions, and documented verification commands and signing-secret behavior. + +### CI + +- PR test runs retain ownership instead of trusting a queued push run that might itself defer to the PR. This removes a double-skip race. +- Test-orchestration changes select all components while avoiding unnecessary native packaging. +- CI runs frontend type checks plus dependency-free workflow and fixture-script regression tests. +- Removed obsolete deduplication inputs from the test and Lighthouse workflows. + +## Verification + +Final local verification on the audit branch: + +| Check | Result | +| --- | --- | +| Backend `./gradlew test statusServiceJar` | Passed; 598 tests passed, one opt-in live contract skipped; detached status JAR built | +| Launcher `./gradlew test` | Passed; 248 tests passed, seven opt-in live/browser/performance/snapshot tests skipped | +| Frontend `npm test` | 341 tests passed across 68 files | +| Frontend `npm run check` | Zero errors, zero warnings; 106 informational hints remain | +| Frontend `npm run build` | Passed; existing chunk-size and mixed static/dynamic import notices remain | +| Workflow and fixture tests | Six tests passed | +| Frontend and mock-db dependency installation/audit | Clean installs; npm reported zero known vulnerabilities at audit time | +| Shell and fixture scripts | Syntax checks passed | +| Patch whitespace | `git diff --check` passed | + +GitHub's [Tests run for the final code commit](https://github.com/Modtale/modtale/actions/runs/34079703140) also passed. Its launcher job was correctly skipped because that commit changed backend/frontend code; the complete launcher suite was separately run locally. + +The initial frontend suite intermittently exceeded its five-second browser-import deadline while Java tests and type checking ran concurrently. It passed independently; the integration-specific deadline and bounded HTTP requests address that observed load sensitivity without raising unit-test timeouts. + +## Operational boundaries and follow-ups + +- Configure the same private `PRE_AUTH_SECRET` on every backend replica. The generated local fallback changes after a restart and is not shared across instances. Existing deployment configuration already supplies the secret. +- Rate limiting still relies on upstream forwarding-header sanitization. The application trusts `CF-Connecting-IP`/`X-Forwarded-For`, and forwarded-header processing is enabled. Verify that deployment ingress prevents arbitrary clients from supplying trusted address headers; this audit did not change live ingress or proxy configuration. +- Social-preview rendering now deliberately omits arbitrary external image hosts. Use the configured storage origin for project assets that should appear in social previews. +- Production OAuth flows, real Mongo/R2 operations, the proprietary Warden service, native installer packaging on Windows/macOS, and opt-in live-provider/browser/performance tests were not exercised. No production database refresh or manual production deployment was performed. +- Informational TypeScript hints and large frontend chunks remain candidates for a separate measured cleanup/performance pass. No unsupported claim of complete CVE coverage is made for the Java dependency graph. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5912cdd..97287e73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,12 +48,23 @@ Mock sign-in accounts use the password `password`. See [mock-db/README.md](mock- ### PR Preview Deployments -Every pull request to `develop` gets a Cloud Run preview backed by a preview-only Mongo database seeded from the sanitized mock template database. PR previews run untrusted PR code against preview-only infrastructure with no production/dev secrets, no real object-storage credentials, no OAuth credentials, no Warden credentials, and no production domains. +A repository owner, member, or collaborator can request a Cloud Run preview for a fork pull request with a `/deploy-preview` comment. Same-repository pull requests skip this preview infrastructure. Each preview uses a preview-only Mongo database seeded from the sanitized mock template database. PR previews run untrusted PR code against preview-only infrastructure with no production/dev secrets, no real object-storage credentials, no OAuth credentials, no Warden credentials, and no production domains. Preview services are named per PR and are deleted when the PR closes. The preview workflow must keep using the trusted base branch workflow/build config for deployment orchestration; PR code can affect the application being built, but it must not receive GitHub or production cloud credentials. The preview project must use dedicated no-production-access service accounts for both Cloud Build (`GCP_PREVIEW_BUILD_SERVICE_ACCOUNT`) and Cloud Run (`GCP_PREVIEW_RUNTIME_SERVICE_ACCOUNT`). The preview Mongo secret must point only at a preview/mock Mongo environment, never dev or prod. +### Run checks before submitting + +```bash +(cd backend && ./gradlew test) +(cd frontend && npm ci && npm run check && npm test && npm run build) +(cd launcher && ./gradlew test) +node --test .github/tests/*.test.mjs mock-db/tests/*.test.mjs +``` + +Launcher tests do not require building the platform installers. `./gradlew build` also packages the launcher and requires the host's packaging tools. + ## 3. Git Workflow & Branching We use a feature-branch workflow rooted in `develop`. diff --git a/README.md b/README.md index f50dce85..ed9036a4 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@

License: AGPL v3 - Astro + Astro React - Spring Boot + Spring Boot Java 21 MongoDB Lines of Code @@ -43,7 +43,10 @@ modtale/ │ ├── astro.config.mjs # Astro build & integration settings │ └── package.json # Node dependencies │ -└── Warden/ # Security Scanner Service (Closed Source) +├── launcher/ # JavaFX desktop client and native packaging +└── mock-db/ # Sanitized fixture generation and import tools + +Warden is a separate, closed-source security scanner service. ``` @@ -107,13 +110,18 @@ The Spring Boot backend relies on environment variables. You can set these in yo | Variable | Description | Example | | --- | --- | --- | | `MONGODB_URI` | Connection String | `mongodb://localhost:27017/modtale` | +| `R2_BUCKET_NAME` | Storage Bucket | `modtale-dev` | | `R2_ACCESS_KEY` | Storage Access Key | `your_dev_access_key` | | `R2_SECRET_KEY` | Storage Secret Key | `your_dev_secret_key` | | `R2_ENDPOINT` | Storage Endpoint URL | `https://.r2.cloudflarestorage.com` | +| `R2_PUBLIC_DOMAIN` | Optional public storage URL | `https://cdn.example.test` | | `WARDEN_ENABLED` | **Must be false locally** | `false` | +| `PRE_AUTH_SECRET` | Shared random MFA pre-auth signing secret; required for consistent token validation across multiple instances | Set through your deployment secret manager | | `STATUS_DISCORD_WEBHOOK_URL` | Optional Discord webhook for the continually updated status mirror | `https://discord.com/api/webhooks/...` | | `STATUS_CHECKER_ENABLED` | Opt into the legacy embedded backend checker | `false` | +If `PRE_AUTH_SECRET` is unset, the backend generates a random secret for that process. In-flight MFA sign-ins will need to restart after a backend restart. Use the same configured secret on every instance of a deployment. + Detached status service variables: | Variable | Description | Default | @@ -176,6 +184,26 @@ npm run dev *The web client is now accessible at `http://localhost:5173`!* +### 5. Native Launcher + +The `launcher/` project is a native Java 21 JavaFX client for installing Modtale projects into a local Hytale mods folder. It does not use Electron. + +```bash +cd launcher +./gradlew run +``` + +The launcher lets users search the Modtale catalog, install the latest compatible version, include required or optional dependencies, check installed projects for updates, apply updates, and point the app at the correct Hytale mods folder. + +Self-contained native packages are built by default: + +```bash +cd launcher +./gradlew build +``` + +Package outputs land in `launcher/build/distributions/`. Windows builds produce an `.exe` installer, macOS builds produce a `.dmg`, and Linux builds produce an `.AppImage`. Each package embeds the required Java runtime, so end users do not need Java installed. Build on each target OS, or use a CI matrix, to produce all three platform artifacts. + --- ## License @@ -193,7 +221,7 @@ Modtale is free software: you can redistribute it and/or modify it under the ter ### Contributing -We welcome contributions from the community! Whether it's a bug fix, a new feature, or documentation improvements, please refer to our [CONTRIBUTING.md]() for coding guidelines and pull request instructions. +We welcome contributions from the community! Whether it's a bug fix, a new feature, or documentation improvements, please refer to our [CONTRIBUTING.md](CONTRIBUTING.md) for coding guidelines and pull request instructions. --- diff --git a/backend/cloudbuild.yml b/backend/cloudbuild.yml index 68ab3637..a7881989 100644 --- a/backend/cloudbuild.yml +++ b/backend/cloudbuild.yml @@ -22,4 +22,4 @@ substitutions: _TAG: latest options: - defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET \ No newline at end of file + defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET diff --git a/backend/src/main/java/net/modtale/config/auth/ApiKeyAuthFilter.java b/backend/src/main/java/net/modtale/config/auth/ApiKeyAuthFilter.java index 4d36063d..406722e6 100644 --- a/backend/src/main/java/net/modtale/config/auth/ApiKeyAuthFilter.java +++ b/backend/src/main/java/net/modtale/config/auth/ApiKeyAuthFilter.java @@ -44,37 +44,34 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String path = request.getRequestURI(); String apiKeyHeader = request.getHeader("X-MODTALE-KEY"); - if (path.startsWith("/api/v1") && apiKeyHeader != null && !apiKeyHeader.isBlank()) { - - ApiKey apiKey = apiKeyService.resolveKey(apiKeyHeader); - - if (apiKey != null) { - User user = apiKeyService.getUserFromKey(apiKey); - if (user != null) { - List authorities = new ArrayList<>(); - authorities.add(new SimpleGrantedAuthority("ROLE_API")); + if ((path.equals("/api/v1") || path.startsWith("/api/v1/")) && apiKeyHeader != null) { + ApiKey apiKey = apiKeyHeader.isBlank() ? null : apiKeyService.resolveKey(apiKeyHeader); + User user = apiKey == null ? null : apiKeyService.getUserFromKey(apiKey); + if (user == null) { + // A supplied credential must never fall back to an existing browser session. + SecurityContextHolder.clearContext(); + exceptionResolver.resolveException(request, response, null, new UnauthorizedException("Invalid API Key.")); + return; + } - Map> perms = apiKey.getContextPermissions(); - if (perms != null) { - for (Map.Entry> entry : perms.entrySet()) { - String contextId = entry.getKey(); - for (ApiKey.ApiPermission permission : entry.getValue()) { - authorities.add(new SimpleGrantedAuthority("SCOPE_" + contextId + "_" + permission.name())); - } - } + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_API")); + Map> perms = apiKey.getContextPermissions(); + if (perms != null) { + for (Map.Entry> entry : perms.entrySet()) { + String contextId = entry.getKey(); + for (ApiKey.ApiPermission permission : entry.getValue()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + contextId + "_" + permission.name())); } - - UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( - user, - null, - authorities - ); - SecurityContextHolder.getContext().setAuthentication(auth); } - } else { - exceptionResolver.resolveException(request, response, null, new UnauthorizedException("Invalid API Key.")); - return; } + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + user, null, authorities + ); + var context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); } filterChain.doFilter(request, response); diff --git a/backend/src/main/java/net/modtale/config/auth/HytaleAuthorizationRequestResolver.java b/backend/src/main/java/net/modtale/config/auth/HytaleAuthorizationRequestResolver.java index 30f5fabd..640f9bdf 100644 --- a/backend/src/main/java/net/modtale/config/auth/HytaleAuthorizationRequestResolver.java +++ b/backend/src/main/java/net/modtale/config/auth/HytaleAuthorizationRequestResolver.java @@ -3,14 +3,12 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; -import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; /** - * Hytale requires S256 PKCE even for confidential clients. Spring Security only - * enables PKCE automatically for public clients, so apply it explicitly to this - * registration while leaving the other providers' requests unchanged. + * Applies Hytale's required S256 PKCE to its confidential OAuth client. */ public class HytaleAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { @@ -31,8 +29,14 @@ public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { } @Override - public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { - return addHytalePkce(delegate.resolve(request, clientRegistrationId), clientRegistrationId); + public OAuth2AuthorizationRequest resolve( + HttpServletRequest request, + String clientRegistrationId + ) { + return addHytalePkce( + delegate.resolve(request, clientRegistrationId), + clientRegistrationId + ); } private OAuth2AuthorizationRequest addHytalePkce( @@ -43,7 +47,8 @@ private OAuth2AuthorizationRequest addHytalePkce( return authorizationRequest; } - OAuth2AuthorizationRequest.Builder builder = OAuth2AuthorizationRequest.from(authorizationRequest); + OAuth2AuthorizationRequest.Builder builder = + OAuth2AuthorizationRequest.from(authorizationRequest); OAuth2AuthorizationRequestCustomizers.withPkce().accept(builder); return builder.build(); } diff --git a/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java b/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java index 021193f5..71d02bdd 100644 --- a/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java +++ b/backend/src/main/java/net/modtale/config/core/PublicApiEndpointMatcher.java @@ -17,6 +17,7 @@ public final class PublicApiEndpointMatcher { private static final List PUBLIC_READ_EXACT_PATHS = List.of( "/api/v1/tags", "/api/v1/status", + "/api/v1/auth/csrf", "/api/v1/analytics/platform/stats", "/api/v1/projects" ); @@ -30,6 +31,7 @@ public final class PublicApiEndpointMatcher { "/api/v1/og/", "/api/v1/download/", "/api/v1/download-bundle/", + "/api/v1/lists/", "/api/v1/meta/", "/api/v1/version/", "/api/v1/wiki/" @@ -46,7 +48,8 @@ public static boolean isPublicOperation(String path, String method) { String normalizedPath = path.trim(); String normalizedMethod = method.toUpperCase(Locale.ROOT); - if (normalizedMethod.equals("POST") && normalizedPath.equals("/api/v1/users/batch")) { + if (normalizedMethod.equals("POST") && (normalizedPath.equals("/api/v1/users/batch") + || normalizedPath.equals("/api/v1/projects/external/identify"))) { return true; } diff --git a/backend/src/main/java/net/modtale/config/db/MongoConfig.java b/backend/src/main/java/net/modtale/config/db/MongoConfig.java index 6ba9d1ae..a839e36e 100644 --- a/backend/src/main/java/net/modtale/config/db/MongoConfig.java +++ b/backend/src/main/java/net/modtale/config/db/MongoConfig.java @@ -24,7 +24,7 @@ public OAuthProvider convert(String source) { return null; } try { - return OAuthProvider.valueOf(source.toUpperCase()); + return OAuthProvider.valueOf(source.toUpperCase(java.util.Locale.ROOT)); } catch (IllegalArgumentException e) { try { return OAuthProvider.valueOf(source); diff --git a/backend/src/main/java/net/modtale/config/properties/AppCurseForgeProperties.java b/backend/src/main/java/net/modtale/config/properties/AppCurseForgeProperties.java deleted file mode 100644 index 6a8c9233..00000000 --- a/backend/src/main/java/net/modtale/config/properties/AppCurseForgeProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -package net.modtale.config.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "app.curseforge") -public record AppCurseForgeProperties( - String apiKey, - long hytaleGameId -) { - public boolean isConfigured() { - return apiKey != null && !apiKey.isBlank() && hytaleGameId > 0; - } -} diff --git a/backend/src/main/java/net/modtale/config/properties/AppSecurityProperties.java b/backend/src/main/java/net/modtale/config/properties/AppSecurityProperties.java index 7602c0b3..136dc59d 100644 --- a/backend/src/main/java/net/modtale/config/properties/AppSecurityProperties.java +++ b/backend/src/main/java/net/modtale/config/properties/AppSecurityProperties.java @@ -5,7 +5,7 @@ @ConfigurationProperties(prefix = "app.security") public record AppSecurityProperties( - @DefaultValue("default-secret-change-in-prod") String preAuthSecret, + @DefaultValue("") String preAuthSecret, @DefaultValue("600") long preAuthExpirySeconds, @DefaultValue("120") long baselineConfidenceDecayDays, @DefaultValue("2") long autoApproveDelayMinutesMin, @@ -15,4 +15,9 @@ public record AppSecurityProperties( @DefaultValue("25") long scanTimeoutMinutes, @DefaultValue("2") int scanMaxRetries ) { + public AppSecurityProperties { + if (preAuthSecret == null || preAuthSecret.isBlank()) { + preAuthSecret = java.util.UUID.randomUUID().toString(); + } + } } diff --git a/backend/src/main/java/net/modtale/config/security/ApiCorsPolicy.java b/backend/src/main/java/net/modtale/config/security/ApiCorsPolicy.java new file mode 100644 index 00000000..10032d4b --- /dev/null +++ b/backend/src/main/java/net/modtale/config/security/ApiCorsPolicy.java @@ -0,0 +1,61 @@ +package net.modtale.config.security; + +import java.util.List; +import java.util.Set; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +/** Keeps browser sessions private while allowing third-party API-key clients. */ +final class ApiCorsPolicy { + private static final List RESTRICTED_PATHS = List.of( + "/api/v1/admin/**", + "/api/v1/auth/csrf", + "/api/v1/user/api-keys/**", + "/api/v1/user/analytics", + "/api/v1/projects/*/publish", + "/api/v1/analytics/view/**", + "/api/v1/views/project/**", + "/api/v1/user/repos/**", + "/api/v1/orgs/*/repos/**", + "/api/v1/user/connections/**", + "/api/v1/orgs/*/connections/**" + ); + + private ApiCorsPolicy() { + } + + static CorsConfigurationSource create(Set frontendOrigins) { + CorsConfiguration restricted = new CorsConfiguration(); + restricted.setAllowedOriginPatterns(List.copyOf(frontendOrigins)); + restricted.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")); + restricted.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type", "X-XSRF-TOKEN")); + restricted.setAllowCredentials(true); + restricted.setMaxAge(3600L); + + CorsConfiguration frontend = new CorsConfiguration(restricted); + frontend.addAllowedHeader("X-Modtale-Key"); + frontend.setExposedHeaders(List.of("X-XSRF-TOKEN", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Tier")); + + CorsConfiguration publicApi = new CorsConfiguration(frontend); + publicApi.setAllowedOriginPatterns(List.of()); + publicApi.setAllowedOrigins(List.of("*")); + publicApi.setAllowCredentials(false); + publicApi.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type", "X-Modtale-Key")); + publicApi.setExposedHeaders(List.of("X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Tier")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + for (String path : RESTRICTED_PATHS) { + source.registerCorsConfiguration(path, restricted); + } + source.registerCorsConfiguration("/**", publicApi); + + return request -> { + // Never reflect arbitrary origins alongside Access-Control-Allow-Credentials. + String origin = request.getHeader("Origin"); + CorsConfiguration configuration = source.getCorsConfiguration(request); + return configuration == publicApi && origin != null && frontend.checkOrigin(origin) != null + ? frontend : configuration; + }; + } +} diff --git a/backend/src/main/java/net/modtale/config/security/ApiCsrfRequestMatcher.java b/backend/src/main/java/net/modtale/config/security/ApiCsrfRequestMatcher.java new file mode 100644 index 00000000..8ba5a8a4 --- /dev/null +++ b/backend/src/main/java/net/modtale/config/security/ApiCsrfRequestMatcher.java @@ -0,0 +1,36 @@ +package net.modtale.config.security; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Set; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; + +/** Exempts only operations that do not rely on an existing browser session. */ +final class ApiCsrfRequestMatcher implements RequestMatcher { + private static final Set PUBLIC_POST_PATHS = Set.of( + "/api/v1/auth/register", + "/api/v1/auth/verify", + "/api/v1/auth/signin", + "/api/v1/auth/mfa/validate-login", + "/api/v1/auth/launcher/exchange", + "/api/v1/auth/forgot-password", + "/api/v1/auth/reset-password", + "/api/v1/users/batch", + "/api/v1/projects/external/identify" + ); + + @Override + public boolean matches(HttpServletRequest request) { + if (!CsrfFilter.DEFAULT_CSRF_MATCHER.matches(request)) { + return false; + } + String path = request.getRequestURI(); + if ("POST".equals(request.getMethod()) && PUBLIC_POST_PATHS.contains(path)) { + return false; + } + String key = request.getHeader("X-MODTALE-KEY"); + // ApiKeyAuthFilter rejects invalid credentials instead of using the session. + return !((path.equals("/api/v1") || path.startsWith("/api/v1/")) + && key != null && !key.isBlank()); + } +} diff --git a/backend/src/main/java/net/modtale/config/security/RateLimitFilter.java b/backend/src/main/java/net/modtale/config/security/RateLimitFilter.java index 5c5323f6..3ac929da 100644 --- a/backend/src/main/java/net/modtale/config/security/RateLimitFilter.java +++ b/backend/src/main/java/net/modtale/config/security/RateLimitFilter.java @@ -60,7 +60,7 @@ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, return; } - boolean isWrite = WRITE_METHODS.contains(req.getMethod().toUpperCase()); + boolean isWrite = WRITE_METHODS.contains(req.getMethod().toUpperCase(java.util.Locale.ROOT)); String clientIp = getClientIp(req); String userAgent = req.getHeader("User-Agent"); String apiKeyHeader = req.getHeader("X-MODTALE-KEY"); @@ -156,7 +156,7 @@ private boolean isFrontendRequest(HttpServletRequest req) { private boolean isBlockedAgent(String ua) { if (ua == null || ua.isBlank()) return true; - String lowerUA = ua.toLowerCase(); + String lowerUA = ua.toLowerCase(java.util.Locale.ROOT); return BLOCKED_AGENTS.stream().anyMatch(lowerUA::contains); } diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 498743f6..91080cd6 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -8,16 +8,15 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashSet; -import java.util.List; import java.util.Set; +import net.modtale.controller.auth.AuthController; import net.modtale.config.auth.ApiKeyAuthFilter; import net.modtale.config.properties.AppFrontendProperties; import net.modtale.exception.ErrorMessageUtils; import net.modtale.model.user.User; import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; import net.modtale.service.auth.LocalUserDetailsService; import net.modtale.service.auth.OAuth2LoginService; import net.modtale.service.auth.OidcLoginService; @@ -52,9 +51,7 @@ import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; -import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration public class SecurityConfig { @@ -70,6 +67,7 @@ public class SecurityConfig { private final PasswordEncoder passwordEncoder; private final AccountService accountService; private final AuthenticationService authenticationService; + private final LauncherAuthService launcherAuthService; private final AppFrontendProperties frontendProperties; public SecurityConfig( @@ -82,6 +80,7 @@ public SecurityConfig( PasswordEncoder passwordEncoder, AccountService accountService, AuthenticationService authenticationService, + LauncherAuthService launcherAuthService, AppFrontendProperties frontendProperties ) { this.apiKeyAuthFilter = apiKeyAuthFilter; @@ -93,6 +92,7 @@ public SecurityConfig( this.passwordEncoder = passwordEncoder; this.accountService = accountService; this.authenticationService = authenticationService; + this.launcherAuthService = launcherAuthService; this.frontendProperties = frontendProperties; } @@ -124,8 +124,9 @@ private boolean isPreviewEnvironment() { } private boolean isLocalhost() { - String cleanUrl = getCleanFrontendUrl(); - return cleanUrl != null && (cleanUrl.contains("localhost") || cleanUrl.contains("127.0.0.1")); + String host = safeHostFromUrl(getCleanFrontendUrl()); + return "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) + || "[::1]".equals(host); } private Set getAllowedFrontendOriginPatterns() { @@ -167,7 +168,7 @@ private Set getAllowedFrontendOriginPatterns() { private boolean isAllowedFrontendHost(String host) { if (host == null || host.isBlank()) return false; - String normalized = host.toLowerCase(); + String normalized = host.toLowerCase(java.util.Locale.ROOT); for (String originPattern : getAllowedFrontendOriginPatterns()) { String allowedHost = safeHostFromUrl(originPattern); if (allowedHost != null && normalized.equalsIgnoreCase(allowedHost)) { @@ -247,14 +248,7 @@ public SecurityFilterChain securityFilterChain( .csrfTokenRepository(tokenRepository) .csrfTokenRequestHandler(requestHandler); - csrf.ignoringRequestMatchers("/api/v1/user/api-keys/**", "/api/v1/auth/**"); - csrf.ignoringRequestMatchers("/api/v1/users/batch"); - csrf.ignoringRequestMatchers(request -> request.getHeader("X-MODTALE-KEY") != null); - - if (isPreviewEnvironment()) { - logger.warn("SECURITY WARNING: Disabling CSRF protection for Staging/Preview environment to allow cross-site requests."); - csrf.ignoringRequestMatchers("/**"); - } + csrf.requireCsrfProtectionMatcher(new ApiCsrfRequestMatcher()); }) .addFilterBefore(rateLimitFilter, OAuth2LoginAuthenticationFilter.class) .addFilterBefore(apiKeyAuthFilter, OAuth2LoginAuthenticationFilter.class) @@ -264,10 +258,7 @@ public SecurityFilterChain securityFilterChain( .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .sessionFixation().migrateSession() ) - .formLogin(form -> form - .loginProcessingUrl("/api/v1/auth/login-legacy") - .permitAll() - ) + .formLogin(form -> form.disable()) .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(authorization -> authorization .authorizationRequestResolver(authorizationRequestResolver) @@ -285,11 +276,15 @@ public SecurityFilterChain securityFilterChain( .requestMatchers("/oauth2/**", "/login**", "/error", "/logout").permitAll() .requestMatchers("/api/v1/docs/**").permitAll() .requestMatchers( + "/api/v1/auth/csrf", "/api/v1/auth/register", "/api/v1/auth/verify", "/api/v1/auth/signin", "/api/v1/auth/logout", + "/api/v1/auth/oauth/**", + "/api/v1/auth/launcher/oauth/**", "/api/v1/auth/mfa/validate-login", + "/api/v1/auth/launcher/exchange", "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password" ).permitAll() @@ -306,14 +301,16 @@ public SecurityFilterChain securityFilterChain( "/api/v1/og/**", "/api/v1/download/**", "/api/v1/download-bundle/**", + "/api/v1/lists/**", "/api/v1/meta/**", "/api/v1/status", "/api/v1/version/**", "/api/v1/analytics/platform/stats", "/api/v1/wiki/**" ).permitAll() - .requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll() + .requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**", "/api/v1/lists/**").permitAll() .requestMatchers(HttpMethod.POST, + "/api/v1/projects/external/identify", "/api/v1/users/batch" ).permitAll() .requestMatchers("/api/v1/analytics/platform/full").access((authentication, context) -> { @@ -337,10 +334,6 @@ public SecurityFilterChain securityFilterChain( boolean isValidOrigin = isAllowedFrontendHost(originHost); boolean isValidReferer = isAllowedFrontendHost(refererHost); - if (isPreviewEnvironment() && (origin != null && origin.contains(".run.app"))) { - return new AuthorizationDecision(true); - } - return new AuthorizationDecision(isValidOrigin || isValidReferer); }) .requestMatchers( @@ -400,55 +393,7 @@ public SecurityFilterChain securityFilterChain( @Bean public CorsConfigurationSource corsConfigurationSource() { - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - CorsConfiguration restrictedConfig = new CorsConfiguration(); - List restrictedOrigins = new ArrayList<>(); - - boolean isPreview = isPreviewEnvironment(); - Set frontendOrigins = getAllowedFrontendOriginPatterns(); - String cleanUrl = getCleanFrontendUrl(); - - if (isPreview) { - restrictedOrigins.add("https://*.run.app"); - if (cleanUrl != null && cleanUrl.contains("dev.modtale.net")) { - restrictedOrigins.add(cleanUrl); - } - } else { - restrictedOrigins.addAll(frontendOrigins); - } - - restrictedConfig.setAllowedOriginPatterns(restrictedOrigins); - restrictedConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")); - restrictedConfig.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type", "X-Xsrf-Token", "X-XSRF-TOKEN")); - restrictedConfig.setAllowCredentials(true); - restrictedConfig.setMaxAge(3600L); - - source.registerCorsConfiguration("/api/v1/admin/**", restrictedConfig); - source.registerCorsConfiguration("/api/v1/user/api-keys/**", restrictedConfig); - source.registerCorsConfiguration("/api/v1/user/analytics", restrictedConfig); - source.registerCorsConfiguration("/api/v1/projects/*/publish", restrictedConfig); - source.registerCorsConfiguration("/api/v1/analytics/view/**", restrictedConfig); - source.registerCorsConfiguration("/api/v1/views/project/**", restrictedConfig); - source.registerCorsConfiguration("/api/v1/user/repos/**", restrictedConfig); - source.registerCorsConfiguration("/api/v1/orgs/*/repos/**", restrictedConfig); - source.registerCorsConfiguration("/api/v1/user/connections/**", restrictedConfig); - source.registerCorsConfiguration("/api/v1/orgs/*/connections/**", restrictedConfig); - - CorsConfiguration publicConfig = new CorsConfiguration(); - List publicOrigins = new ArrayList<>(); - publicOrigins.add("*"); - publicOrigins.addAll(frontendOrigins); - if (isPreview) { - publicOrigins.add("https://*.run.app"); - } - publicConfig.setAllowedOriginPatterns(publicOrigins); - publicConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH")); - publicConfig.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type", "X-Xsrf-Token", "X-XSRF-TOKEN", "X-Modtale-Key")); - publicConfig.setExposedHeaders(Arrays.asList("X-Xsrf-Token", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Tier")); - publicConfig.setAllowCredentials(true); - publicConfig.setMaxAge(3600L); - source.registerCorsConfiguration("/**", publicConfig); - return source; + return ApiCorsPolicy.create(getAllowedFrontendOriginPatterns()); } @Bean @@ -492,9 +437,44 @@ public AuthenticationSuccessHandler oauthSuccessHandler() { user = accountService.saveUser(user); } boolean isLinking = Boolean.TRUE.equals(oauthUser.getAttribute("is_linking")); + LauncherOAuthRequest launcherOAuthRequest = consumeLauncherOAuthRequest(request); + if (launcherOAuthRequest != null && !isLinking) { + if (user == null) { + response.sendRedirect(launcherCallbackUrl( + launcherOAuthRequest.redirectUri(), + "oauth_user_not_found", + launcherOAuthRequest.state(), + false + )); + return; + } + if (!user.isMfaEnabled()) { + SecurityContextRepository repository = securityContextRepository(); + repository.saveContext(SecurityContextHolder.getContext(), request, response); + try { + LauncherAuthService.LauncherAuthGrant grant = launcherAuthService.issueCode( + user, + launcherOAuthRequest.redirectUri(), + launcherOAuthRequest.state() + ); + response.sendRedirect(launcherCallbackUrl(grant.redirectUri(), grant.code(), grant.state(), true)); + } catch (RuntimeException ex) { + response.sendRedirect(launcherCallbackUrl( + launcherOAuthRequest.redirectUri(), + ex.getMessage(), + launcherOAuthRequest.state(), + false + )); + } + return; + } + } if (user != null && user.isMfaEnabled() && !isLinking) { String preAuthToken = authenticationService.generatePreAuthToken(user.getId()); + String postLoginRedirect = launcherOAuthRequest == null + ? consumePostOAuthRedirect(request, "/dashboard/profile") + : launcherAuthFrontendPath(launcherOAuthRequest); SecurityContextHolder.clearContext(); @@ -506,14 +486,16 @@ public AuthenticationSuccessHandler oauthSuccessHandler() { session.invalidate(); } - String cleanUrl = getCleanFrontendUrl(); - response.sendRedirect((cleanUrl != null ? cleanUrl : "") + "/mfa?token=" + preAuthToken); + String mfaPath = "/mfa?token=" + preAuthToken; + if (!"/dashboard/profile".equals(postLoginRedirect)) { + mfaPath += "&redirect=" + URLEncoder.encode(postLoginRedirect, StandardCharsets.UTF_8); + } + response.sendRedirect(frontendUrl(mfaPath)); } else { SecurityContextRepository repository = securityContextRepository(); repository.saveContext(SecurityContextHolder.getContext(), request, response); - String cleanUrl = getCleanFrontendUrl(); - response.sendRedirect((cleanUrl != null ? cleanUrl : "") + "/dashboard/profile"); + response.sendRedirect(frontendUrl(consumePostOAuthRedirect(request, "/dashboard/profile"))); } }; } @@ -521,12 +503,101 @@ public AuthenticationSuccessHandler oauthSuccessHandler() { @Bean public AuthenticationFailureHandler oauthFailureHandler() { return (request, response, exception) -> { + LauncherOAuthRequest launcherOAuthRequest = consumeLauncherOAuthRequest(request); + if (launcherOAuthRequest != null) { + response.sendRedirect(launcherCallbackUrl( + launcherOAuthRequest.redirectUri(), + exception.getMessage(), + launcherOAuthRequest.state(), + false + )); + return; + } String errorParam = URLEncoder.encode(exception.getMessage(), StandardCharsets.UTF_8); - String cleanUrl = getCleanFrontendUrl(); - response.sendRedirect((cleanUrl != null ? cleanUrl : "") + "/?oauth_error=" + errorParam); + String redirectPath = consumePostOAuthRedirect(request, "/"); + String separator = redirectPath.contains("?") ? "&" : "?"; + response.sendRedirect(frontendUrl(redirectPath + separator + "oauth_error=" + errorParam)); }; } + private String frontendUrl(String path) { + String cleanUrl = getCleanFrontendUrl(); + return (cleanUrl != null ? cleanUrl : "") + safeInternalRedirect(path, "/"); + } + + private LauncherOAuthRequest consumeLauncherOAuthRequest(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + + Object redirectUri = session.getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE); + Object state = session.getAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE); + session.removeAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE); + session.removeAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE); + + if (redirectUri instanceof String redirect && !redirect.isBlank()) { + return new LauncherOAuthRequest(redirect, state instanceof String value ? value : ""); + } + return null; + } + + private String launcherAuthFrontendPath(LauncherOAuthRequest request) { + return "/launcher/auth?redirect_uri=" + URLEncoder.encode(request.redirectUri(), StandardCharsets.UTF_8) + + (request.state().isBlank() + ? "" + : "&state=" + URLEncoder.encode(request.state(), StandardCharsets.UTF_8)); + } + + private String launcherCallbackUrl(String redirectUri, String value, String state, boolean success) { + String key = success ? "code" : "error"; + int fragmentStart = redirectUri.indexOf('#'); + String base = fragmentStart >= 0 ? redirectUri.substring(0, fragmentStart) : redirectUri; + String fragment = fragmentStart >= 0 ? redirectUri.substring(fragmentStart) : ""; + + StringBuilder target = new StringBuilder(base); + if (base.contains("?")) { + if (!base.endsWith("?") && !base.endsWith("&")) { + target.append('&'); + } + } else { + target.append('?'); + } + + target.append(key).append('=').append(URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8)); + if (state != null && !state.isBlank()) { + target.append("&state=").append(URLEncoder.encode(state, StandardCharsets.UTF_8)); + } + target.append(fragment); + return target.toString(); + } + + private String consumePostOAuthRedirect(HttpServletRequest request, String fallback) { + HttpSession session = request.getSession(false); + if (session == null) { + return fallback; + } + + Object redirect = session.getAttribute(AuthController.POST_OAUTH_REDIRECT_ATTRIBUTE); + session.removeAttribute(AuthController.POST_OAUTH_REDIRECT_ATTRIBUTE); + if (redirect instanceof String redirectPath) { + return safeInternalRedirect(redirectPath, fallback); + } + return fallback; + } + + private String safeInternalRedirect(String redirect, String fallback) { + if (redirect == null || redirect.isBlank()) { + return fallback; + } + + String trimmed = redirect.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return fallback; + } + return trimmed; + } + private URI safeUri(String rawUri, String description) { if (rawUri == null || rawUri.isBlank()) { return null; @@ -543,4 +614,7 @@ private String safeHostFromUrl(String rawUri) { URI uri = safeUri(rawUri, "request origin"); return uri != null ? uri.getHost() : null; } + + private record LauncherOAuthRequest(String redirectUri, String state) { + } } diff --git a/backend/src/main/java/net/modtale/controller/auth/AuthController.java b/backend/src/main/java/net/modtale/controller/auth/AuthController.java index 61d74063..98bc574a 100644 --- a/backend/src/main/java/net/modtale/controller/auth/AuthController.java +++ b/backend/src/main/java/net/modtale/controller/auth/AuthController.java @@ -3,19 +3,23 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +import java.io.IOException; import jakarta.validation.Valid; import java.time.Duration; -import java.util.Map; +import java.util.stream.Collectors; import net.modtale.exception.InvalidAuthenticationRequestException; import net.modtale.exception.UnauthorizedException; import net.modtale.model.dto.request.auth.ChangePasswordRequest; import net.modtale.model.dto.request.auth.ForgotPasswordRequest; +import net.modtale.model.dto.request.auth.LauncherAuthExchangeRequest; +import net.modtale.model.dto.request.auth.LauncherAuthIssueRequest; import net.modtale.model.dto.request.auth.MfaLoginRequest; import net.modtale.model.dto.request.auth.RegisterRequest; import net.modtale.model.dto.request.auth.ResetPasswordRequest; import net.modtale.model.dto.request.auth.SignInRequest; import net.modtale.model.dto.request.auth.UpdateCredentialsRequest; import net.modtale.model.dto.request.auth.VerifyMfaRequest; +import net.modtale.model.dto.response.auth.LauncherAuthIssueResponse; import net.modtale.model.dto.response.auth.MfaChallengeResponse; import net.modtale.model.dto.response.auth.MfaSetupResponse; import net.modtale.model.dto.response.auth.RegistrationResponse; @@ -25,6 +29,7 @@ import net.modtale.model.user.User; import net.modtale.service.auth.AuthenticationMutationService; import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; import net.modtale.service.auth.TwoFactorService; import net.modtale.service.security.access.AdminAuthorityUtils; import net.modtale.service.user.account.AccountService; @@ -43,10 +48,13 @@ @RequestMapping("/api/v1/auth") public class AuthController { + public static final String POST_OAUTH_REDIRECT_ATTRIBUTE = "MODTALE_POST_OAUTH_REDIRECT"; + private final AuthenticationService authenticationService; private final AuthenticationMutationService authenticationMutationService; private final AccountService accountService; private final TwoFactorService twoFactorService; + private final LauncherAuthService launcherAuthService; private final SecurityContextRepository securityContextRepository; public AuthController( @@ -54,12 +62,14 @@ public AuthController( AuthenticationMutationService authenticationMutationService, AccountService accountService, TwoFactorService twoFactorService, + LauncherAuthService launcherAuthService, SecurityContextRepository securityContextRepository ) { this.authenticationService = authenticationService; this.authenticationMutationService = authenticationMutationService; this.accountService = accountService; this.twoFactorService = twoFactorService; + this.launcherAuthService = launcherAuthService; this.securityContextRepository = securityContextRepository; } @@ -198,6 +208,80 @@ public ResponseEntity validateLoginMfa(@Valid @RequestBody MfaLo return ResponseEntity.ok(new StatusResponse("success")); } + @GetMapping("/oauth/{provider}") + public void beginOAuthLogin( + @PathVariable String provider, + @RequestParam(value = "redirect", required = false) String redirect, + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + if (!provider.matches("[A-Za-z0-9_-]+")) { + throw new InvalidAuthenticationRequestException("That OAuth provider is not valid."); + } + + String safeRedirect = safeInternalRedirect(redirect); + if (safeRedirect != null) { + request.getSession(true).setAttribute(POST_OAUTH_REDIRECT_ATTRIBUTE, safeRedirect); + } + + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @GetMapping("/launcher/oauth/{provider}") + public void beginLauncherOAuthLogin( + @PathVariable String provider, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam(value = "state", required = false) String state, + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + if (!provider.matches("[A-Za-z0-9_-]+")) { + throw new InvalidAuthenticationRequestException("That OAuth provider is not valid."); + } + + launcherAuthService.validateLoopbackRedirectUri(redirectUri); + HttpSession session = request.getSession(true); + session.setAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, redirectUri.trim()); + session.setAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE, state == null ? "" : state.trim()); + + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @PostMapping("/launcher/issue") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity issueLauncherAuthCode( + @Valid @RequestBody LauncherAuthIssueRequest requestPayload, + Authentication authentication + ) { + User user = accountService.requireCurrentUser(authentication, "authorizing the Modtale Launcher"); + LauncherAuthService.LauncherAuthGrant grant = launcherAuthService.issueCode( + user, + requestPayload.getRedirectUri(), + requestPayload.getState() + ); + return ResponseEntity.ok(new LauncherAuthIssueResponse( + grant.code(), + grant.redirectUri(), + grant.state(), + grant.expiresIn() + )); + } + + @PostMapping("/launcher/exchange") + public ResponseEntity exchangeLauncherAuthCode( + @Valid @RequestBody LauncherAuthExchangeRequest requestPayload, + HttpServletRequest request, + HttpServletResponse response + ) { + User user = launcherAuthService.consumeCode(requestPayload.getCode()); + if (user == null) { + throw new UnauthorizedException("That launcher authorization code is invalid or has expired. Please sign in again."); + } + + createSession(user, request, response); + return ResponseEntity.ok(new StatusResponse("success")); + } + private void createSession(User user, HttpServletRequest request, HttpServletResponse response) { HttpSession session = request.getSession(true); @@ -221,4 +305,16 @@ private void expireCookie(HttpServletResponse response, String name) { response.addHeader(HttpHeaders.SET_COOKIE, expiredCookie.toString()); } + private String safeInternalRedirect(String redirect) { + if (redirect == null || redirect.isBlank()) { + return null; + } + + String trimmed = redirect.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return null; + } + return trimmed; + } + } diff --git a/backend/src/main/java/net/modtale/controller/auth/CsrfController.java b/backend/src/main/java/net/modtale/controller/auth/CsrfController.java new file mode 100644 index 00000000..a4bb1917 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/auth/CsrfController.java @@ -0,0 +1,21 @@ +package net.modtale.controller.auth; + +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CsrfController { + @GetMapping("/api/v1/auth/csrf") + public ResponseEntity token(CsrfToken token) { + // Trusted cross-origin frontends cannot read the API host's cookie directly. + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .body(new TokenResponse(token.getToken())); + } + + public record TokenResponse(String token) { + } +} diff --git a/backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java b/backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java index f71686b1..80bdb6a8 100644 --- a/backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java +++ b/backend/src/main/java/net/modtale/controller/project/ExternalProjectController.java @@ -1,8 +1,16 @@ package net.modtale.controller.project; import net.modtale.model.dto.project.ExternalProjectReferenceDTO; +import net.modtale.model.dto.project.CurseForgeCatalogDTO; +import net.modtale.model.dto.project.ArtifactIdentityDTO; import net.modtale.model.project.ProjectDependency; +import net.modtale.exception.ResourceNotFoundException; +import net.modtale.service.project.version.CurseForgeApiClient; import net.modtale.service.project.version.ExternalProjectReferenceService; +import net.modtale.service.project.version.ArtifactIdentityService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -11,16 +19,22 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.concurrent.TimeUnit; - @RestController @RequestMapping("/api/v1") public class ExternalProjectController { private final ExternalProjectReferenceService externalProjectReferenceService; + private final CurseForgeApiClient curseForgeApiClient; + private final ArtifactIdentityService artifactIdentityService; - public ExternalProjectController(ExternalProjectReferenceService externalProjectReferenceService) { + public ExternalProjectController( + ExternalProjectReferenceService externalProjectReferenceService, + CurseForgeApiClient curseForgeApiClient, + ArtifactIdentityService artifactIdentityService + ) { this.externalProjectReferenceService = externalProjectReferenceService; + this.curseForgeApiClient = curseForgeApiClient; + this.artifactIdentityService = artifactIdentityService; } @GetMapping("/projects/external/resolve") @@ -30,7 +44,48 @@ public ResponseEntity resolveExternalProject( @RequestParam(required = false) ProjectDependency.Source source ) { return ResponseEntity.ok() - .cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES).cachePublic()) + .cacheControl(CacheControl.noStore()) .body(externalProjectReferenceService.resolve(url, source)); } + + @GetMapping("/projects/external/curseforge") + @PreAuthorize("@apiSecurity.hasAnyPerm('PROJECT_READ', authentication)") + public ResponseEntity browseCurseForge( + @RequestParam(required = false) String search, + @RequestParam(required = false) String gameVersion, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "downloads") String sort + ) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(CurseForgeCatalogDTO.Page.from(artifactIdentityService.removeModtaleAliases( + curseForgeApiClient.searchMods(search, gameVersion, page, size, sort)))); + } + + @PostMapping("/projects/external/identify") + @PreAuthorize("@apiSecurity.hasAnyPerm('PROJECT_READ', authentication)") + public ResponseEntity identifyArtifacts(@Valid @RequestBody ArtifactIdentityDTO.Request request) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(artifactIdentityService.identify(request)); + } + + @GetMapping("/projects/external/curseforge/{projectId}") + @PreAuthorize("@apiSecurity.hasAnyPerm('PROJECT_READ', authentication)") + public ResponseEntity getCurseForgeProject( + @org.springframework.web.bind.annotation.PathVariable long projectId + ) { + CurseForgeApiClient.CurseForgeProject project = curseForgeApiClient.getProject(projectId) + .orElseThrow(() -> new ResourceNotFoundException("CurseForge project was not found.")); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(CurseForgeCatalogDTO.Project.from(project)); + } + + @GetMapping("/projects/external/curseforge/{projectId}/files/{fileId}/download-url") + @PreAuthorize("@apiSecurity.hasAnyPerm('PROJECT_READ', authentication)") + public ResponseEntity getCurseForgeDownload( + @org.springframework.web.bind.annotation.PathVariable long projectId, + @org.springframework.web.bind.annotation.PathVariable long fileId + ) { + CurseForgeApiClient.CurseForgeDownload download = curseForgeApiClient.getDownload(projectId, fileId) + .orElseThrow(() -> new ResourceNotFoundException("This exact CurseForge file is unavailable.")); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(CurseForgeCatalogDTO.Download.from(download)); + } } diff --git a/backend/src/main/java/net/modtale/controller/project/VersionController.java b/backend/src/main/java/net/modtale/controller/project/VersionController.java index 128d8d67..348eeb8b 100644 --- a/backend/src/main/java/net/modtale/controller/project/VersionController.java +++ b/backend/src/main/java/net/modtale/controller/project/VersionController.java @@ -12,7 +12,6 @@ import net.modtale.model.dto.response.project.DownloadUrlResponse; import net.modtale.model.dto.response.project.VersionDependenciesView; import net.modtale.model.user.User; -import net.modtale.model.project.ModpackTarget; import net.modtale.service.project.version.VersionApplicationService; import net.modtale.service.project.version.VersionDownloadPayload; import net.modtale.service.user.account.AccountService; @@ -31,6 +30,7 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -39,6 +39,9 @@ @RequestMapping("/api/v1") public class VersionController { + private static final String CLIENT_HEADER = "X-Modtale-Client"; + private static final String LAUNCHER_CLIENT = "launcher"; + private final VersionApplicationService versionApplicationService; private final AccountService accountService; @@ -137,15 +140,15 @@ public ResponseEntity getDownloadUrl( @PathVariable String id, @PathVariable String version, @RequestParam(value = "gameVersion", required = false) String gameVersion, - @RequestParam(value = "target", defaultValue = "UNIVERSAL") ModpackTarget target, + @RequestHeader(value = CLIENT_HEADER, required = false) String client, Authentication authentication ) { return ResponseEntity.ok(versionApplicationService.createDownloadUrl( id, version, gameVersion, - target, - accountService.getCurrentUser(authentication) + accountService.getCurrentUser(authentication), + isLauncherClient(client) )); } @@ -161,11 +164,16 @@ public ResponseEntity downloadWithToken( request.getHeader("Referer"), request.getRemoteAddr(), request.getHeader("X-Forwarded-For"), - accountService.getCurrentUser(authentication) + accountService.getCurrentUser(authentication), + isLauncherClient(request.getHeader(CLIENT_HEADER)) ); return asDownloadResponse(payload); } + private boolean isLauncherClient(String client) { + return LAUNCHER_CLIENT.equalsIgnoreCase(client); + } + @GetMapping("/projects/{id}/versions/{version}/download-bundle-url") @PreAuthorize("@apiSecurity.hasProjectPerm(#id, 'PROJECT_READ', authentication)") public ResponseEntity getDownloadBundleUrl( diff --git a/backend/src/main/java/net/modtale/controller/project/WikiProxyController.java b/backend/src/main/java/net/modtale/controller/project/WikiProxyController.java index 00faf28f..06856fda 100644 --- a/backend/src/main/java/net/modtale/controller/project/WikiProxyController.java +++ b/backend/src/main/java/net/modtale/controller/project/WikiProxyController.java @@ -83,7 +83,6 @@ private ResponseEntity wikiResponse(String body, User currentUser) { @ExceptionHandler(UpstreamServiceException.class) public ResponseEntity handleWikiUpstream(UpstreamServiceException ex) { - return ResponseEntity.status(ex.getStatus()) - .body(ErrorMessageUtils.problemDetail(ex.getStatus(), ErrorMessageUtils.describe(ex, "Wiki upstream request failed."))); + return ErrorMessageUtils.response(ex.getStatus(), ex, "Wiki upstream request failed."); } } diff --git a/backend/src/main/java/net/modtale/controller/system/OgAssetPolicy.java b/backend/src/main/java/net/modtale/controller/system/OgAssetPolicy.java new file mode 100644 index 00000000..aa76c35e --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/system/OgAssetPolicy.java @@ -0,0 +1,49 @@ +package net.modtale.controller.system; + +import java.net.URI; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import net.modtale.config.properties.AppR2Properties; + +/** OG rendering only fetches assets from the site's own storage origins. */ +final class OgAssetPolicy { + private final Set origins = new HashSet<>(Set.of("https://modtale.net", "https://cdn.modtale.net")); + + OgAssetPolicy(AppR2Properties properties) { + String domain = properties.publicDomain(); + if (domain != null && !domain.isBlank()) { + URI uri = URI.create(domain); + if (origin(uri) != null) origins.add(origin(uri)); + } + } + + URI resolve(String value) { + if (value == null || value.isBlank()) return null; + try { + URI uri = URI.create(value); + if (uri.getRawUserInfo() != null || uri.getRawFragment() != null) return null; + if (uri.getScheme() == null && uri.getRawAuthority() == null) { + String path = uri.getPath(); + if (path == null || !path.equals(URI.create(path).normalize().getPath())) return null; + if (path.startsWith("/assets/")) return URI.create("https://modtale.net").resolve(uri); + if (path.startsWith("/api/v1/files/")) return URI.create("http://localhost:8080").resolve(uri); + return null; + } + String origin = origin(uri); + return origin != null && origins.contains(origin) ? uri : null; + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static String origin(URI uri) { + String scheme = uri.getScheme(); + if (scheme == null || uri.getHost() == null || uri.getRawUserInfo() != null + || !(scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http"))) return null; + int port = uri.getPort(); + boolean defaultPort = port == -1 || (scheme.equalsIgnoreCase("https") ? port == 443 : port == 80); + return scheme.toLowerCase(Locale.ROOT) + "://" + uri.getHost().toLowerCase(Locale.ROOT) + + (defaultPort ? "" : ":" + port); + } +} diff --git a/backend/src/main/java/net/modtale/controller/system/OgImageController.java b/backend/src/main/java/net/modtale/controller/system/OgImageController.java index a56c25e5..98faf0a6 100644 --- a/backend/src/main/java/net/modtale/controller/system/OgImageController.java +++ b/backend/src/main/java/net/modtale/controller/system/OgImageController.java @@ -12,7 +12,6 @@ import java.io.IOException; import java.net.HttpURLConnection; import java.net.URI; -import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -21,6 +20,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.imageio.ImageIO; +import net.modtale.config.properties.AppR2Properties; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectClassification; import net.modtale.service.project.query.ProjectService; @@ -51,6 +51,9 @@ public class OgImageController { private final Cache renderCache; private final Cache assetCache; private final SVGDocument logoDocument; + private final OgAssetPolicy assetPolicy; + private static final int MAX_ASSET_BYTES = 10 * 1024 * 1024; + private static final long MAX_RASTER_PIXELS = 16_000_000; private static final Color BRAND_ACCENT = new Color(59, 130, 246); private static final Color BRAND_DARK = new Color(11, 17, 32); @@ -100,7 +103,8 @@ public class OgImageController { """; - public OgImageController(ProjectService ProjectService) { + public OgImageController(ProjectService ProjectService, AppR2Properties r2Properties) { + this.assetPolicy = new OgAssetPolicy(r2Properties); this.ProjectService = ProjectService; this.renderCache = Caffeine.newBuilder() .maximumSize(5000) @@ -217,21 +221,24 @@ private BufferedImage getOrFetchImage(String url) { BufferedImage cached = assetCache.getIfPresent(url); if (cached != null) return cached; - String fetchUrl = url.startsWith("/") ? "http://localhost:8080" + url : url; - URL targetUrl = new URL(fetchUrl); - HttpURLConnection connection = (HttpURLConnection) targetUrl.openConnection(); + URI target = assetPolicy.resolve(url); + if (target == null) return null; + HttpURLConnection connection = (HttpURLConnection) target.toURL().openConnection(); connection.setConnectTimeout(1000); connection.setReadTimeout(1000); - connection.connect(); - - try (var is = connection.getInputStream()) { - String contentType = connection.getContentType(); - byte[] data = is.readAllBytes(); - BufferedImage img = decodeFetchedImage(data, contentType, fetchUrl); - if (img != null) { - assetCache.put(url, img); + // Redirects must not turn an approved storage origin into an internal fetch. + connection.setInstanceFollowRedirects(false); + try { + if (connection.getResponseCode() != 200 || connection.getContentLengthLong() > MAX_ASSET_BYTES) return null; + try (var input = connection.getInputStream()) { + byte[] data = input.readNBytes(MAX_ASSET_BYTES + 1); + if (data.length > MAX_ASSET_BYTES) return null; + BufferedImage img = decodeFetchedImage(data, connection.getContentType(), target.toString()); + if (img != null) assetCache.put(url, img); + return img; } - return img; + } finally { + connection.disconnect(); } } catch (IOException | IllegalArgumentException e) { logger.debug("Failed to fetch OG asset from {}", url, e); @@ -242,9 +249,18 @@ private BufferedImage getOrFetchImage(String url) { private BufferedImage decodeFetchedImage(byte[] data, String contentType, String sourceUrl) { if (data == null || data.length == 0) return null; - try { - BufferedImage raster = ImageIO.read(new ByteArrayInputStream(data)); - if (raster != null) return raster; + try (var input = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) { + var readers = ImageIO.getImageReaders(input); + if (readers.hasNext()) { + var reader = readers.next(); + try { + reader.setInput(input); + if ((long) reader.getWidth(0) * reader.getHeight(0) > MAX_RASTER_PIXELS) return null; + return reader.read(0); + } finally { + reader.dispose(); + } + } } catch (IOException | RuntimeException ex) { logger.debug("Failed to decode fetched raster image from {}", sourceUrl, ex); } diff --git a/backend/src/main/java/net/modtale/controller/user/UserController.java b/backend/src/main/java/net/modtale/controller/user/UserController.java index 474905de..7829ce8d 100644 --- a/backend/src/main/java/net/modtale/controller/user/UserController.java +++ b/backend/src/main/java/net/modtale/controller/user/UserController.java @@ -16,6 +16,7 @@ import net.modtale.model.dto.user.UserDTO; import net.modtale.model.dto.user.UserSummaryDTO; import net.modtale.model.project.Project; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.media.MediaUploadService; @@ -185,6 +186,33 @@ public ResponseEntity updateNotificationSettings( return ResponseEntity.ok().build(); } + @GetMapping("/user/launcher-settings") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity getLauncherSettings(Authentication authentication) { + User user = accountService.requireCurrentUser(authentication, "loading launcher settings"); + return ResponseEntity.ok(accountService.getLauncherSettings(user.getId())); + } + + @PutMapping("/user/launcher-settings") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_EDIT_BASIC', authentication)") + public ResponseEntity updateLauncherSettings( + @RequestBody LauncherSettingsSnapshot snapshot, + Authentication authentication + ) { + User user = accountService.requireCurrentUser(authentication, "syncing launcher settings"); + return ResponseEntity.ok(accountService.updateLauncherSettings(user.getId(), snapshot)); + } + + @PutMapping("/user/launcher-settings/preferences") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_EDIT_BASIC', authentication)") + public ResponseEntity updateLauncherSettingsPreferences( + @RequestBody LauncherSettingsSnapshot snapshot, + Authentication authentication + ) { + User user = accountService.requireCurrentUser(authentication, "syncing launcher settings"); + return ResponseEntity.ok(accountService.updateLauncherSettingsPreferences(user.getId(), snapshot)); + } + @PostMapping("/user/follow/{targetId}") @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_FOLLOW', authentication)") public ResponseEntity followUser(@PathVariable String targetId, Authentication authentication) { diff --git a/backend/src/main/java/net/modtale/controller/worldlist/WorldModListController.java b/backend/src/main/java/net/modtale/controller/worldlist/WorldModListController.java new file mode 100644 index 00000000..f5a17e07 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/worldlist/WorldModListController.java @@ -0,0 +1,63 @@ +package net.modtale.controller.worldlist; + +import jakarta.validation.Valid; +import java.io.IOException; +import net.modtale.model.dto.request.worldlist.CreateWorldModListRequest; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.service.user.account.AccountService; +import net.modtale.service.worldlist.WorldModListService; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +public class WorldModListController { + + private final WorldModListService service; + private final AccountService accountService; + + public WorldModListController(WorldModListService service, AccountService accountService) { + this.service = service; + this.accountService = accountService; + } + + @PostMapping("/lists") + public ResponseEntity create( + @Valid @RequestBody CreateWorldModListRequest request, + Authentication authentication + ) { + return ResponseEntity.ok(service.create( + request, + accountService.requireCurrentUser(authentication, "sharing a world mod list") + )); + } + + @GetMapping("/lists/{id}") + public ResponseEntity view(@PathVariable String id) { + return ResponseEntity.ok(service.view(id)); + } + + @GetMapping("/lists/{id}/install") + public ResponseEntity installMetadata(@PathVariable String id) { + return ResponseEntity.ok(service.metadataForInstall(id)); + } + + @GetMapping("/lists/{id}/download") + public ResponseEntity download(@PathVariable String id) throws IOException { + WorldModListService.Download download = service.download(id); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + download.filename() + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(new ByteArrayResource(download.bytes())); + } +} diff --git a/backend/src/main/java/net/modtale/exception/ErrorMessageUtils.java b/backend/src/main/java/net/modtale/exception/ErrorMessageUtils.java index fc64ddb9..063f1374 100644 --- a/backend/src/main/java/net/modtale/exception/ErrorMessageUtils.java +++ b/backend/src/main/java/net/modtale/exception/ErrorMessageUtils.java @@ -57,7 +57,7 @@ public static ResponseEntity response(HttpStatus status, String m } public static ResponseEntity response(HttpStatus status, Throwable throwable, String fallback) { - return response(status, describe(throwable, fallback)); + return response(status, status.is5xxServerError() ? fallback : describe(throwable, fallback)); } public static ResponseEntity badRequest(String message) { diff --git a/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java b/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java index b85cc298..aaafc453 100644 --- a/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java @@ -73,8 +73,7 @@ public ResponseEntity handleHandlerValidation(HandlerMethodValida @ExceptionHandler(UpstreamServiceException.class) public ResponseEntity handleUpstreamServiceException(UpstreamServiceException ex) { logger.error("UpstreamServiceException:", ex); - return ErrorMessageUtils.response(ex.getStatus(), - ErrorMessageUtils.describe(ex, "An upstream service request failed.")); + return ErrorMessageUtils.response(ex.getStatus(), ex, "An upstream service request failed."); } @ExceptionHandler(ProjectMediaOperationException.class) @@ -98,8 +97,7 @@ public ResponseEntity handleStorageOperation(StorageOperationExce @ExceptionHandler(Exception.class) public ResponseEntity handleAllOtherExceptions(Exception ex) { logger.error("Unhandled Exception:", ex); - return ErrorMessageUtils.response(HttpStatus.INTERNAL_SERVER_ERROR, - ErrorMessageUtils.describe(ex, "The server could not complete the request.")); + return ErrorMessageUtils.internalServerError(ex, "The server could not complete the request."); } @ExceptionHandler(MaxUploadSizeExceededException.class) diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index aea10b70..6c912247 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -430,7 +430,6 @@ public static ProjectDependencyDTO toDependencyDTO(ProjectDependency dependency) dependency.getProjectTitle(), dependency.getVersionNumber(), dependency.getDependencyType(), - dependency.getEnvironment(), dependency.getSource(), dependency.getExternalId(), dependency.getExternalUrl(), diff --git a/backend/src/main/java/net/modtale/model/dto/project/ArtifactIdentityDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ArtifactIdentityDTO.java new file mode 100644 index 00000000..2e1539e8 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/project/ArtifactIdentityDTO.java @@ -0,0 +1,50 @@ +package net.modtale.model.dto.project; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; + +public final class ArtifactIdentityDTO { + + private ArtifactIdentityDTO() {} + + public record Request( + @Valid @Size(max = 100) List artifacts + ) { + public Request { + artifacts = artifacts == null ? List.of() : List.copyOf(artifacts); + } + } + + public record Artifact( + @NotBlank @Size(max = 180) String key, + @Pattern(regexp = "(?i)^[a-f0-9]{64}$") String sha256, + @Min(0) @Max(4294967295L) Long curseForgeFingerprint, + @Size(max = 240) String manifestId, + @Size(max = 120) String version, + @Size(max = 1000) String website + ) {} + + public record Response(List matches) { + public Response { + matches = matches == null ? List.of() : List.copyOf(matches); + } + } + + public record Match( + String key, + String source, + String projectId, + String slug, + String title, + String classification, + String versionNumber, + String versionId, + String evidence, + int confidence + ) {} +} diff --git a/backend/src/main/java/net/modtale/model/dto/project/CurseForgeCatalogDTO.java b/backend/src/main/java/net/modtale/model/dto/project/CurseForgeCatalogDTO.java new file mode 100644 index 00000000..297bc428 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/project/CurseForgeCatalogDTO.java @@ -0,0 +1,99 @@ +package net.modtale.model.dto.project; + +import java.util.List; +import java.util.Map; +import net.modtale.service.project.version.CurseForgeApiClient; + +public final class CurseForgeCatalogDTO { + + private CurseForgeCatalogDTO() { + } + + public record Page(List content, int totalPages, long totalElements, int number, boolean last) { + public static Page from(CurseForgeApiClient.CurseForgeSearchResult result) { + int size = Math.max(1, result.pageSize()); + int page = result.index() / size; + int pages = result.totalCount() == 0 ? 0 : (int) Math.ceil(result.totalCount() / (double) size); + return new Page(result.projects().stream().map(Project::from).toList(), pages, + result.totalCount(), page, page + 1 >= pages); + } + } + + public record Project( + String id, + String slug, + String title, + String about, + String description, + String authorId, + String author, + String imageUrl, + String bannerUrl, + String classification, + int downloadCount, + int favoriteCount, + String updatedAt, + String license, + String repositoryUrl, + Map links, + List tags, + List galleryImages, + Map galleryImageCaptions, + Boolean allowComments, + boolean hmWikiEnabled, + String hmWikiSlug, + List versions, + String source, + String websiteUrl, + Boolean distributionAllowed + ) { + public static Project from(CurseForgeApiClient.CurseForgeProject project) { + String providerId = "curseforge:" + project.id(); + Map links = project.websiteUrl() == null + ? Map.of() + : Map.of("CurseForge", project.websiteUrl()); + return new Project( + providerId, providerId, project.title(), project.description(), project.summary(), null, + String.join(", ", project.authors()), project.iconUrl(), null, "MOD", + (int) Math.min(Integer.MAX_VALUE, Math.max(0, project.downloadCount())), 0, + project.dateModified(), null, null, links, project.categories(), project.screenshots(), Map.of(), + false, false, null, project.files().stream().map(file -> Version.from(project, file)).toList(), + "CURSEFORGE", project.websiteUrl(), project.distributionAllowed() + ); + } + } + + public record Version( + String id, + String versionNumber, + List gameVersions, + String fileUrl, + int downloadCount, + String releaseDate, + String changelog, + List dependencies, + String channel, + List incompatibleProjectIds + ) { + static Version from(CurseForgeApiClient.CurseForgeProject project, CurseForgeApiClient.CurseForgeFile file) { + return new Version(file.id(), file.versionNumber(), file.gameVersions(), + project.websiteUrl() + "/files/" + file.id(), + (int) Math.min(Integer.MAX_VALUE, Math.max(0, file.downloadCount())), file.fileDate(), null, + List.of(), file.releaseType(), List.of()); + } + } + + public record Download( + String downloadUrl, + int expiresIn, + String fileName, + Long fileSize, + Map hashes, + String source + ) { + public static Download from(CurseForgeApiClient.CurseForgeDownload download) { + return new Download(download.downloadUrl(), 0, download.fileName(), download.fileSize(), + download.hashes(), "CURSEFORGE"); + } + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java index 590bc817..31dc0282 100644 --- a/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java @@ -11,7 +11,6 @@ public record ProjectDependencyDTO( String projectTitle, String versionNumber, ProjectDependency.DependencyType dependencyType, - ProjectDependency.Environment environment, ProjectDependency.Source source, String externalId, String externalUrl, diff --git a/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthExchangeRequest.java b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthExchangeRequest.java new file mode 100644 index 00000000..ec1425c8 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthExchangeRequest.java @@ -0,0 +1,17 @@ +package net.modtale.model.dto.request.auth; + +import jakarta.validation.constraints.NotBlank; + +public class LauncherAuthExchangeRequest { + + @NotBlank(message = "A launcher authorization code is required.") + private String code; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthIssueRequest.java b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthIssueRequest.java new file mode 100644 index 00000000..63b4a143 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/auth/LauncherAuthIssueRequest.java @@ -0,0 +1,27 @@ +package net.modtale.model.dto.request.auth; + +import jakarta.validation.constraints.NotBlank; + +public class LauncherAuthIssueRequest { + + @NotBlank(message = "A launcher callback URL is required.") + private String redirectUri; + + private String state; + + public String getRedirectUri() { + return redirectUri; + } + + public void setRedirectUri(String redirectUri) { + this.redirectUri = redirectUri; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java b/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java index 5ee1bc02..711c073d 100644 --- a/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java +++ b/backend/src/main/java/net/modtale/model/dto/request/project/DependencyReferenceRequest.java @@ -11,7 +11,6 @@ public class DependencyReferenceRequest { private String projectTitle; private String versionNumber; private ProjectDependency.DependencyType dependencyType = ProjectDependency.DependencyType.REQUIRED; - private ProjectDependency.Environment environment = ProjectDependency.Environment.COMMON; private ProjectDependency.Source source = ProjectDependency.Source.MODTALE; private String externalId; private String externalUrl; @@ -45,14 +44,6 @@ public void setDependencyType(ProjectDependency.DependencyType dependencyType) { this.dependencyType = dependencyType == null ? ProjectDependency.DependencyType.REQUIRED : dependencyType; } - public ProjectDependency.Environment getEnvironment() { - return environment == null ? ProjectDependency.Environment.COMMON : environment; - } - - public void setEnvironment(ProjectDependency.Environment environment) { - this.environment = environment == null ? ProjectDependency.Environment.COMMON : environment; - } - public ProjectDependency.Source getSource() { return source == null ? ProjectDependency.Source.MODTALE : source; } diff --git a/backend/src/main/java/net/modtale/model/dto/request/worldlist/CreateWorldModListRequest.java b/backend/src/main/java/net/modtale/model/dto/request/worldlist/CreateWorldModListRequest.java new file mode 100644 index 00000000..37611dd2 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/worldlist/CreateWorldModListRequest.java @@ -0,0 +1,28 @@ +package net.modtale.model.dto.request.worldlist; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; + +public record CreateWorldModListRequest( + @Size(max = 120) String title, + @Size(max = 120) String worldName, + @Size(max = 60) String gameVersion, + @NotEmpty @Size(max = 200) List<@Valid Item> mods +) { + public record Item( + @Size(max = 160) String modId, + @Size(max = 120) String projectId, + @Size(max = 160) String slug, + @Size(max = 180) String title, + @Size(max = 80) String versionNumber, + ProjectClassification classification, + ProjectDependency.Source source, + @Size(max = 180) String externalId, + @Size(max = 600) String externalUrl, + @Size(max = 600) String icon + ) {} +} diff --git a/backend/src/main/java/net/modtale/model/dto/response/auth/LauncherAuthIssueResponse.java b/backend/src/main/java/net/modtale/model/dto/response/auth/LauncherAuthIssueResponse.java new file mode 100644 index 00000000..56a10a3e --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/response/auth/LauncherAuthIssueResponse.java @@ -0,0 +1,4 @@ +package net.modtale.model.dto.response.auth; + +public record LauncherAuthIssueResponse(String code, String redirectUri, String state, int expiresIn) { +} diff --git a/backend/src/main/java/net/modtale/model/dto/worldlist/WorldModListDTO.java b/backend/src/main/java/net/modtale/model/dto/worldlist/WorldModListDTO.java new file mode 100644 index 00000000..e0cd1d14 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/worldlist/WorldModListDTO.java @@ -0,0 +1,48 @@ +package net.modtale.model.dto.worldlist; + +import java.time.Instant; +import java.util.List; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; + +public record WorldModListDTO( + String id, + String title, + String worldName, + String gameVersion, + String ownerUsername, + Instant createdAt, + Instant lastViewedAt, + Instant expiresAt, + int viewCount, + int downloadCount, + int modCount, + int downloadableCount, + String shareUrl, + String downloadUrl, + String launcherInstallUrl, + List mods +) { + public record Item( + String id, + String modId, + String projectId, + String slug, + String title, + String authorId, + String author, + String description, + String versionNumber, + ProjectClassification classification, + ProjectDependency.Source source, + String externalId, + String externalUrl, + String icon, + String bannerUrl, + int downloadCount, + int favoriteCount, + String updatedAt, + boolean downloadable, + String unavailableReason + ) {} +} diff --git a/backend/src/main/java/net/modtale/model/project/ModpackTarget.java b/backend/src/main/java/net/modtale/model/project/ModpackTarget.java deleted file mode 100644 index e625979f..00000000 --- a/backend/src/main/java/net/modtale/model/project/ModpackTarget.java +++ /dev/null @@ -1,16 +0,0 @@ -package net.modtale.model.project; - -public enum ModpackTarget { - UNIVERSAL, - CLIENT, - SERVER; - - public boolean includes(ProjectDependency.Environment environment) { - ProjectDependency.Environment effective = environment == null - ? ProjectDependency.Environment.COMMON - : environment; - return this == UNIVERSAL - || this == CLIENT && effective != ProjectDependency.Environment.SERVER - || this == SERVER && effective != ProjectDependency.Environment.CLIENT; - } -} diff --git a/backend/src/main/java/net/modtale/model/project/Project.java b/backend/src/main/java/net/modtale/model/project/Project.java index 50146881..eb338514 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -49,6 +49,9 @@ @CompoundIndex(name = "status_game_version_downloads_idx", def = "{'status': 1, 'versions.gameVersions': 1, 'downloadCount': -1}"), @CompoundIndex(name = "status_game_version_updated_idx", def = "{'status': 1, 'versions.gameVersions': 1, 'updatedAt': -1}"), @CompoundIndex(name = "status_class_game_version_relevance_rank_idx", def = "{'status': 1, 'classification': 1, 'versions.gameVersions': 1, 'relevanceRank': 1}"), + @CompoundIndex(name = "status_version_hash_idx", def = "{'status': 1, 'versions.hash': 1}"), + @CompoundIndex(name = "status_manifest_id_idx", def = "{'status': 1, 'versions.manifestId': 1}"), + @CompoundIndex(name = "status_cf_fingerprint_idx", def = "{'status': 1, 'versions.curseForgeFingerprint': 1}"), @CompoundIndex(name = "status_expires_idx", def = "{'status': 1, 'expiresAt': 1}"), @CompoundIndex(name = "deleted_at_idx", def = "{'deletedAt': 1}"), @CompoundIndex(name = "trend_score_idx", def = "{'trendScore': -1}"), diff --git a/backend/src/main/java/net/modtale/model/project/ProjectDependency.java b/backend/src/main/java/net/modtale/model/project/ProjectDependency.java index 77a25485..21a86d02 100644 --- a/backend/src/main/java/net/modtale/model/project/ProjectDependency.java +++ b/backend/src/main/java/net/modtale/model/project/ProjectDependency.java @@ -24,12 +24,6 @@ public enum DependencyType { EMBEDDED } - public enum Environment { - COMMON, - CLIENT, - SERVER - } - private String id = UUID.randomUUID().toString(); private String projectId; private String projectTitle; @@ -43,7 +37,6 @@ public enum Environment { @Transient private String slug; private DependencyType dependencyType = DependencyType.REQUIRED; - private Environment environment = Environment.COMMON; private Source source = Source.MODTALE; private String externalId; private String externalUrl; @@ -131,11 +124,6 @@ public void setDependencyType(DependencyType dependencyType) { this.dependencyType = dependencyType == null ? DependencyType.REQUIRED : dependencyType; } - public Environment getEnvironment() { return environment == null ? Environment.COMMON : environment; } - public void setEnvironment(Environment environment) { - this.environment = environment == null ? Environment.COMMON : environment; - } - public Source getSource() { return source == null ? Source.MODTALE : source; } public void setSource(Source source) { this.source = source == null ? Source.MODTALE : source; } diff --git a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java index 8775a215..26e3bae6 100644 --- a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java +++ b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java @@ -9,6 +9,9 @@ public class ProjectVersion { private String fileUrl; private String overrideFileUrl; private String hash; + private String manifestId; + private String manifestVersion; + private Long curseForgeFingerprint; private int downloadCount; private String releaseDate; private String changelog; @@ -90,6 +93,15 @@ public ApprovedIssueBaseline( public String getHash() { return hash; } public void setHash(String hash) { this.hash = hash; } + public String getManifestId() { return manifestId; } + public void setManifestId(String manifestId) { this.manifestId = manifestId; } + + public String getManifestVersion() { return manifestVersion; } + public void setManifestVersion(String manifestVersion) { this.manifestVersion = manifestVersion; } + + public Long getCurseForgeFingerprint() { return curseForgeFingerprint; } + public void setCurseForgeFingerprint(Long curseForgeFingerprint) { this.curseForgeFingerprint = curseForgeFingerprint; } + public int getDownloadCount() { return downloadCount; } public void setDownloadCount(int downloadCount) { this.downloadCount = downloadCount; } diff --git a/backend/src/main/java/net/modtale/model/system/StatusHistory.java b/backend/src/main/java/net/modtale/model/system/StatusHistory.java index 2a76ed05..699e07cd 100644 --- a/backend/src/main/java/net/modtale/model/system/StatusHistory.java +++ b/backend/src/main/java/net/modtale/model/system/StatusHistory.java @@ -2,7 +2,6 @@ import java.time.LocalDateTime; import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; @Document(collection = "status_history") @@ -10,7 +9,10 @@ public class StatusHistory { @Id private String id; - @Indexed(expireAfter = "30d") + // The detached status store owns this collection's TTL index and reconciles + // legacy index options before replacing them. Declaring the index here makes + // Spring Data attempt creation first, which prevents startup when an older + // non-TTL timestamp index already exists. private LocalDateTime timestamp; private int apiLatency; diff --git a/backend/src/main/java/net/modtale/model/user/LauncherSettingsSnapshot.java b/backend/src/main/java/net/modtale/model/user/LauncherSettingsSnapshot.java new file mode 100644 index 00000000..35ea1151 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/user/LauncherSettingsSnapshot.java @@ -0,0 +1,205 @@ +package net.modtale.model.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class LauncherSettingsSnapshot implements Serializable { + + private static final long serialVersionUID = 1L; + + private int schemaVersion = 1; + private String settingsHash = ""; + private String updatedAt = ""; + private Preferences preferences = new Preferences(); + private List installedProjects = new ArrayList<>(); + + public int getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(int schemaVersion) { + this.schemaVersion = Math.max(1, schemaVersion); + } + + public String getSettingsHash() { + return settingsHash; + } + + public void setSettingsHash(String settingsHash) { + this.settingsHash = settingsHash == null ? "" : settingsHash.trim(); + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt == null ? "" : updatedAt.trim(); + } + + public Preferences getPreferences() { + return preferences; + } + + public void setPreferences(Preferences preferences) { + this.preferences = preferences == null ? new Preferences() : preferences; + } + + public List getInstalledProjects() { + return installedProjects; + } + + public void setInstalledProjects(List installedProjects) { + this.installedProjects = installedProjects == null ? new ArrayList<>() : new ArrayList<>(installedProjects); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Preferences implements Serializable { + private static final long serialVersionUID = 1L; + + private String hytaleModsPath = ""; + private String hytaleGamePath = ""; + private String hytaleUserDataPath = ""; + private String hytaleJavaPath = ""; + private String hytaleBranch = "release"; + private int hytaleBuild; + private String gameVersion = ""; + private boolean includeDependencies = true; + private boolean includeOptionalDependencies; + private boolean autoCheckUpdates = true; + private boolean launcherAutoUpdates; + + public String getHytaleModsPath() { return hytaleModsPath; } + public void setHytaleModsPath(String hytaleModsPath) { this.hytaleModsPath = hytaleModsPath; } + public String getHytaleGamePath() { return hytaleGamePath; } + public void setHytaleGamePath(String hytaleGamePath) { this.hytaleGamePath = hytaleGamePath; } + public String getHytaleUserDataPath() { return hytaleUserDataPath; } + public void setHytaleUserDataPath(String hytaleUserDataPath) { this.hytaleUserDataPath = hytaleUserDataPath; } + public String getHytaleJavaPath() { return hytaleJavaPath; } + public void setHytaleJavaPath(String hytaleJavaPath) { this.hytaleJavaPath = hytaleJavaPath; } + public String getHytaleBranch() { return hytaleBranch; } + public void setHytaleBranch(String hytaleBranch) { this.hytaleBranch = hytaleBranch; } + public int getHytaleBuild() { return hytaleBuild; } + public void setHytaleBuild(int hytaleBuild) { this.hytaleBuild = Math.max(0, hytaleBuild); } + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = gameVersion; } + public boolean isIncludeDependencies() { return includeDependencies; } + public void setIncludeDependencies(boolean includeDependencies) { this.includeDependencies = includeDependencies; } + public boolean isIncludeOptionalDependencies() { return includeOptionalDependencies; } + public void setIncludeOptionalDependencies(boolean includeOptionalDependencies) { this.includeOptionalDependencies = includeOptionalDependencies; } + public boolean isAutoCheckUpdates() { return autoCheckUpdates; } + public void setAutoCheckUpdates(boolean autoCheckUpdates) { this.autoCheckUpdates = autoCheckUpdates; } + public boolean isLauncherAutoUpdates() { return launcherAutoUpdates; } + public void setLauncherAutoUpdates(boolean launcherAutoUpdates) { this.launcherAutoUpdates = launcherAutoUpdates; } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstalledProject implements Serializable { + private static final long serialVersionUID = 1L; + + private String projectId = ""; + private String slug = ""; + private String title = ""; + private String classification = ""; + private String installedVersion = ""; + private String installedVersionId = ""; + private String gameVersion = ""; + private String source = "MODTALE"; + private String installType = "DIRECT"; + private boolean modpackUnlocked; + private List dependencyProjectIds = new ArrayList<>(); + private List externalDependencies = new ArrayList<>(); + private List bundledProjects = new ArrayList<>(); + + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getClassification() { return classification; } + public void setClassification(String classification) { this.classification = classification; } + public String getInstalledVersion() { return installedVersion; } + public void setInstalledVersion(String installedVersion) { this.installedVersion = installedVersion; } + public String getInstalledVersionId() { return installedVersionId; } + public void setInstalledVersionId(String installedVersionId) { this.installedVersionId = installedVersionId; } + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = gameVersion; } + public String getSource() { return source; } + public void setSource(String source) { this.source = source; } + public String getInstallType() { return installType; } + public void setInstallType(String installType) { this.installType = installType; } + public boolean isModpackUnlocked() { return modpackUnlocked; } + public void setModpackUnlocked(boolean modpackUnlocked) { this.modpackUnlocked = modpackUnlocked; } + public List getDependencyProjectIds() { return dependencyProjectIds; } + public void setDependencyProjectIds(List dependencyProjectIds) { + this.dependencyProjectIds = dependencyProjectIds == null ? new ArrayList<>() : new ArrayList<>(dependencyProjectIds); + } + public List getExternalDependencies() { return externalDependencies; } + public void setExternalDependencies(List externalDependencies) { + this.externalDependencies = externalDependencies == null ? new ArrayList<>() : new ArrayList<>(externalDependencies); + } + public List getBundledProjects() { return bundledProjects; } + public void setBundledProjects(List bundledProjects) { + this.bundledProjects = bundledProjects == null ? new ArrayList<>() : new ArrayList<>(bundledProjects); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstalledProjectReference implements Serializable { + private static final long serialVersionUID = 1L; + + private String id = ""; + private String projectId = ""; + private String slug = ""; + private String title = ""; + private String classification = ""; + private String versionNumber = ""; + private String dependencyType = ""; + private String source = ""; + private String externalId = ""; + private String externalUrl = ""; + private String externalFileUrl = ""; + private String externalFileName = ""; + private String cachedFileUrl = ""; + private String icon = ""; + private Boolean optional; + private Boolean embedded; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getClassification() { return classification; } + public void setClassification(String classification) { this.classification = classification; } + public String getVersionNumber() { return versionNumber; } + public void setVersionNumber(String versionNumber) { this.versionNumber = versionNumber; } + public String getDependencyType() { return dependencyType; } + public void setDependencyType(String dependencyType) { this.dependencyType = dependencyType; } + public String getSource() { return source; } + public void setSource(String source) { this.source = source; } + public String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } + public String getExternalUrl() { return externalUrl; } + public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } + public String getExternalFileUrl() { return externalFileUrl; } + public void setExternalFileUrl(String externalFileUrl) { this.externalFileUrl = externalFileUrl; } + public String getExternalFileName() { return externalFileName; } + public void setExternalFileName(String externalFileName) { this.externalFileName = externalFileName; } + public String getCachedFileUrl() { return cachedFileUrl; } + public void setCachedFileUrl(String cachedFileUrl) { this.cachedFileUrl = cachedFileUrl; } + public String getIcon() { return icon; } + public void setIcon(String icon) { this.icon = icon; } + public Boolean getOptional() { return optional; } + public void setOptional(Boolean optional) { this.optional = optional; } + public Boolean getEmbedded() { return embedded; } + public void setEmbedded(Boolean embedded) { this.embedded = embedded; } + } +} diff --git a/backend/src/main/java/net/modtale/model/user/User.java b/backend/src/main/java/net/modtale/model/user/User.java index b26b101c..4e59007d 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -77,6 +77,8 @@ public class User implements Serializable { private NotificationPreferences notificationPreferences = new NotificationPreferences(); + private LauncherSettingsSnapshot launcherSettings; + private String githubAccessToken; private String gitlabAccessToken; @@ -324,6 +326,9 @@ public void setAdminPermissions(Set adminPermissions) { public NotificationPreferences getNotificationPreferences() { return notificationPreferences; } public void setNotificationPreferences(NotificationPreferences notificationPreferences) { this.notificationPreferences = notificationPreferences; } + public LauncherSettingsSnapshot getLauncherSettings() { return launcherSettings; } + public void setLauncherSettings(LauncherSettingsSnapshot launcherSettings) { this.launcherSettings = launcherSettings; } + public String getGithubAccessToken() { return githubAccessToken; } public void setGithubAccessToken(String githubAccessToken) { this.githubAccessToken = githubAccessToken; } diff --git a/backend/src/main/java/net/modtale/model/worldlist/WorldModList.java b/backend/src/main/java/net/modtale/model/worldlist/WorldModList.java new file mode 100644 index 00000000..92c6e703 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/worldlist/WorldModList.java @@ -0,0 +1,158 @@ +package net.modtale.model.worldlist; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.FieldType; +import org.springframework.data.mongodb.core.mapping.MongoId; + +@Document(collection = "world_mod_lists") +@CompoundIndex(name = "world_mod_lists_expires_idx", def = "{'expiresAt': 1}") +public class WorldModList { + + @MongoId(FieldType.STRING) + private String id = UUID.randomUUID().toString(); + + @Indexed + private String ownerId; + + private String ownerUsername; + private String title; + private String worldName; + private String gameVersion; + private Instant createdAt; + private Instant lastViewedAt; + private Instant expiresAt; + private int viewCount; + private int downloadCount; + private List mods = new ArrayList<>(); + + public String getId() { return id; } + public void setId(String id) { this.id = id == null || id.isBlank() ? UUID.randomUUID().toString() : id; } + + public String getOwnerId() { return ownerId; } + public void setOwnerId(String ownerId) { this.ownerId = ownerId; } + + public String getOwnerUsername() { return ownerUsername; } + public void setOwnerUsername(String ownerUsername) { this.ownerUsername = ownerUsername; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getWorldName() { return worldName; } + public void setWorldName(String worldName) { this.worldName = worldName; } + + public String getGameVersion() { return gameVersion; } + public void setGameVersion(String gameVersion) { this.gameVersion = gameVersion; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getLastViewedAt() { return lastViewedAt; } + public void setLastViewedAt(Instant lastViewedAt) { this.lastViewedAt = lastViewedAt; } + + public Instant getExpiresAt() { return expiresAt; } + public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } + + public int getViewCount() { return viewCount; } + public void setViewCount(int viewCount) { this.viewCount = Math.max(0, viewCount); } + + public int getDownloadCount() { return downloadCount; } + public void setDownloadCount(int downloadCount) { this.downloadCount = Math.max(0, downloadCount); } + + public List getMods() { return mods; } + public void setMods(List mods) { this.mods = mods == null ? new ArrayList<>() : new ArrayList<>(mods); } + + public static class Item { + private String id = UUID.randomUUID().toString(); + private String modId; + private String projectId; + private String slug; + private String title; + private String authorId; + private String author; + private String description; + private String versionNumber; + private ProjectClassification classification; + private ProjectDependency.Source source = ProjectDependency.Source.MODTALE; + private String externalId; + private String externalUrl; + private String icon; + private String bannerUrl; + private int downloadCount; + private int favoriteCount; + private String updatedAt; + private String fileUrl; + private boolean downloadable; + private String unavailableReason; + + public String getId() { return id; } + public void setId(String id) { this.id = id == null || id.isBlank() ? UUID.randomUUID().toString() : id; } + + public String getModId() { return modId; } + public void setModId(String modId) { this.modId = modId; } + + public String getProjectId() { return projectId; } + public void setProjectId(String projectId) { this.projectId = projectId; } + + public String getSlug() { return slug; } + public void setSlug(String slug) { this.slug = slug; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getAuthorId() { return authorId; } + public void setAuthorId(String authorId) { this.authorId = authorId; } + + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getVersionNumber() { return versionNumber; } + public void setVersionNumber(String versionNumber) { this.versionNumber = versionNumber; } + + public ProjectClassification getClassification() { return classification; } + public void setClassification(ProjectClassification classification) { this.classification = classification; } + + public ProjectDependency.Source getSource() { return source == null ? ProjectDependency.Source.MODTALE : source; } + public void setSource(ProjectDependency.Source source) { this.source = source == null ? ProjectDependency.Source.MODTALE : source; } + + public String getExternalId() { return externalId; } + public void setExternalId(String externalId) { this.externalId = externalId; } + + public String getExternalUrl() { return externalUrl; } + public void setExternalUrl(String externalUrl) { this.externalUrl = externalUrl; } + + public String getIcon() { return icon; } + public void setIcon(String icon) { this.icon = icon; } + + public String getBannerUrl() { return bannerUrl; } + public void setBannerUrl(String bannerUrl) { this.bannerUrl = bannerUrl; } + + public int getDownloadCount() { return downloadCount; } + public void setDownloadCount(int downloadCount) { this.downloadCount = Math.max(0, downloadCount); } + + public int getFavoriteCount() { return favoriteCount; } + public void setFavoriteCount(int favoriteCount) { this.favoriteCount = Math.max(0, favoriteCount); } + + public String getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; } + + public String getFileUrl() { return fileUrl; } + public void setFileUrl(String fileUrl) { this.fileUrl = fileUrl; } + + public boolean isDownloadable() { return downloadable; } + public void setDownloadable(boolean downloadable) { this.downloadable = downloadable; } + + public String getUnavailableReason() { return unavailableReason; } + public void setUnavailableReason(String unavailableReason) { this.unavailableReason = unavailableReason; } + } +} diff --git a/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java b/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java index db5c8547..8529886f 100644 --- a/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java +++ b/backend/src/main/java/net/modtale/repository/project/ProjectRepository.java @@ -255,6 +255,19 @@ public interface ProjectRepository extends MongoRepository, Pro @Query(value = "{ 'status': { $in: ['PUBLISHED', 'ARCHIVED'] }, 'deletedAt': null }") List findAllPublished(); + @Query(value = "{ 'status': 'PUBLISHED', 'deletedAt': null, 'versions.hash': { $in: ?0 } }") + List findPublishedByVersionHashes(List hashes); + + @Query(value = "{ 'status': 'PUBLISHED', 'deletedAt': null, 'versions.manifestId': { $in: ?0 } }") + List findPublishedByManifestIds(List manifestIds); + + @Query(value = "{ 'status': 'PUBLISHED', 'deletedAt': null, 'versions.curseForgeFingerprint': { $in: ?0 } }") + List findPublishedByCurseForgeFingerprints(List fingerprints); + + @Query(value = "{ 'status': 'PUBLISHED', 'deletedAt': null }", + fields = "{ '_id': 1, 'slug': 1, 'title': 1, 'classification': 1, 'links': 1, 'repositoryUrl': 1, 'versions._id': 1, 'versions.versionNumber': 1, 'versions.curseForgeFingerprint': 1 }") + List findPublishedIdentityIndex(); + @Query(value = "{ 'status': 'PUBLISHED', 'deletedAt': null }", fields = "{ 'id': 1, 'title': 1, 'slug': 1, 'updatedAt': 1, 'classification': 1, 'author': 1, 'authorId': 1 }") List findAllForSitemap(); diff --git a/backend/src/main/java/net/modtale/repository/worldlist/WorldModListRepository.java b/backend/src/main/java/net/modtale/repository/worldlist/WorldModListRepository.java new file mode 100644 index 00000000..296f083b --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/worldlist/WorldModListRepository.java @@ -0,0 +1,9 @@ +package net.modtale.repository.worldlist; + +import java.time.Instant; +import net.modtale.model.worldlist.WorldModList; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface WorldModListRepository extends MongoRepository { + void deleteByExpiresAtBefore(Instant cutoff); +} diff --git a/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java b/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java index e74ada47..49dd131f 100644 --- a/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java +++ b/backend/src/main/java/net/modtale/service/auth/AuthenticationService.java @@ -156,7 +156,9 @@ public User validatePreAuthToken(String token) { if (System.currentTimeMillis() > expiry) return null; String expectedSignature = hmacSha256(userId + ":" + expiry, securityProperties.preAuthSecret()); - if (!expectedSignature.equals(providedSignature)) return null; + if (!java.security.MessageDigest.isEqual( + expectedSignature.getBytes(StandardCharsets.UTF_8), + providedSignature.getBytes(StandardCharsets.UTF_8))) return null; User user = userRepository.findById(userId).orElse(null); if (user != null && user.isDeleted()) return null; diff --git a/backend/src/main/java/net/modtale/service/auth/LauncherAuthService.java b/backend/src/main/java/net/modtale/service/auth/LauncherAuthService.java new file mode 100644 index 00000000..c7964ca8 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/auth/LauncherAuthService.java @@ -0,0 +1,108 @@ +package net.modtale.service.auth; + +import java.net.URI; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import net.modtale.exception.InvalidAuthenticationRequestException; +import net.modtale.model.user.User; +import net.modtale.repository.user.UserRepository; +import org.springframework.stereotype.Service; + +@Service +public class LauncherAuthService { + + public static final String OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE = "MODTALE_LAUNCHER_OAUTH_REDIRECT_URI"; + public static final String OAUTH_STATE_SESSION_ATTRIBUTE = "MODTALE_LAUNCHER_OAUTH_STATE"; + private static final Duration CODE_VALIDITY = Duration.ofMinutes(5); + private static final int CODE_LENGTH_BYTES = 32; + + private final Map codes = new ConcurrentHashMap<>(); + private final SecureRandom secureRandom = new SecureRandom(); + private final UserRepository userRepository; + + public LauncherAuthService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public LauncherAuthGrant issueCode(User user, String redirectUri, String state) { + if (user == null || user.getId() == null || user.getId().isBlank()) { + throw new InvalidAuthenticationRequestException("You need to sign in before authorizing the Modtale Launcher."); + } + validateLoopbackRedirectUri(redirectUri); + cleanExpiredCodes(); + + byte[] randomBytes = new byte[CODE_LENGTH_BYTES]; + secureRandom.nextBytes(randomBytes); + String code = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + Instant expiresAt = Instant.now().plus(CODE_VALIDITY); + codes.put(code, new LauncherAuthCode(user.getId(), expiresAt)); + + return new LauncherAuthGrant(code, redirectUri, normalizeState(state), Math.toIntExact(CODE_VALIDITY.toSeconds())); + } + + public User consumeCode(String code) { + if (code == null || code.isBlank()) { + return null; + } + + LauncherAuthCode authCode = codes.remove(code); + if (authCode == null || authCode.isExpired()) { + return null; + } + + Optional user = userRepository.findById(authCode.userId()); + return user.filter(candidate -> !candidate.isDeleted()).orElse(null); + } + + public int getActiveCodeCount() { + cleanExpiredCodes(); + return codes.size(); + } + + public void validateLoopbackRedirectUri(String redirectUri) { + URI uri; + try { + uri = URI.create(redirectUri == null ? "" : redirectUri.trim()); + } catch (IllegalArgumentException ex) { + throw new InvalidAuthenticationRequestException("The launcher callback URL is invalid."); + } + + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (!"http".equalsIgnoreCase(scheme) || host == null || uri.getPort() < 1) { + throw new InvalidAuthenticationRequestException("The launcher callback URL must use a local HTTP callback."); + } + + String normalizedHost = host.toLowerCase(Locale.ROOT); + boolean loopback = "localhost".equals(normalizedHost) + || "127.0.0.1".equals(normalizedHost) + || "::1".equals(normalizedHost) + || "[::1]".equals(normalizedHost); + if (!loopback) { + throw new InvalidAuthenticationRequestException("The launcher callback URL must point to this device."); + } + } + + private static String normalizeState(String state) { + return state == null ? "" : state.trim(); + } + + private void cleanExpiredCodes() { + codes.entrySet().removeIf(entry -> entry.getValue().isExpired()); + } + + public record LauncherAuthGrant(String code, String redirectUri, String state, int expiresIn) { + } + + private record LauncherAuthCode(String userId, Instant expiresAt) { + boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java b/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java index 950b5e2a..0e7b3c3f 100644 --- a/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java +++ b/backend/src/main/java/net/modtale/service/auth/OAuth2LoginService.java @@ -1,6 +1,7 @@ package net.modtale.service.auth; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import net.modtale.exception.AuthenticationOperationException; import net.modtale.exception.ForbiddenOperationException; import net.modtale.exception.InvalidAuthenticationRequestException; @@ -43,8 +44,11 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic try { Authentication currentAuth = currentAuthentication(); - if (currentAuth != null && currentAuth.isAuthenticated() && - !currentAuth.getName().equals("anonymousUser")) { + boolean linking = !hasPendingLauncherOAuth() + && currentAuth != null && currentAuth.isAuthenticated() + && !currentAuth.getName().equals("anonymousUser"); + + if (linking) { User currentUser = accountService.getCurrentUser(); @@ -53,10 +57,8 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } } - if ("gitlab".equals(provider)) { - throw new InvalidAuthenticationRequestException( - "GitLab can only be linked from an existing Modtale account." - ); + if ("gitlab".equalsIgnoreCase(provider)) { + throw new InvalidAuthenticationRequestException("GitLab can be linked from profile settings, but it cannot be used to sign in."); } return authenticationService.processUserLogin(provider, oauthUser, accessToken); @@ -74,6 +76,13 @@ private Authentication currentAuthentication() { return request != null && request.getUserPrincipal() instanceof Authentication auth ? auth : null; } + private boolean hasPendingLauncherOAuth() { + HttpServletRequest request = requestProvider.getIfAvailable(); + HttpSession session = request == null ? null : request.getSession(false); + return session != null + && session.getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE) instanceof String; + } + protected OAuth2User fetchOAuthUser(OAuth2UserRequest userRequest) { return super.loadUser(userRequest); } diff --git a/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java b/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java index 323871a4..d98bfff8 100644 --- a/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java +++ b/backend/src/main/java/net/modtale/service/auth/OidcLoginService.java @@ -1,6 +1,7 @@ package net.modtale.service.auth; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import java.util.Map; import net.modtale.exception.AuthenticationOperationException; import net.modtale.exception.ForbiddenOperationException; @@ -49,7 +50,8 @@ public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2Authenticatio Authentication currentAuth = currentAuthentication(); HttpServletRequest request = currentRequest(); - if (currentAuth != null && currentAuth.isAuthenticated() && !currentAuth.getName().equals("anonymousUser")) { + if (!hasPendingLauncherOAuth() + && currentAuth != null && currentAuth.isAuthenticated() && !currentAuth.getName().equals("anonymousUser")) { String pendingOrgId = request != null ? (String) request.getSession().getAttribute("pending_org_link_id") : null; @@ -89,6 +91,13 @@ private HttpServletRequest currentRequest() { return requestProvider.getIfAvailable(); } + private boolean hasPendingLauncherOAuth() { + HttpServletRequest request = currentRequest(); + HttpSession session = request == null ? null : request.getSession(false); + return session != null + && session.getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE) instanceof String; + } + protected OidcUser fetchOidcUser(OidcUserRequest userRequest) { return super.loadUser(userRequest); } diff --git a/backend/src/main/java/net/modtale/service/project/version/ArtifactIdentityService.java b/backend/src/main/java/net/modtale/service/project/version/ArtifactIdentityService.java new file mode 100644 index 00000000..f9768652 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/ArtifactIdentityService.java @@ -0,0 +1,235 @@ +package net.modtale.service.project.version; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import net.modtale.model.dto.project.ArtifactIdentityDTO; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectVersion; +import net.modtale.repository.project.ProjectRepository; +import org.springframework.stereotype.Service; + +@Service +public class ArtifactIdentityService { + private final ProjectRepository projects; + private final CurseForgeApiClient curseForge; + private volatile CachedAliases cachedAliases; + private static final long ALIAS_CACHE_NANOS = java.time.Duration.ofMinutes(2).toNanos(); + + public ArtifactIdentityService(ProjectRepository projects, CurseForgeApiClient curseForge) { + this.projects = projects; + this.curseForge = curseForge; + } + + public ArtifactIdentityDTO.Response identify(ArtifactIdentityDTO.Request request) { + List artifacts = request == null ? List.of() : request.artifacts(); + List hashes = values(artifacts, true); + List manifestIds = values(artifacts, false); + Map byHash = indexVersions( + hashes.isEmpty() ? List.of() : projects.findPublishedByVersionHashes(hashes), true); + Map byManifest = indexVersions( + manifestIds.isEmpty() ? List.of() : projects.findPublishedByManifestIds(manifestIds), false); + List requestedFingerprints = artifacts.stream().map(ArtifactIdentityDTO.Artifact::curseForgeFingerprint) + .filter(java.util.Objects::nonNull).distinct().toList(); + Map modtaleByFingerprint = indexFingerprints(requestedFingerprints.isEmpty() ? List.of() + : projects.findPublishedByCurseForgeFingerprints(requestedFingerprints)); + AliasIndex aliases = aliases(); + Map fingerprintMatches = curseForge.matchArtifacts( + artifacts.stream() + .filter(artifact -> artifact.curseForgeFingerprint() != null) + .map(artifact -> new CurseForgeApiClient.CurseForgeArtifact( + artifact.curseForgeFingerprint(), artifact.key())) + .toList()); + List matches = new ArrayList<>(); + for (ArtifactIdentityDTO.Artifact artifact : artifacts) { + Project exact = byHash.get(normalize(artifact.sha256())); + if (exact != null) { + matches.add(modtaleMatch(artifact, exact, versionByHash(exact, artifact.sha256()), "sha256", 100)); + continue; + } + Project exactCfBinary = artifact.curseForgeFingerprint() == null ? null + : modtaleByFingerprint.get(artifact.curseForgeFingerprint()); + if (exactCfBinary != null) { + matches.add(modtaleMatch(artifact, exactCfBinary, + versionByFingerprint(exactCfBinary, artifact.curseForgeFingerprint()), "curseforge-fingerprint", 100)); + continue; + } + CurseForgeApiClient.CurseForgeFingerprintMatch cf = artifact.curseForgeFingerprint() == null ? null + : fingerprintMatches.get(artifact.curseForgeFingerprint()); + if (cf != null) { + Project canonical = aliases.byCurseForgeId().get(cf.projectId()); + if (canonical != null) matches.add(modtaleMatch(artifact, canonical, versionByNumber(canonical, artifact.version()), "curseforge-fingerprint+alias", 100)); + else matches.add(curseForgeMatch(artifact, cf.projectId(), cf.fileId(), null, + "curseforge-fingerprint", 100)); + continue; + } + Project linked = modtaleProjectFromUrl(artifact.website(), aliases); + if (linked != null) { + matches.add(modtaleMatch(artifact, linked, versionByNumber(linked, artifact.version()), "project-url", 98)); + continue; + } + String cfSlug = curseForgeSlug(artifact.website()); + if (cfSlug != null) { + Project canonical = aliases.byCurseForgeSlug().get(cfSlug); + if (canonical != null) { + matches.add(modtaleMatch(artifact, canonical, versionByNumber(canonical, artifact.version()), + "curseforge-url+alias", 98)); + continue; + } + Optional resolved = curseForge.resolveProject(cfSlug, null); + if (resolved.isPresent()) { + CurseForgeApiClient.CurseForgeProject project = resolved.get(); + matches.add(curseForgeMatch(artifact, Long.parseLong(project.id()), 0, project, + "curseforge-url", 96)); + continue; + } + } + Project manifest = byManifest.get(normalize(artifact.manifestId())); + if (manifest != null) matches.add(modtaleMatch(artifact, manifest, + versionByManifest(manifest, artifact.manifestId(), artifact.version()), "hytale-manifest-id", 90)); + } + return new ArtifactIdentityDTO.Response(matches); + } + + public CurseForgeApiClient.CurseForgeSearchResult removeModtaleAliases(CurseForgeApiClient.CurseForgeSearchResult page) { + AliasIndex index = aliases(); + List filtered = page.projects().stream() + .filter(project -> !index.byCurseForgeSlug().containsKey(normalize(project.slug()))) + .filter(project -> parseLong(project.id()).map(id -> !index.byCurseForgeId().containsKey(id)).orElse(true)) + .filter(project -> project.files().stream().map(CurseForgeApiClient.CurseForgeFile::fingerprint) + .filter(java.util.Objects::nonNull).noneMatch(index.byFingerprint()::containsKey)) + .toList(); + long removed = page.projects().size() - filtered.size(); + return new CurseForgeApiClient.CurseForgeSearchResult(filtered, page.index(), page.pageSize(), + Math.max(filtered.size(), page.totalCount() - removed)); + } + + private AliasIndex aliases() { + CachedAliases cached = cachedAliases; + long now = System.nanoTime(); + if (cached != null && now - cached.createdAtNanos() < ALIAS_CACHE_NANOS) return cached.index(); + synchronized (this) { + cached = cachedAliases; + if (cached != null && now - cached.createdAtNanos() < ALIAS_CACHE_NANOS) return cached.index(); + AliasIndex rebuilt = buildAliases(); + cachedAliases = new CachedAliases(now, rebuilt); + return rebuilt; + } + } + + private AliasIndex buildAliases() { + Map slugs = new HashMap<>(); + Map ids = new HashMap<>(); + Map modtaleSlugs = new HashMap<>(); + Map fingerprints = new HashMap<>(); + for (Project project : projects.findPublishedIdentityIndex()) { + modtaleSlugs.putIfAbsent(normalize(project.getSlug()), project); + List urls = new ArrayList<>(project.getLinks() == null ? List.of() : project.getLinks().values()); + if (project.getRepositoryUrl() != null) urls.add(project.getRepositoryUrl()); + for (String url : urls) { + String slug = curseForgeSlug(url); + if (slug != null) slugs.putIfAbsent(slug, project); + curseForgeProjectId(url).ifPresent(id -> ids.putIfAbsent(id, project)); + } + for (ProjectVersion version : safeVersions(project)) if (version.getCurseForgeFingerprint() != null) + fingerprints.putIfAbsent(version.getCurseForgeFingerprint(), project); + } + return new AliasIndex(Map.copyOf(slugs), Map.copyOf(ids), Map.copyOf(modtaleSlugs), Map.copyOf(fingerprints)); + } + + private static Project modtaleProjectFromUrl(String url, AliasIndex aliases) { + String slug = modtaleSlug(url); + if (slug == null) return null; + return aliases.byModtaleSlug().get(slug); + } + + private Map indexVersions(List source, boolean hash) { + Map result = new LinkedHashMap<>(); + java.util.Set ambiguous = new java.util.HashSet<>(); + for (Project project : source) for (ProjectVersion version : safeVersions(project)) { + String key = normalize(hash ? version.getHash() : version.getManifestId()); + if (!key.isBlank() && result.putIfAbsent(key, project) != null && result.get(key) != project) ambiguous.add(key); + } + ambiguous.forEach(result::remove); + return result; + } + + private static Map indexFingerprints(List source) { + Map result = new LinkedHashMap<>(); + java.util.Set ambiguous = new java.util.HashSet<>(); + for (Project project : source) for (ProjectVersion version : safeVersions(project)) { + Long key = version.getCurseForgeFingerprint(); + if (key != null && result.putIfAbsent(key, project) != null && result.get(key) != project) ambiguous.add(key); + } + ambiguous.forEach(result::remove); + return result; + } + + private static List values(List artifacts, boolean hash) { + return artifacts.stream().map(a -> hash ? a.sha256() : a.manifestId()).map(ArtifactIdentityService::normalize) + .filter(s -> !s.isBlank()).distinct().toList(); + } + + private static ArtifactIdentityDTO.Match modtaleMatch(ArtifactIdentityDTO.Artifact a, Project p, ProjectVersion v, String evidence, int confidence) { + return new ArtifactIdentityDTO.Match(a.key(), "MODTALE", p.getId(), p.getSlug(), p.getTitle(), + p.getClassification() == null ? "PLUGIN" : p.getClassification().name(), + v == null ? a.version() : v.getVersionNumber(), v == null ? "" : v.getId(), evidence, confidence); + } + + private static ArtifactIdentityDTO.Match curseForgeMatch(ArtifactIdentityDTO.Artifact a, long id, long fileId, + CurseForgeApiClient.CurseForgeProject p, String evidence, int confidence) { + String slug = p == null ? "" : p.slug(); + String title = p == null ? "" : p.title(); + return new ArtifactIdentityDTO.Match(a.key(), "CURSEFORGE", "curseforge:" + id, slug, title, + "PLUGIN", a.version(), fileId > 0 ? Long.toString(fileId) : "", evidence, confidence); + } + + private static ProjectVersion versionByHash(Project p, String hash) { return safeVersions(p).stream().filter(v -> normalize(hash).equals(normalize(v.getHash()))).findFirst().orElse(null); } + private static ProjectVersion versionByNumber(Project p, String number) { return safeVersions(p).stream().filter(v -> normalize(number).equals(normalize(v.getVersionNumber()))).findFirst().orElse(null); } + private static ProjectVersion versionByManifest(Project p, String id, String version) { return safeVersions(p).stream().filter(v -> normalize(id).equals(normalize(v.getManifestId())) && (normalize(version).isBlank() || normalize(version).equals(normalize(v.getManifestVersion())))).findFirst().orElse(null); } + private static ProjectVersion versionByFingerprint(Project p, Long fingerprint) { return safeVersions(p).stream().filter(v -> fingerprint != null && fingerprint.equals(v.getCurseForgeFingerprint())).findFirst().orElse(null); } + private static List safeVersions(Project p) { return p.getVersions() == null ? List.of() : p.getVersions(); } + private static String normalize(String value) { return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); } + + private static String curseForgeSlug(String value) { + try { + URI uri = URI.create(value == null ? "" : value.trim()); + if ((!"www.curseforge.com".equalsIgnoreCase(uri.getHost()) + && !"curseforge.com".equalsIgnoreCase(uri.getHost())) || uri.getPath() == null) return null; + var matcher = java.util.regex.Pattern.compile("^/hytale/mods/([a-z0-9-]+)(?:/.*)?$", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(uri.getPath()); + return matcher.matches() ? normalize(matcher.group(1)) : null; + } catch (IllegalArgumentException ex) { return null; } + } + + private static Optional curseForgeProjectId(String value) { + try { + URI uri = URI.create(value == null ? "" : value.trim()); + if (!"curseforge.com".equalsIgnoreCase(uri.getHost()) && !"www.curseforge.com".equalsIgnoreCase(uri.getHost())) return Optional.empty(); + String query = uri.getQuery(); + if (query == null) return Optional.empty(); + for (String item : query.split("&")) if (item.matches("(?:projectId|project-id)=\\d+")) return parseLong(item.substring(item.indexOf('=') + 1)); + } catch (IllegalArgumentException ignored) {} + return Optional.empty(); + } + + private static String modtaleSlug(String value) { + try { + URI uri = URI.create(value == null ? "" : value.trim()); + if (uri.getHost() == null) return null; + String host = uri.getHost().toLowerCase(Locale.ROOT); + if (!host.equals("modtale.net") && !host.endsWith(".modtale.net")) return null; + var matcher = java.util.regex.Pattern.compile("^/(?:mod|project)/([a-z0-9-]+)/?.*$", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(uri.getPath()); + return matcher.matches() ? normalize(matcher.group(1)) : null; + } catch (IllegalArgumentException ex) { return null; } + } + + private static Optional parseLong(String value) { try { return Optional.of(Long.parseLong(value)); } catch (RuntimeException ex) { return Optional.empty(); } } + private record AliasIndex(Map byCurseForgeSlug, Map byCurseForgeId, + Map byModtaleSlug, Map byFingerprint) {} + private record CachedAliases(long createdAtNanos, AliasIndex index) {} +} diff --git a/backend/src/main/java/net/modtale/service/project/version/CurseForgeApiClient.java b/backend/src/main/java/net/modtale/service/project/version/CurseForgeApiClient.java index 9ee584e6..220ecc39 100644 --- a/backend/src/main/java/net/modtale/service/project/version/CurseForgeApiClient.java +++ b/backend/src/main/java/net/modtale/service/project/version/CurseForgeApiClient.java @@ -4,21 +4,21 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.time.Duration; -import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import net.modtale.config.properties.AppCurseForgeProperties; -import org.springframework.beans.factory.annotation.Autowired; +import java.util.Set; +import net.modtale.exception.UpstreamServiceException; import org.springframework.http.HttpHeaders; -import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.RequestEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; @@ -26,235 +26,356 @@ @Service public class CurseForgeApiClient { - - private static final int MAX_FILES = 20; - private static final int MAX_CACHE_ENTRIES = 500; - private static final Duration CACHE_TTL = Duration.ofMinutes(10); - private static final String API_BASE = "https://api.curseforge.com"; + private static final int HYTALE_GAME_ID = 70216; + private static final int MAX_FILES = 50; + private static final int MAX_IDENTITY_CANDIDATES = 200; + private static final String WEBSITE_BASE = "https://www.curseforge.com"; + private static final String NYOCF_BASE = "https://nyocf.junyo.dev"; + private static final String USER_AGENT = "Modtale/1.0 (+https://modtale.net)"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private final AppCurseForgeProperties properties; private final RestTemplate restTemplate; - private final ConcurrentMap cache = new ConcurrentHashMap<>(); - @Autowired - public CurseForgeApiClient(AppCurseForgeProperties properties) { - this(properties, createRestTemplate()); + public CurseForgeApiClient() { + this(createRestTemplate()); } private static RestTemplate createRestTemplate() { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(Duration.ofSeconds(3)); - requestFactory.setReadTimeout(Duration.ofSeconds(5)); + requestFactory.setConnectTimeout(Duration.ofSeconds(10)); + requestFactory.setReadTimeout(Duration.ofSeconds(30)); return new RestTemplate(requestFactory); } - CurseForgeApiClient(AppCurseForgeProperties properties, RestTemplate restTemplate) { - this.properties = properties; + CurseForgeApiClient(RestTemplate restTemplate) { this.restTemplate = restTemplate; } public boolean isConfigured() { - return properties.isConfigured(); + return true; } public Optional resolveProject(String slug, String requestedFileId) { - if (!properties.isConfigured() || slug == null || slug.isBlank() - || (requestedFileId != null && !requestedFileId.matches("[0-9]+"))) { - return Optional.empty(); + if (slug == null || !slug.trim().matches("[A-Za-z0-9][A-Za-z0-9-]*") + || (requestedFileId != null && !requestedFileId.matches("[0-9]+"))) return Optional.empty(); + return fetchProject(slug.trim(), requestedFileId); + } + + public CurseForgeSearchResult searchMods(String search, String gameVersion, int page, int pageSize, String sort) { + int safePage = Math.max(0, page); + int safePageSize = Math.max(1, Math.min(50, pageSize)); + URI uri = UriComponentsBuilder.fromUriString(NYOCF_BASE) + .path("/api/v1/hytale/mods/search") + .queryParam("q", search == null ? "" : search.trim()) + .queryParam("limit", safePageSize) + .queryParam("offset", safePage * safePageSize) + .queryParam("include_files", true).build().encode().toUri(); + try { + JsonNode envelope = getJson(uri); + List projects = new ArrayList<>(); + if (envelope.path("data").isArray()) for (JsonNode item : envelope.path("data")) { + CurseForgeProject parsed = parseSearchProject(item, gameVersion); + if (parsed != null && (isBlank(gameVersion) || !parsed.files().isEmpty())) projects.add(parsed); + } + sortProjects(projects, sort); + long total = Math.max(projects.size(), envelope.path("pagination").path("total").asLong(projects.size())); + return new CurseForgeSearchResult(List.copyOf(projects), safePage * safePageSize, safePageSize, total); + } catch (RestClientException | java.io.IOException ex) { + throw new UpstreamServiceException(HttpStatus.BAD_GATEWAY, "CurseForge catalog is unavailable.", ex); } + } - String cacheKey = slug.toLowerCase(Locale.ROOT) + ":" + (requestedFileId == null ? "latest" : requestedFileId); - CachedProject cached = cache.get(cacheKey); - if (cached != null && cached.expiresAt().isAfter(Instant.now())) { - return Optional.of(cached.project()); + public Optional getProject(long projectId) { + return projectId <= 0 ? Optional.empty() : fetchProject(Long.toString(projectId), null); + } + + public Optional getDownload(long projectId, long fileId) { + if (projectId <= 0 || fileId <= 0) return Optional.empty(); + try { + JsonNode node = getJson(exactFileUri(projectId, fileId)); + CurseForgeFile file = parseExactFile(node, projectId); + if (file == null) return Optional.empty(); + String providerUrl = text(node, "download_url"); + String downloadUrl = isApprovedDownloadUrl(providerUrl) ? providerUrl + : WEBSITE_BASE + "/api/v1/mods/" + projectId + "/files/" + fileId + "/download"; + return Optional.of(new CurseForgeDownload(downloadUrl, file.fileName(), file.fileSize(), file.hashes())); + } catch (RestClientException | java.io.IOException | IllegalArgumentException ex) { + return Optional.empty(); } - if (cached != null) cache.remove(cacheKey, cached); + } + /** A filename locates candidates; only nyoCF's exact-file fingerprint proves identity. */ + public Map matchArtifacts(List artifacts) { + if (artifacts == null || artifacts.isEmpty()) return Map.of(); + Map> byFileName = new LinkedHashMap<>(); + for (CurseForgeArtifact artifact : artifacts) { + if (artifact == null || artifact.fingerprint() < 0 || isBlank(artifact.fileName())) continue; + String fileName = baseName(artifact.fileName()); + if (!isBlank(fileName)) byFileName.computeIfAbsent(fileName, ignored -> new ArrayList<>()).add(artifact); + if (byFileName.size() >= 100) break; + } + if (byFileName.isEmpty()) return Map.of(); try { - URI searchUri = UriComponentsBuilder.fromUriString(API_BASE) - .path("/v1/mods/search") - .queryParam("gameId", properties.hytaleGameId()) - .queryParam("slug", slug) - .queryParam("pageSize", 1) - .build() - .encode() - .toUri(); - JsonNode projectEnvelope = get(searchUri); - JsonNode projects = projectEnvelope.path("data"); - if (!projects.isArray() || projects.isEmpty()) { - return Optional.empty(); + JsonNode results = postJson(UriComponentsBuilder.fromUriString(NYOCF_BASE) + .path("/api/v1/hytale/mods/batch-search").build().toUri(), + Map.of("queries", List.copyOf(byFileName.keySet()))).path("results"); + Map matches = new LinkedHashMap<>(); + Map> filesByProject = new HashMap<>(); + Set inspected = new LinkedHashSet<>(); + int candidateCount = 0; + for (Map.Entry> query : byFileName.entrySet()) { + JsonNode candidates = results.path(query.getKey()); + if (!candidates.isArray()) continue; + for (JsonNode candidate : candidates) { + long projectId = candidate.path("id").asLong(0); + if (projectId <= 0 || ++candidateCount > MAX_IDENTITY_CANDIDATES) break; + List files = filesByProject.computeIfAbsent(projectId, this::fetchFilesQuietly); + for (CurseForgeFile listed : files) { + if (!query.getKey().equalsIgnoreCase(listed.fileName())) continue; + String exactKey = projectId + ":" + listed.id(); + if (!inspected.add(exactKey)) continue; + CurseForgeFile exact = fetchExactFileQuietly(projectId, Long.parseLong(listed.id())); + if (exact == null || exact.fingerprint() == null) continue; + for (CurseForgeArtifact artifact : query.getValue()) { + if (exact.fingerprint().equals(artifact.fingerprint())) matches.putIfAbsent( + artifact.fingerprint(), new CurseForgeFingerprintMatch(projectId, Long.parseLong(exact.id()))); + } + } + } } + return Map.copyOf(matches); + } catch (RestClientException | java.io.IOException ex) { + return Map.of(); + } + } - JsonNode project = projects.get(0); - String resolvedSlug = text(project, "slug"); + private Optional fetchProject(String idOrSlug, String requestedFileId) { + try { + JsonNode project = getJson(UriComponentsBuilder.fromUriString(NYOCF_BASE) + .path("/api/v1/hytale/mods/{id}").buildAndExpand(idOrSlug).encode().toUri()); long projectId = project.path("id").asLong(0); - if (projectId <= 0 || resolvedSlug == null || !resolvedSlug.equalsIgnoreCase(slug) - || project.path("gameId").asLong(0) != properties.hytaleGameId() - || !project.path("isAvailable").asBoolean(false)) { - return Optional.empty(); + String slug = text(project, "slug"); + String websiteUrl = project.path("links").path("website").textValue(); + if (projectId <= 0 || project.path("game_id").asLong(0) != HYTALE_GAME_ID + || !project.path("is_available").asBoolean(false) || slug == null + || !isHytaleProjectUrl(websiteUrl)) return Optional.empty(); + List files; + if (requestedFileId == null) files = fetchFiles(projectId).stream().limit(MAX_FILES).toList(); + else { + CurseForgeFile exact = fetchExactFile(projectId, Long.parseLong(requestedFileId)); + if (exact == null) return Optional.empty(); + files = List.of(exact); } - - List files = requestedFileId == null - ? getRecentFiles(projectId) - : getExactFile(projectId, requestedFileId); - if (requestedFileId != null && files.isEmpty()) return Optional.empty(); - - CurseForgeProject resolved = new CurseForgeProject( - Long.toString(projectId), - resolvedSlug, - text(project, "name"), - text(project, "summary"), - project.path("logo").path("thumbnailUrl").textValue(), - project.has("allowModDistribution") ? project.path("allowModDistribution").booleanValue() : null, - files - ); - if (cache.size() >= MAX_CACHE_ENTRIES) cache.clear(); - cache.put(cacheKey, new CachedProject(resolved, Instant.now().plus(CACHE_TTL))); - return Optional.of(resolved); + String description = null; + try { + description = text(getJson(UriComponentsBuilder.fromUriString(NYOCF_BASE) + .path("/api/v1/hytale/mods/{id}/description").buildAndExpand(projectId).encode().toUri()), "description"); + } catch (RestClientException | java.io.IOException ignored) { + // Rich descriptions are optional; metadata, versions, and installs remain available. + } + return Optional.of(new CurseForgeProject(Long.toString(projectId), slug, text(project, "name"), + text(project, "summary"), project.path("logo").path("thumbnail_url").textValue(), true, + files, websiteUrl, strings(project.path("authors"), "name"), strings(project.path("categories"), "name"), + strings(project.path("screenshots"), "url"), project.path("dates").path("modified").textValue(), + Math.max(0, project.path("download_count").asLong(0)), description)); } catch (RestClientException | IllegalArgumentException | java.io.IOException ex) { return Optional.empty(); } } - private List getRecentFiles(long projectId) throws java.io.IOException { - URI filesUri = UriComponentsBuilder.fromUriString(API_BASE) - .path("/v1/mods/{projectId}/files") - .queryParam("pageSize", 50) - .buildAndExpand(projectId) - .encode() - .toUri(); - return parseFiles(get(filesUri).path("data"), projectId); - } - - private List getExactFile(long projectId, String fileId) throws java.io.IOException { - URI fileUri = UriComponentsBuilder.fromUriString(API_BASE) - .path("/v1/mods/{projectId}/files/{fileId}") - .buildAndExpand(projectId, fileId) - .encode() - .toUri(); - CurseForgeFile file = parseFile(get(fileUri).path("data"), projectId); - return file == null ? List.of() : List.of(file); - } - - private JsonNode get(URI uri) throws java.io.IOException { - RequestEntity request = RequestEntity.get(uri) - .header(HttpHeaders.ACCEPT, "application/json") - .header(HttpHeaders.USER_AGENT, "Modtale/1.0 (+https://modtale.net)") - .header("x-api-key", properties.apiKey().trim()) - .build(); - String body = restTemplate.exchange(request, String.class).getBody(); - if (body == null || body.isBlank()) { - throw new java.io.IOException("CurseForge returned an empty response."); - } - return OBJECT_MAPPER.readTree(body); + private List fetchFiles(long projectId) throws java.io.IOException { + return parseFiles(getJson(UriComponentsBuilder.fromUriString(NYOCF_BASE) + .path("/api/v1/hytale/mods/{id}/files").buildAndExpand(projectId).encode().toUri()), projectId, null); } - private List parseFiles(JsonNode data, long projectId) { - if (!data.isArray()) { - return List.of(); - } + private List fetchFilesQuietly(long projectId) { + try { return fetchFiles(projectId); } + catch (RestClientException | java.io.IOException ex) { return List.of(); } + } + + private CurseForgeFile fetchExactFile(long projectId, long fileId) throws java.io.IOException { + return parseExactFile(getJson(exactFileUri(projectId, fileId)), projectId); + } + + private CurseForgeFile fetchExactFileQuietly(long projectId, long fileId) { + try { return fetchExactFile(projectId, fileId); } + catch (RestClientException | java.io.IOException ex) { return null; } + } + + private URI exactFileUri(long projectId, long fileId) { + return UriComponentsBuilder.fromUriString(NYOCF_BASE) + .path("/api/v1/hytale/mods/{projectId}/files/{fileId}") + .buildAndExpand(projectId, fileId).encode().toUri(); + } + + private CurseForgeProject parseSearchProject(JsonNode item, String gameVersion) { + long projectId = item.path("id").asLong(0); + String slug = text(item, "slug"); + if (projectId <= 0 || slug == null || !slug.matches("[a-z0-9-]+")) return null; + List files = parseFiles(item.path("recent_files"), projectId, gameVersion); + String modified = files.isEmpty() ? null : files.getFirst().fileDate(); + String author = text(item, "primary_author"); + return new CurseForgeProject(Long.toString(projectId), slug, text(item, "name"), text(item, "summary"), + text(item, "logo_thumbnail_url"), true, files, WEBSITE_BASE + "/hytale/mods/" + slug, + author == null ? List.of() : List.of(author), strings(item.path("categories"), null), List.of(), + modified, Math.max(0, item.path("download_count").asLong(0)), null); + } + + private List parseFiles(JsonNode nodes, long projectId, String gameVersion) { + if (!nodes.isArray()) return List.of(); List files = new ArrayList<>(); - for (JsonNode file : data) { - CurseForgeFile parsed = parseFile(file, projectId); - if (parsed != null) files.add(parsed); + for (JsonNode node : nodes) { + List versions = parseGameVersions(node.path("game_versions")); + if (!isBlank(gameVersion) && versions.stream().noneMatch(gameVersion.trim()::equalsIgnoreCase)) continue; + CurseForgeFile file = parseFile(node, true); + if (file != null) files.add(file); } files.sort(Comparator.comparing(CurseForgeFile::fileDate, Comparator.nullsLast(String::compareTo)).reversed()); - return files.stream().limit(MAX_FILES).toList(); + return List.copyOf(files); } - private CurseForgeFile parseFile(JsonNode file, long projectId) { - long fileId = file.path("id").asLong(0); - if (fileId <= 0 || file.path("modId").asLong(0) != projectId || !file.path("isAvailable").asBoolean(false)) { - return null; - } - return new CurseForgeFile( - Long.toString(fileId), - text(file, "displayName"), - text(file, "fileName"), - inferVersion(file), - releaseType(file.path("releaseType").asInt(0)), - text(file, "fileDate"), - file.path("fileLength").canConvertToLong() && file.path("fileLength").asLong() > 0 - ? file.path("fileLength").asLong() : null, - parseHashes(file.path("hashes")), - parseGameVersions(file.path("gameVersions")), - file.path("fileStatus").canConvertToInt() && file.path("fileStatus").asInt() > 0 - ? file.path("fileStatus").asInt() : null, - true - ); + private CurseForgeFile parseExactFile(JsonNode node, long projectId) { + if (node.path("mod_id").asLong(0) != projectId || node.path("game_id").asLong(0) != HYTALE_GAME_ID + || !node.path("is_available").asBoolean(false)) return null; + return parseFile(node, false); + } + + private CurseForgeFile parseFile(JsonNode node, boolean listed) { + long id = node.path("id").asLong(0); + if (id <= 0) return null; + String releaseType = text(node, "release_type"); + String displayName = text(node, "display_name"); + String fileName = text(node, "file_name"); + JsonNode hashes = node.path("hashes"); + Long fingerprint = hashes.path("fingerprint").canConvertToLong() && hashes.path("fingerprint").asLong() >= 0 + ? hashes.path("fingerprint").asLong() : null; + return new CurseForgeFile(Long.toString(id), displayName, fileName, displayName == null ? fileName : displayName, + releaseType == null ? null : releaseType.toUpperCase(Locale.ROOT), text(node, "file_date"), + node.path("file_length").asLong(0) > 0 ? node.path("file_length").asLong() : null, + parseHashes(hashes), parseGameVersions(node.path("game_versions")), null, + listed || node.path("is_available").asBoolean(false), Math.max(0, node.path("download_count").asLong(0)), fingerprint); + } + + private JsonNode getJson(URI uri) throws java.io.IOException { + RequestEntity request = RequestEntity.get(uri).header(HttpHeaders.ACCEPT, "application/json") + .header(HttpHeaders.USER_AGENT, USER_AGENT).build(); + return readJson(restTemplate.exchange(request, String.class).getBody()); + } + + private JsonNode postJson(URI uri, Object body) throws java.io.IOException { + RequestEntity request = RequestEntity.post(uri).header(HttpHeaders.ACCEPT, "application/json") + .header(HttpHeaders.CONTENT_TYPE, "application/json").header(HttpHeaders.USER_AGENT, USER_AGENT).body(body); + return readJson(restTemplate.exchange(request, String.class).getBody()); + } + + private JsonNode readJson(String body) throws java.io.IOException { + if (body == null || body.isBlank()) throw new java.io.IOException("nyoCF returned an empty response."); + return OBJECT_MAPPER.readTree(body); + } + + private void sortProjects(List projects, String sort) { + String normalized = sort == null ? "downloads" : sort.trim().toLowerCase(Locale.ROOT); + Comparator comparator = switch (normalized) { + case "name", "alphabetical" -> Comparator.comparing( + project -> Optional.ofNullable(project.title()).orElse(""), String.CASE_INSENSITIVE_ORDER); + case "updated", "recently-updated", "created", "newest" -> Comparator.comparing( + CurseForgeProject::dateModified, Comparator.nullsLast(String::compareTo)).reversed(); + default -> Comparator.comparingLong(CurseForgeProject::downloadCount).reversed(); + }; + projects.sort(comparator.thenComparing(CurseForgeProject::id)); + } + + private boolean isHytaleProjectUrl(String value) { + try { + URI uri = URI.create(value); + return "https".equalsIgnoreCase(uri.getScheme()) && uri.getUserInfo() == null && uri.getPort() == -1 + && "www.curseforge.com".equalsIgnoreCase(uri.getHost()) && uri.getPath() != null + && uri.getPath().matches("/hytale/mods/[a-z0-9-]+/?"); + } catch (IllegalArgumentException ex) { return false; } + } + + private boolean isApprovedDownloadUrl(String value) { + if (isBlank(value)) return false; + URI uri = URI.create(value.trim()); + String host = uri.getHost(); + return "https".equalsIgnoreCase(uri.getScheme()) && uri.getUserInfo() == null && uri.getPort() == -1 + && host != null && (host.equalsIgnoreCase("forgecdn.net") || host.toLowerCase(Locale.ROOT).endsWith(".forgecdn.net")); } private Map parseHashes(JsonNode hashes) { - if (!hashes.isArray()) return Map.of(); + if (!hashes.isObject()) return Map.of(); Map result = new LinkedHashMap<>(); - for (JsonNode hash : hashes) { - String algorithm = switch (hash.path("algo").asInt(0)) { - case 1 -> "sha1"; - case 2 -> "md5"; - default -> null; - }; - String value = text(hash, "value"); - if (algorithm != null && value != null && value.matches("(?i)[a-f0-9]+")) { - int expectedLength = "sha1".equals(algorithm) ? 40 : 32; - if (value.length() == expectedLength) result.put(algorithm, value.toLowerCase(Locale.ROOT)); - } - } + addHash(result, "sha1", text(hashes, "sha1"), 40); + addHash(result, "md5", text(hashes, "md5"), 32); return Map.copyOf(result); } + private void addHash(Map hashes, String algorithm, String value, int length) { + if (value != null && value.length() == length && value.matches("(?i)[a-f0-9]+")) + hashes.put(algorithm, value.toLowerCase(Locale.ROOT)); + } + private List parseGameVersions(JsonNode versions) { if (!versions.isArray()) return List.of(); List result = new ArrayList<>(); for (JsonNode version : versions) { String value = version.textValue(); - if (value != null && !value.isBlank() && !result.contains(value.trim())) result.add(value.trim()); + if (!isBlank(value) && !result.contains(value.trim())) result.add(value.trim()); } return List.copyOf(result); } - private String inferVersion(JsonNode file) { - String displayName = text(file, "displayName"); - return displayName == null ? text(file, "fileName") : displayName; - } - - private String releaseType(int value) { - return switch (value) { - case 1 -> "RELEASE"; - case 2 -> "BETA"; - case 3 -> "ALPHA"; - default -> null; - }; + private List strings(JsonNode array, String field) { + if (!array.isArray()) return List.of(); + List values = new ArrayList<>(); + for (JsonNode item : array) { + String value = field == null ? item.textValue() : text(item, field); + if (!isBlank(value) && !values.contains(value.trim())) values.add(value.trim()); + } + return List.copyOf(values); } private String text(JsonNode node, String field) { String value = node.path(field).textValue(); - return value == null || value.isBlank() ? null : value.trim(); - } - - public record CurseForgeProject( - String id, - String slug, - String title, - String summary, - String iconUrl, - Boolean distributionAllowed, - List files - ) {} - - public record CurseForgeFile( - String id, - String displayName, - String fileName, - String versionNumber, - String releaseType, - String fileDate, - Long fileSize, - Map hashes, - List gameVersions, - Integer fileStatus, - boolean available - ) {} - - private record CachedProject(CurseForgeProject project, Instant expiresAt) {} + return isBlank(value) ? null : value.trim(); + } + + private static boolean isBlank(String value) { return value == null || value.isBlank(); } + + private static String baseName(String value) { + int slash = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')); + return slash < 0 ? value : value.substring(slash + 1); + } + + public record CurseForgeProject(String id, String slug, String title, String summary, String iconUrl, + Boolean distributionAllowed, List files, String websiteUrl, List authors, + List categories, List screenshots, String dateModified, long downloadCount, String description) { + public CurseForgeProject(String id, String slug, String title, String summary, String iconUrl, + Boolean distributionAllowed, List files) { + this(id, slug, title, summary, iconUrl, distributionAllowed, files, + null, List.of(), List.of(), List.of(), null, 0, null); + } + } + + public record CurseForgeFile(String id, String displayName, String fileName, String versionNumber, + String releaseType, String fileDate, Long fileSize, Map hashes, List gameVersions, + Integer fileStatus, boolean available, long downloadCount, Long fingerprint) { + public CurseForgeFile(String id, String displayName, String fileName, String versionNumber, String releaseType, + String fileDate, Long fileSize, Map hashes, List gameVersions, + Integer fileStatus, boolean available, long downloadCount) { + this(id, displayName, fileName, versionNumber, releaseType, fileDate, fileSize, hashes, + gameVersions, fileStatus, available, downloadCount, null); + } + public CurseForgeFile(String id, String displayName, String fileName, String versionNumber, String releaseType, + String fileDate, Long fileSize, Map hashes, List gameVersions, + Integer fileStatus, boolean available) { + this(id, displayName, fileName, versionNumber, releaseType, fileDate, fileSize, hashes, + gameVersions, fileStatus, available, 0, null); + } + } + + public record CurseForgeSearchResult(List projects, int index, int pageSize, long totalCount) {} + public record CurseForgeDownload(String downloadUrl, String fileName, Long fileSize, Map hashes) {} + public record CurseForgeArtifact(long fingerprint, String fileName) {} + public record CurseForgeFingerprintMatch(long projectId, long fileId) {} } diff --git a/backend/src/main/java/net/modtale/service/project/version/CurseForgeFingerprint.java b/backend/src/main/java/net/modtale/service/project/version/CurseForgeFingerprint.java new file mode 100644 index 00000000..ec3e721a --- /dev/null +++ b/backend/src/main/java/net/modtale/service/project/version/CurseForgeFingerprint.java @@ -0,0 +1,46 @@ +package net.modtale.service.project.version; + +import java.io.IOException; +import java.io.InputStream; + +final class CurseForgeFingerprint { + private static final int M = 0x5bd1e995; + private CurseForgeFingerprint() {} + + static long calculate(org.springframework.web.multipart.MultipartFile file) throws IOException { + int length = 0; + try (InputStream input = file.getInputStream()) { + int value; + while ((value = input.read()) >= 0) if (!whitespace(value)) length++; + } + int hash = 1 ^ length; + int block = 0; + int count = 0; + try (InputStream input = file.getInputStream()) { + int value; + while ((value = input.read()) >= 0) { + if (whitespace(value)) continue; + block |= value << (count * 8); + if (++count == 4) { + int k = block * M; + k ^= k >>> 24; + k *= M; + hash = (hash * M) ^ k; + block = 0; + count = 0; + } + } + } + if (count == 3) hash ^= block & 0x00ff0000; + if (count >= 2) hash ^= block & 0x0000ff00; + if (count >= 1) { hash ^= block & 0x000000ff; hash *= M; } + hash ^= hash >>> 13; + hash *= M; + hash ^= hash >>> 15; + return Integer.toUnsignedLong(hash); + } + + private static boolean whitespace(int value) { + return value == 0x09 || value == 0x0a || value == 0x0d || value == 0x20; + } +} diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java index e27d678e..9eaddc49 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionApplicationService.java @@ -15,7 +15,6 @@ import net.modtale.model.dto.response.project.DownloadUrlResponse; import net.modtale.model.dto.response.project.VersionDependenciesView; import net.modtale.model.project.Project; -import net.modtale.model.project.ModpackTarget; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; @@ -94,8 +93,15 @@ public DownloadUrlResponse createDownloadUrl(String projectId, String versionNum return versionDownloadOrchestrationService.createDownloadUrl(projectId, versionNumber, gameVersion, currentUser); } - public DownloadUrlResponse createDownloadUrl(String projectId, String versionNumber, String gameVersion, ModpackTarget target, User currentUser) { - return versionDownloadOrchestrationService.createDownloadUrl(projectId, versionNumber, gameVersion, target, currentUser); + public DownloadUrlResponse createDownloadUrl( + String projectId, + String versionNumber, + String gameVersion, + User currentUser, + boolean launcherClient + ) { + return versionDownloadOrchestrationService.createDownloadUrl( + projectId, versionNumber, gameVersion, currentUser, launcherClient); } public BundleDownloadUrlResponse createBundleDownloadUrl( @@ -132,6 +138,26 @@ public VersionDownloadPayload downloadVersion( ); } + public VersionDownloadPayload downloadVersion( + String token, + boolean apiRole, + String referer, + String remoteAddress, + String forwardedFor, + User currentUser, + boolean launcherClient + ) throws IOException { + return versionDownloadOrchestrationService.downloadVersion( + token, + apiRole, + referer, + remoteAddress, + forwardedFor, + currentUser, + launcherClient + ); + } + public VersionDownloadPayload downloadBundle( String token, boolean apiRole, diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java b/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java index a6e86f22..fbf47ace 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionArtifactService.java @@ -44,15 +44,23 @@ public PreparedVersionArtifact prepareVersionArtifact(Project project, Multipart boolean isModpack = effectiveClassification == ProjectClassification.MODPACK; storageService.validateUploadSize(file); + FileValidationService.ManifestInspection manifest = null; if (file != null && !file.isEmpty()) { - fileValidationService.validateProjectFile(file, effectiveClassification.name()); + manifest = fileValidationService.validateProjectFile(file, effectiveClassification.name()); } String filePath = null; String fileHash = null; + Long curseForgeFingerprint = null; if (file != null) { if (!isModpack) { fileHash = calculateSha256(file); + try { + curseForgeFingerprint = CurseForgeFingerprint.calculate(file); + } catch (java.io.IOException ex) { + throw StorageArtifactOperationException.from(ex, + "Failed to read the uploaded file while calculating its provider fingerprint."); + } Query duplicateQuery = new Query(Criteria.where("versions.hash").is(fileHash).and("deletedAt").is(null)); if (mongoTemplate.exists(duplicateQuery, Project.class)) { throw new InvalidVersionRequestException("This file has already been uploaded to Modtale."); @@ -62,7 +70,10 @@ public PreparedVersionArtifact prepareVersionArtifact(Project project, Multipart filePath = storageService.upload(file, folder); } - return new PreparedVersionArtifact(effectiveClassification, filePath, fileHash); + String manifestId = manifest == null ? null : manifest.getGroup() + ":" + manifest.getName(); + String manifestVersion = manifest == null ? null : manifest.getVersion(); + return new PreparedVersionArtifact(effectiveClassification, filePath, fileHash, manifestId, manifestVersion, + curseForgeFingerprint); } private ProjectClassification resolveClassificationForUpload(Project project, MultipartFile file) { @@ -111,6 +122,16 @@ private String calculateSha256(MultipartFile file) { } } - public record PreparedVersionArtifact(ProjectClassification classification, String filePath, String fileHash) { + public record PreparedVersionArtifact( + ProjectClassification classification, + String filePath, + String fileHash, + String manifestId, + String manifestVersion, + Long curseForgeFingerprint + ) { + public PreparedVersionArtifact(ProjectClassification classification, String filePath, String fileHash) { + this(classification, filePath, fileHash, null, null, null); + } } } diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java index d37221af..8d6125ae 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionCreationCommandHandler.java @@ -129,6 +129,9 @@ private ProjectVersion buildVersion( version.setChangelog(versionMutationOrchestrationService.sanitizeChangelog(changelog)); version.setChannel(channel); version.setHash(preparedArtifact.fileHash()); + version.setManifestId(preparedArtifact.manifestId()); + version.setManifestVersion(preparedArtifact.manifestVersion()); + version.setCurseForgeFingerprint(preparedArtifact.curseForgeFingerprint()); version.setReviewStatus(ProjectVersion.ReviewStatus.PENDING); version.setDependencies(new ArrayList<>()); version.setIncompatibleProjectIds(new ArrayList<>()); diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java b/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java index 6bd5533c..7d0d4d49 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionDependencyService.java @@ -125,7 +125,6 @@ private ProjectDependency resolveModtaleDependency( dependencyType ); dependency.setId(reference.getId()); - dependency.setEnvironment(reference.getEnvironment()); return dependency; } @@ -157,7 +156,6 @@ private ProjectDependency resolveExternalDependency( ProjectDependency.DependencyType dependencyType = reference.getDependencyType(); ProjectDependency dependency = ProjectDependency.external(source, externalId, title, versionNumber, externalUrl, dependencyType); dependency.setId(reference.getId()); - dependency.setEnvironment(reference.getEnvironment()); dependency.setExternalFileUrl(trimToNull(reference.getExternalFileUrl())); dependency.setExternalFileName(trimToNull(reference.getExternalFileName())); dependency.setExternalFileSize(reference.getExternalFileSize()); diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java index ef4dd8a4..7f584967 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java @@ -6,6 +6,7 @@ import net.modtale.exception.InvalidDownloadTokenException; import net.modtale.exception.InvalidVersionRequestException; import net.modtale.exception.ResourceNotFoundException; +import net.modtale.exception.UnauthorizedException; import net.modtale.exception.VersionNotFoundException; import net.modtale.model.dto.response.project.BundleDownloadUrlResponse; import net.modtale.model.dto.response.project.DownloadUrlResponse; @@ -13,7 +14,6 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; -import net.modtale.model.project.ModpackTarget; import net.modtale.model.user.User; import net.modtale.service.analytics.AnalyticsEligibilityService; import net.modtale.service.analytics.TrackingService; @@ -61,19 +61,23 @@ public VersionDownloadOrchestrationService( } public DownloadUrlResponse createDownloadUrl(String projectId, String versionNumber, String gameVersion, User currentUser) { - return createDownloadUrl(projectId, versionNumber, gameVersion, ModpackTarget.UNIVERSAL, currentUser); + return createDownloadUrl(projectId, versionNumber, gameVersion, currentUser, false); } - public DownloadUrlResponse createDownloadUrl(String projectId, String versionNumber, String gameVersion, ModpackTarget target, User currentUser) { + public DownloadUrlResponse createDownloadUrl( + String projectId, + String versionNumber, + String gameVersion, + User currentUser, + boolean launcherClient + ) { Project project = getProjectOrThrow(projectId, currentUser, "We couldn't find that project, so no download link could be generated."); ProjectVersion version = getVersionOrThrow(project, versionNumber, gameVersion, "We couldn't find the requested version for that project."); - ensureBrowserDownloadable(project, version); - ModpackTarget effectiveTarget = target == null ? ModpackTarget.UNIVERSAL : target; - String token = effectiveTarget == ModpackTarget.UNIVERSAL - ? downloadTokenService.generateToken(projectId, versionNumber, gameVersion) - : downloadTokenService.generateToken(projectId, versionNumber, gameVersion, null, effectiveTarget); + ensureDownloadable(project, version, launcherClient); + String token = downloadTokenService.generateToken( + projectId, versionNumber, gameVersion, null, currentUserId(currentUser)); return new DownloadUrlResponse("/download/" + token, downloadTokenService.getTokenValiditySeconds()); } @@ -88,7 +92,7 @@ public BundleDownloadUrlResponse createBundleDownloadUrl( "We couldn't find that project, so no bundle download link could be generated."); getVersionOrThrow(project, versionNumber, gameVersion, "We couldn't find the requested version for that bundle download."); - String token = downloadTokenService.generateToken(projectId, versionNumber, gameVersion, dependencies); + String token = downloadTokenService.generateToken(projectId, versionNumber, gameVersion, dependencies, currentUserId(currentUser)); return new BundleDownloadUrlResponse("/download-bundle/" + token, downloadTokenService.getTokenValiditySeconds()); } @@ -100,31 +104,37 @@ public VersionDownloadPayload downloadVersion( String forwardedFor, User currentUser ) throws IOException { - DownloadContext context = resolveDownloadContext(apiRole, referer, remoteAddress, forwardedFor, currentUser); + return downloadVersion(token, apiRole, referer, remoteAddress, forwardedFor, currentUser, false); + } + + public VersionDownloadPayload downloadVersion( + String token, + boolean apiRole, + String referer, + String remoteAddress, + String forwardedFor, + User currentUser, + boolean launcherClient + ) throws IOException { DownloadTokenService.DownloadToken downloadToken = validateToken(token, "This download link is invalid, expired, or has already been used."); + DownloadContext context = resolveDownloadContext(downloadToken, apiRole, referer, remoteAddress, forwardedFor, currentUser); Project project = getRawProjectOrThrow(downloadToken.getProjectId(), "We couldn't find the project for this download link."); - ensureReadable(project, currentUser); + ensureReadable(project, context.currentUser()); ProjectVersion targetVersion = getVersionOrThrow(project, downloadToken.getVersion(), downloadToken.getGameVersion(), "We couldn't find the version requested by this download link."); - ensureBrowserDownloadable(project, targetVersion); + ensureDownloadable(project, targetVersion, launcherClient); trackDownload(project, targetVersion.getId(), context); if (project.getClassification() == ProjectClassification.MODPACK) { - ModpackTarget target = downloadToken.getModpackTarget() == null - ? ModpackTarget.UNIVERSAL - : downloadToken.getModpackTarget(); if (targetVersion.getDependencies() != null) { targetVersion.getDependencies().stream() - .filter(dependency -> includedInTarget(dependency, target)) .forEach(dep -> trackDependencyDownload(dep, context)); } - byte[] zipData = target == ModpackTarget.UNIVERSAL - ? downloadService.generateModpackZip(project, targetVersion, context.currentUser()) - : downloadService.generateModpackZip(project, targetVersion, context.currentUser(), target); - return new VersionDownloadPayload(buildModpackFilename(project, targetVersion, target), zipData); + byte[] zipData = downloadService.generateModpackZip(project, targetVersion, context.currentUser()); + return new VersionDownloadPayload(buildModpackFilename(project, targetVersion), zipData); } byte[] data = storageService.download(targetVersion.getFileUrl()); @@ -139,12 +149,12 @@ public VersionDownloadPayload downloadBundle( String forwardedFor, User currentUser ) throws IOException { - DownloadContext context = resolveDownloadContext(apiRole, referer, remoteAddress, forwardedFor, currentUser); DownloadTokenService.DownloadToken downloadToken = validateToken(token, "This bundle download link is invalid, expired, or has already been used."); + DownloadContext context = resolveDownloadContext(downloadToken, apiRole, referer, remoteAddress, forwardedFor, currentUser); Project project = getRawProjectOrThrow(downloadToken.getProjectId(), "We couldn't find the project for this bundle download link."); - ensureReadable(project, currentUser); + ensureReadable(project, context.currentUser()); ProjectVersion targetVersion = getVersionOrThrow(project, downloadToken.getVersion(), downloadToken.getGameVersion(), "We couldn't find the version requested by this bundle download link."); @@ -170,15 +180,17 @@ public VersionDownloadPayload downloadBundle( } private DownloadContext resolveDownloadContext( + DownloadTokenService.DownloadToken downloadToken, boolean apiRole, String referer, String remoteAddress, String forwardedFor, User currentUser ) { + User effectiveUser = requireTokenUser(downloadToken, currentUser); boolean apiRequest = apiRole || referer == null || !referer.startsWith(frontendUrl); String clientIp = forwardedFor == null ? remoteAddress : forwardedFor.split(",")[0].trim(); - return new DownloadContext(apiRequest, clientIp, currentUser); + return new DownloadContext(apiRequest, clientIp, effectiveUser); } private DownloadTokenService.DownloadToken validateToken(String token, String failureMessage) { @@ -189,6 +201,22 @@ private DownloadTokenService.DownloadToken validateToken(String token, String fa return downloadToken; } + private User requireTokenUser(DownloadTokenService.DownloadToken downloadToken, User currentUser) { + String tokenUserId = downloadToken.getUserId(); + if (tokenUserId == null || tokenUserId.isBlank()) { + return currentUser; + } + + if (currentUser == null || currentUser.getId() == null || !tokenUserId.equals(currentUser.getId())) { + throw new UnauthorizedException("Sign in with the account that created this download link before using it."); + } + return currentUser; + } + + private String currentUserId(User currentUser) { + return currentUser == null ? null : currentUser.getId(); + } + private Project getProjectOrThrow(String projectId, User currentUser, String failureMessage) { Project project = projectService.getProjectById(projectId, currentUser); if (project == null) { @@ -216,15 +244,12 @@ private void ensureReadable(Project project, User currentUser) { } } - private void ensureBrowserDownloadable(Project project, ProjectVersion version) { - if (project.getClassification() != ProjectClassification.MODPACK - || version.getDependencies() == null) { - return; - } - boolean containsCurseForge = version.getDependencies().stream() - .anyMatch(dependency -> dependency != null - && dependency.getSource() == ProjectDependency.Source.CURSEFORGE); - if (containsCurseForge) { + private void ensureDownloadable(Project project, ProjectVersion version, boolean launcherClient) { + if (!launcherClient + && project.getClassification() == ProjectClassification.MODPACK + && version.getDependencies() != null + && version.getDependencies().stream() + .anyMatch(dependency -> dependency.getSource() == ProjectDependency.Source.CURSEFORGE)) { throw new InvalidVersionRequestException( "This modpack contains CurseForge projects and can only be installed with Modtale Launcher." ); @@ -254,13 +279,8 @@ private void trackDependencyDownload(ProjectDependency dependency, DownloadConte } } - private boolean includedInTarget(ProjectDependency dependency, ModpackTarget target) { - return target.includes(dependency.getEnvironment()); - } - - private String buildModpackFilename(Project project, ProjectVersion version, ModpackTarget target) { - String suffix = target == ModpackTarget.UNIVERSAL ? "" : "-" + target.name().toLowerCase(); - return sanitizeProjectName(project.getTitle()) + "-" + version.getVersionNumber() + suffix + ".zip"; + private String buildModpackFilename(Project project, ProjectVersion version) { + return sanitizeProjectName(project.getTitle()) + "-" + version.getVersionNumber() + ".zip"; } private String sanitizeProjectName(String title) { diff --git a/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java b/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java index 44e1c295..3443d1c5 100644 --- a/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java +++ b/backend/src/main/java/net/modtale/service/social/ProjectSocialService.java @@ -158,7 +158,7 @@ void setCommentPinned(String projectId, String commentId, boolean pinned) { } private Project getProject(String projectId) { - Project project = projectService.getRawProjectById(projectId); + Project project = projectService.getRawProjectByRouteKey(projectId); if (project == null) { throw new ResourceNotFoundException("Project not found."); } diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadService.java b/backend/src/main/java/net/modtale/service/storage/DownloadService.java index 46a2c11d..32318516 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadService.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadService.java @@ -4,7 +4,6 @@ import java.util.List; import net.modtale.config.properties.AppLimitProperties; import net.modtale.model.project.Project; -import net.modtale.model.project.ModpackTarget; import net.modtale.model.project.ProjectVersion; import net.modtale.model.user.User; import net.modtale.repository.project.ProjectRepository; @@ -31,12 +30,8 @@ public DownloadService( } public byte[] generateModpackZip(Project pack, ProjectVersion version, User user) throws IOException { - return generateModpackZip(pack, version, user, ModpackTarget.UNIVERSAL); - } - - public byte[] generateModpackZip(Project pack, ProjectVersion version, User user, ModpackTarget target) throws IOException { rateLimitService.consumeModpackGeneration(user); - return modpackArchiveService.generateModpackZip(pack, version, target); + return modpackArchiveService.generateModpackZip(pack, version); } public byte[] generateBundleZip(Project mainProject, ProjectVersion mainVersion, List selectedDependencies, User user) throws IOException { diff --git a/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java b/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java index 492a132b..fcb68217 100644 --- a/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java +++ b/backend/src/main/java/net/modtale/service/storage/DownloadTokenService.java @@ -6,7 +6,6 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import net.modtale.model.project.ModpackTarget; import org.springframework.stereotype.Service; @Service @@ -22,21 +21,21 @@ public static class DownloadToken { private final String projectId; private final String version; private final String gameVersion; + private final String userId; private final Instant expiresAt; private final List selectedDependencies; - private final ModpackTarget modpackTarget; private boolean used; public DownloadToken(String projectId, String version, String gameVersion, List selectedDependencies, Instant expiresAt) { this(projectId, version, gameVersion, selectedDependencies, null, expiresAt); } - public DownloadToken(String projectId, String version, String gameVersion, List selectedDependencies, ModpackTarget modpackTarget, Instant expiresAt) { + public DownloadToken(String projectId, String version, String gameVersion, List selectedDependencies, String userId, Instant expiresAt) { this.projectId = projectId; this.version = version; this.gameVersion = gameVersion; - this.selectedDependencies = selectedDependencies; - this.modpackTarget = modpackTarget; + this.selectedDependencies = selectedDependencies == null ? null : List.copyOf(selectedDependencies); + this.userId = userId; this.expiresAt = expiresAt; this.used = false; } @@ -44,14 +43,14 @@ public DownloadToken(String projectId, String version, String gameVersion, List< public String getProjectId() { return projectId; } public String getVersion() { return version; } public String getGameVersion() { return gameVersion; } + public String getUserId() { return userId; } public List getSelectedDependencies() { return selectedDependencies; } - public ModpackTarget getModpackTarget() { return modpackTarget; } public Instant getExpiresAt() { return expiresAt; } public boolean isUsed() { return used; } public void markAsUsed() { this.used = true; } public boolean isExpired() { - return Instant.now().isAfter(expiresAt); + return !Instant.now().isBefore(expiresAt); } } @@ -59,7 +58,7 @@ public String generateToken(String projectId, String version, String gameVersion return generateToken(projectId, version, gameVersion, selectedDependencies, null); } - public String generateToken(String projectId, String version, String gameVersion, List selectedDependencies, ModpackTarget modpackTarget) { + public String generateToken(String projectId, String version, String gameVersion, List selectedDependencies, String userId) { cleanExpiredTokens(); byte[] randomBytes = new byte[TOKEN_LENGTH]; @@ -67,7 +66,7 @@ public String generateToken(String projectId, String version, String gameVersion String token = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); Instant expiresAt = Instant.now().plusSeconds(TOKEN_VALIDITY_MINUTES * 60); - tokens.put(token, new DownloadToken(projectId, version, gameVersion, selectedDependencies, modpackTarget, expiresAt)); + tokens.put(token, new DownloadToken(projectId, version, gameVersion, selectedDependencies, userId, expiresAt)); return token; } @@ -81,24 +80,15 @@ public String generateToken(String projectId, String version) { } public DownloadToken validateAndConsume(String token) { - DownloadToken downloadToken = tokens.get(token); - - if (downloadToken == null) { + if (token == null || token.isBlank()) { return null; } - - if (downloadToken.isExpired()) { - tokens.remove(token); + // Removal is the atomic claim: concurrent requests cannot both consume it. + DownloadToken downloadToken = tokens.remove(token); + if (downloadToken == null || downloadToken.isExpired() || downloadToken.isUsed()) { return null; } - - if (downloadToken.isUsed()) { - return null; - } - downloadToken.markAsUsed(); - tokens.remove(token); - return downloadToken; } diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java index 2790eabb..9bce6d9e 100644 --- a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java +++ b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveService.java @@ -23,7 +23,6 @@ import net.modtale.exception.StorageDownloadException; import net.modtale.exception.StorageUploadException; import net.modtale.model.project.Project; -import net.modtale.model.project.ModpackTarget; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; import net.modtale.repository.project.ProjectRepository; @@ -49,20 +48,13 @@ final class ModpackArchiveService { } byte[] generateModpackZip(Project pack, ProjectVersion version) throws IOException { - return generateModpackZip(pack, version, ModpackTarget.UNIVERSAL); - } - - byte[] generateModpackZip(Project pack, ProjectVersion version, ModpackTarget target) throws IOException { - ModpackTarget effectiveTarget = target == null ? ModpackTarget.UNIVERSAL : target; - byte[] cachedArchive = effectiveTarget == ModpackTarget.UNIVERSAL ? downloadCachedArchive(pack, version) : null; + byte[] cachedArchive = downloadCachedArchive(pack, version); if (cachedArchive != null) { return cachedArchive; } - byte[] zipBytes = buildArchive(pack, version, effectiveTarget); - if (effectiveTarget == ModpackTarget.UNIVERSAL) { - cacheArchive(pack, version, zipBytes); - } + byte[] zipBytes = buildArchive(pack, version); + cacheArchive(pack, version, zipBytes); return zipBytes; } @@ -96,14 +88,14 @@ private byte[] downloadCachedArchive(Project pack, ProjectVersion version) { } } - private byte[] buildArchive(Project pack, ProjectVersion version, ModpackTarget target) throws IOException { - List preparedDependencies = prepareDependencies(version, target); - List overrides = prepareOverrides(version, target); + private byte[] buildArchive(Project pack, ProjectVersion version) throws IOException { + List preparedDependencies = prepareDependencies(version); + List overrides = prepareOverrides(version); ByteArrayOutputStream output = new ByteArrayOutputStream(); try (ZipOutputStream zip = new ZipOutputStream(output)) { - writeJsonEntry(zip, LEGACY_MANIFEST, legacyManifest(pack, version, target)); - writeJsonEntry(zip, MANIFEST, authorManifest(pack, version, target)); - writeJsonEntry(zip, LOCKFILE, lockfile(pack, version, preparedDependencies, overrides, target)); + writeJsonEntry(zip, LEGACY_MANIFEST, legacyManifest(pack, version)); + writeJsonEntry(zip, MANIFEST, authorManifest(pack, version)); + writeJsonEntry(zip, LOCKFILE, lockfile(pack, version, preparedDependencies, overrides)); for (PreparedDependency prepared : preparedDependencies) { if (prepared.bytes() != null) { writeBinaryEntry(zip, prepared.path(), prepared.bytes()); @@ -118,7 +110,7 @@ private byte[] buildArchive(Project pack, ProjectVersion version, ModpackTarget return archive; } - private List prepareOverrides(ProjectVersion version, ModpackTarget target) throws IOException { + private List prepareOverrides(ProjectVersion version) throws IOException { String overrideFileUrl = trimToNull(version.getOverrideFileUrl()); if (overrideFileUrl == null) return List.of(); byte[] archive; @@ -127,12 +119,10 @@ private List prepareOverrides(ProjectVersio } catch (StorageDownloadException ex) { throw new IOException("Cannot download the modpack override bundle.", ex); } - return ModpackOverrideArchive.read(new ByteArrayInputStream(archive)).stream() - .filter(file -> target.includes(file.environment())) - .toList(); + return ModpackOverrideArchive.read(new ByteArrayInputStream(archive)); } - private List prepareDependencies(ProjectVersion version, ModpackTarget target) throws IOException { + private List prepareDependencies(ProjectVersion version) throws IOException { if (version.getDependencies() == null) { return List.of(); } @@ -143,9 +133,6 @@ private List prepareDependencies(ProjectVersion version, Mod archiveKeys.add(LOCKFILE.toLowerCase(Locale.ROOT)); List prepared = new ArrayList<>(); for (ProjectDependency dependency : version.getDependencies()) { - if (!includedInTarget(dependency, target)) { - continue; - } prepared.add(dependency.isExternal() ? prepareExternalDependency(dependency, archiveKeys) : prepareModtaleDependency(dependency, archiveKeys)); @@ -204,39 +191,34 @@ private PreparedDependency prepareExternalDependency( } } - private Map legacyManifest(Project pack, ProjectVersion version, ModpackTarget target) { + private Map legacyManifest(Project pack, ProjectVersion version) { Map manifest = packIdentity(pack, version); manifest.put("name", pack.getTitle()); manifest.put("formatVersion", 1); manifest.put("game", "hytale"); - manifest.put("target", target.name()); - manifest.put("files", authorDependencies(version, target)); + manifest.put("files", authorDependencies(version)); return manifest; } - private Map authorManifest(Project pack, ProjectVersion version, ModpackTarget target) { + private Map authorManifest(Project pack, ProjectVersion version) { Map manifest = new LinkedHashMap<>(); manifest.put("format", "modtale-pack"); manifest.put("schemaVersion", 1); manifest.put("pack", packIdentity(pack, version)); - manifest.put("target", target.name()); Map game = new LinkedHashMap<>(); game.put("id", "hytale"); game.put("versions", version.getGameVersions() == null ? List.of() : version.getGameVersions()); manifest.put("game", game); - manifest.put("dependencies", authorDependencies(version, target)); + manifest.put("dependencies", authorDependencies(version)); return manifest; } - private List> authorDependencies(ProjectVersion version, ModpackTarget target) { + private List> authorDependencies(ProjectVersion version) { if (version.getDependencies() == null) { return List.of(); } List> dependencies = new ArrayList<>(); for (ProjectDependency dependency : version.getDependencies()) { - if (!includedInTarget(dependency, target)) { - continue; - } Map item = baseDependency(dependency); if (dependency.isExternal()) { putIfPresent(item, "externalId", dependency.getExternalId()); @@ -254,14 +236,13 @@ private Map lockfile( Project pack, ProjectVersion version, List preparedDependencies, - List overrides, - ModpackTarget target + List overrides ) { Map lock = new LinkedHashMap<>(); lock.put("format", "modtale-lock"); lock.put("lockVersion", 1); + lock.put("game", "hytale"); lock.put("pack", packIdentity(pack, version)); - lock.put("target", target.name()); lock.put("gameVersions", version.getGameVersions() == null ? List.of() : version.getGameVersions()); List> entries = new ArrayList<>(); @@ -317,7 +298,6 @@ private Map lockfile( for (ModpackOverrideArchive.OverrideFile override : overrides) { Map item = new LinkedHashMap<>(); item.put("path", override.path()); - item.put("environment", override.environment().name()); item.put("size", override.bytes().length); item.put("hashes", Map.of("sha256", sha256(override.bytes()))); overrideEntries.add(item); @@ -326,10 +306,6 @@ private Map lockfile( return lock; } - private boolean includedInTarget(ProjectDependency dependency, ModpackTarget target) { - return target.includes(dependency.getEnvironment()); - } - private Map packIdentity(Project pack, ProjectVersion version) { Map identity = new LinkedHashMap<>(); identity.put("packId", nullToEmpty(pack.getId())); @@ -346,7 +322,6 @@ private Map baseDependency(ProjectDependency dependency) { item.put("version", nullToEmpty(dependency.getVersionNumber())); item.put("source", dependency.getSource().name()); item.put("dependencyType", dependency.getDependencyType().name()); - item.put("environment", dependency.getEnvironment().name()); return item; } diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveValidator.java b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveValidator.java index 5e09fe48..8e644def 100644 --- a/backend/src/main/java/net/modtale/service/storage/ModpackArchiveValidator.java +++ b/backend/src/main/java/net/modtale/service/storage/ModpackArchiveValidator.java @@ -104,25 +104,23 @@ private static void validateMetadata( JsonNode manifest = readJson(metadata.get("manifest.json"), "Modpack manifest"); if (!manifest.isObject() || !"modtale-pack".equals(manifest.path("format").asText()) || manifest.path("schemaVersion").asInt(-1) != 1 || !manifest.path("pack").isObject() - || !manifest.path("game").isObject() || !manifest.path("dependencies").isArray()) { + || !manifest.path("game").isObject() + || !"hytale".equals(manifest.path("game").path("id").asText()) + || !manifest.path("game").path("versions").isArray() + || !manifest.path("dependencies").isArray()) { throw new IOException("Modpack manifest has an unsupported format."); } - for (JsonNode item : manifest.path("dependencies")) { - validateEnvironment(item); - } JsonNode lock = readJson(metadata.get("modtale.lock.json"), "Modpack lockfile"); if (lock == null || !"modtale-lock".equals(lock.path("format").asText()) - || lock.path("lockVersion").asInt(-1) != 1 || !lock.path("pack").isObject() + || lock.path("lockVersion").asInt(-1) != 1 || !"hytale".equals(lock.path("game").asText()) + || !lock.path("pack").isObject() || !lock.path("gameVersions").isArray() || !lock.path("entries").isArray()) { throw new IOException("Modpack lockfile has an unsupported format."); } - validateTargets(legacy, manifest, lock); - Set expectedFiles = new HashSet<>(METADATA_FILES); for (JsonNode item : lock.path("entries")) { String distribution = item.path("distribution").asText(); String source = item.path("source").asText(); - validateEnvironment(item); if ("REFERENCE_ONLY".equals(distribution)) { if (item.has("path") || item.has("size") || item.has("hashes")) { throw new IOException("Reference-only lock entries must not claim bundled bytes."); @@ -160,10 +158,9 @@ private static void validateMetadata( throw new IOException("Modpack lockfile overrides must be an array."); } for (JsonNode item : lock.path("overrides")) { - validateEnvironment(item); String path = validatePath(item.path("path").asText(null)); - String expectedPrefix = "overrides/" + item.path("environment").asText().toLowerCase(Locale.ROOT) + "/"; - if (!path.toLowerCase(Locale.ROOT).startsWith(expectedPrefix) || !expectedFiles.add(path)) { + if (!(path.startsWith("overrides/Mods/") || path.startsWith("overrides/Saves/")) + || !expectedFiles.add(path)) { throw new IOException("Modpack override has an invalid or duplicate path: " + path); } EntryFingerprint actual = archiveEntries.get(path); @@ -180,26 +177,6 @@ private static void validateMetadata( } } - private static void validateEnvironment(JsonNode item) throws IOException { - if (!Set.of("COMMON", "CLIENT", "SERVER").contains(item.path("environment").asText())) { - throw new IOException("Modpack entry has an unknown environment."); - } - } - - private static void validateTargets(JsonNode legacy, JsonNode manifest, JsonNode lock) throws IOException { - String manifestTarget = manifest.path("target").asText(""); - String lockTarget = lock.path("target").asText(""); - String legacyTarget = legacy.path("target").asText(""); - if (manifestTarget.isBlank() && lockTarget.isBlank() && legacyTarget.isBlank()) { - return; - } - if (!Set.of("UNIVERSAL", "CLIENT", "SERVER").contains(manifestTarget) - || !manifestTarget.equals(lockTarget) - || !manifestTarget.equals(legacyTarget)) { - throw new IOException("Modpack metadata has an unknown or inconsistent target."); - } - } - private static void validateCurseForgeReference(JsonNode item) throws IOException { JsonNode provider = item.path("provider"); String projectId = provider.path("projectId").asText(); diff --git a/backend/src/main/java/net/modtale/service/storage/ModpackOverrideArchive.java b/backend/src/main/java/net/modtale/service/storage/ModpackOverrideArchive.java index 6cb7c84b..49142685 100644 --- a/backend/src/main/java/net/modtale/service/storage/ModpackOverrideArchive.java +++ b/backend/src/main/java/net/modtale/service/storage/ModpackOverrideArchive.java @@ -9,7 +9,6 @@ import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; -import net.modtale.model.project.ProjectDependency; public final class ModpackOverrideArchive { private static final int MAX_FILES = 10_000; @@ -45,7 +44,7 @@ public static List read(InputStream source) throws IOException { if (bytes.length > MAX_FILE_SIZE) throw new IOException("An override file exceeds the 32 MiB limit."); total += bytes.length; if (total > MAX_TOTAL_SIZE) throw new IOException("Override bundle exceeds the 512 MiB expanded limit."); - files.add(new OverrideFile(parsed.path(), parsed.environment(), bytes)); + files.add(new OverrideFile(parsed.path(), bytes)); } } if (files.isEmpty()) throw new IOException("Override bundle does not contain any files."); @@ -63,20 +62,26 @@ private static ParsedPath parsePath(String raw) throws IOException { } } String lower = path.toLowerCase(Locale.ROOT); - ProjectDependency.Environment environment; - String prefix; - if (lower.startsWith("overrides/common/")) { - environment = ProjectDependency.Environment.COMMON; - prefix = "overrides/common/"; - } else if (lower.startsWith("overrides/client/")) { - environment = ProjectDependency.Environment.CLIENT; - prefix = "overrides/client/"; - } else if (lower.startsWith("overrides/server/")) { - environment = ProjectDependency.Environment.SERVER; - prefix = "overrides/server/"; + String prefix = "overrides/"; + if (!lower.startsWith(prefix)) { + throw new IOException("Override files must be inside overrides/."); } - else throw new IOException("Override files must be inside overrides/common, overrides/client, or overrides/server."); String relative = path.substring(prefix.length()); + if (relative.isBlank()) { + throw new IOException("Override bundle contains an empty override path."); + } + String[] relativeSegments = relative.split("/", -1); + String hytaleRoot; + if ("mods".equalsIgnoreCase(relativeSegments[0])) { + hytaleRoot = "Mods"; + } else if ("saves".equalsIgnoreCase(relativeSegments[0])) { + hytaleRoot = "Saves"; + } else { + throw new IOException("Override files must be inside overrides/Mods/ or overrides/Saves/."); + } + if (relativeSegments.length < 2) { + throw new IOException("Override bundle contains an empty Hytale destination path."); + } for (String segment : relative.split("/")) { if (segment.matches(".*[<>:\"|?*\\p{Cntrl}].*") || segment.endsWith(".") || segment.endsWith(" ")) { throw new IOException("Override bundle contains a non-portable path."); @@ -89,9 +94,10 @@ private static ParsedPath parsePath(String raw) throws IOException { if (BLOCKED_EXTENSIONS.stream().anyMatch(lower::endsWith)) { throw new IOException("Override bundle contains a blocked executable, script, or nested archive."); } - return new ParsedPath(prefix + relative, environment); + return new ParsedPath(prefix + hytaleRoot + "/" + String.join("/", + java.util.Arrays.copyOfRange(relativeSegments, 1, relativeSegments.length))); } - private record ParsedPath(String path, ProjectDependency.Environment environment) {} - public record OverrideFile(String path, ProjectDependency.Environment environment, byte[] bytes) {} + private record ParsedPath(String path) {} + public record OverrideFile(String path, byte[] bytes) {} } diff --git a/backend/src/main/java/net/modtale/service/user/account/AccountService.java b/backend/src/main/java/net/modtale/service/user/account/AccountService.java index e67b2709..d818aaeb 100644 --- a/backend/src/main/java/net/modtale/service/user/account/AccountService.java +++ b/backend/src/main/java/net/modtale/service/user/account/AccountService.java @@ -7,6 +7,7 @@ import net.modtale.exception.InvalidAccountRequestException; import net.modtale.exception.ResourceNotFoundException; import net.modtale.model.project.Project; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.OAuthProvider; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; @@ -26,6 +27,11 @@ @Service public class AccountService { + private static final int MAX_LAUNCHER_SYNC_PROJECTS = 500; + private static final int MAX_LAUNCHER_SYNC_LIST_ITEMS = 64; + private static final int MAX_LAUNCHER_SYNC_STRING = 512; + private static final int MAX_LAUNCHER_SYNC_HASH = 128; + private final UserRepository userRepository; private final MongoTemplate mongoTemplate; private final SanitizationService sanitizer; @@ -161,6 +167,27 @@ public void updateNotificationPreferences(String userId, User.NotificationPrefer userRepository.save(user); } + public LauncherSettingsSnapshot getLauncherSettings(String userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + return user.getLauncherSettings() == null ? new LauncherSettingsSnapshot() : user.getLauncherSettings(); + } + + public LauncherSettingsSnapshot updateLauncherSettings(String userId, LauncherSettingsSnapshot snapshot) { + User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + LauncherSettingsSnapshot normalized = normalizeLauncherSettings(snapshot); + user.setLauncherSettings(normalized); + userRepository.save(user); + return normalized; + } + + public LauncherSettingsSnapshot updateLauncherSettingsPreferences(String userId, LauncherSettingsSnapshot snapshot) { + User user = userRepository.findById(userId).orElseThrow(() -> new ResourceNotFoundException("User not found.")); + LauncherSettingsSnapshot normalized = normalizeLauncherSettingsPreferences(snapshot, user.getLauncherSettings()); + user.setLauncherSettings(normalized); + userRepository.save(user); + return normalized; + } + public void toggleConnectionVisibility(String userId, String provider) { if ("google".equalsIgnoreCase(provider) || "hytale".equalsIgnoreCase(provider)) { throw new InvalidAccountRequestException(provider + " accounts cannot be made visible on public profiles."); @@ -214,6 +241,145 @@ public void recoverUser(String userId) { accountLifecycleService.recoverUser(userId); } + private LauncherSettingsSnapshot normalizeLauncherSettings(LauncherSettingsSnapshot snapshot) { + LauncherSettingsSnapshot source = snapshot == null ? new LauncherSettingsSnapshot() : snapshot; + LauncherSettingsSnapshot normalized = new LauncherSettingsSnapshot(); + normalized.setSchemaVersion(source.getSchemaVersion()); + normalized.setSettingsHash(limit(source.getSettingsHash(), MAX_LAUNCHER_SYNC_HASH)); + normalized.setUpdatedAt(LocalDateTime.now().toString()); + normalized.setPreferences(normalizeLauncherPreferences(source.getPreferences())); + normalized.setInstalledProjects(normalizeInstalledProjects(source.getInstalledProjects())); + return normalized; + } + + private LauncherSettingsSnapshot normalizeLauncherSettingsPreferences( + LauncherSettingsSnapshot snapshot, + LauncherSettingsSnapshot existing + ) { + LauncherSettingsSnapshot source = snapshot == null ? new LauncherSettingsSnapshot() : snapshot; + LauncherSettingsSnapshot stored = existing == null ? new LauncherSettingsSnapshot() : existing; + LauncherSettingsSnapshot normalized = new LauncherSettingsSnapshot(); + normalized.setSchemaVersion(source.getSchemaVersion()); + normalized.setSettingsHash(limit(source.getSettingsHash(), MAX_LAUNCHER_SYNC_HASH)); + normalized.setUpdatedAt(LocalDateTime.now().toString()); + normalized.setPreferences(normalizeLauncherPreferences(source.getPreferences())); + normalized.setInstalledProjects(normalizeInstalledProjects(stored.getInstalledProjects())); + return normalized; + } + + private LauncherSettingsSnapshot.Preferences normalizeLauncherPreferences(LauncherSettingsSnapshot.Preferences source) { + LauncherSettingsSnapshot.Preferences preferences = new LauncherSettingsSnapshot.Preferences(); + if (source == null) { + return preferences; + } + preferences.setHytaleModsPath(limit(source.getHytaleModsPath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleGamePath(limit(source.getHytaleGamePath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleUserDataPath(limit(source.getHytaleUserDataPath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleJavaPath(limit(source.getHytaleJavaPath(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleBranch(limit(source.getHytaleBranch(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setHytaleBuild(source.getHytaleBuild()); + preferences.setGameVersion(limit(source.getGameVersion(), MAX_LAUNCHER_SYNC_STRING)); + preferences.setIncludeDependencies(source.isIncludeDependencies()); + preferences.setIncludeOptionalDependencies(source.isIncludeOptionalDependencies()); + preferences.setAutoCheckUpdates(source.isAutoCheckUpdates()); + preferences.setLauncherAutoUpdates(source.isLauncherAutoUpdates()); + return preferences; + } + + private List normalizeInstalledProjects( + List source + ) { + if (source == null || source.isEmpty()) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (LauncherSettingsSnapshot.InstalledProject project : source) { + if (project == null || isBlank(project.getProjectId()) || normalized.size() >= MAX_LAUNCHER_SYNC_PROJECTS) { + continue; + } + LauncherSettingsSnapshot.InstalledProject copy = new LauncherSettingsSnapshot.InstalledProject(); + copy.setProjectId(limit(project.getProjectId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSlug(limit(project.getSlug(), MAX_LAUNCHER_SYNC_STRING)); + copy.setTitle(limit(project.getTitle(), MAX_LAUNCHER_SYNC_STRING)); + copy.setClassification(limit(project.getClassification(), MAX_LAUNCHER_SYNC_STRING)); + copy.setInstalledVersion(limit(project.getInstalledVersion(), MAX_LAUNCHER_SYNC_STRING)); + copy.setInstalledVersionId(limit(project.getInstalledVersionId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setGameVersion(limit(project.getGameVersion(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSource(defaultValue(limit(project.getSource(), MAX_LAUNCHER_SYNC_STRING), "MODTALE")); + copy.setInstallType(defaultValue(limit(project.getInstallType(), MAX_LAUNCHER_SYNC_STRING), "DIRECT")); + copy.setModpackUnlocked(project.isModpackUnlocked()); + copy.setDependencyProjectIds(normalizeStringList(project.getDependencyProjectIds())); + copy.setExternalDependencies(normalizeStringList(project.getExternalDependencies())); + copy.setBundledProjects(normalizeInstalledProjectReferences(project.getBundledProjects())); + normalized.add(copy); + } + return normalized; + } + + private List normalizeInstalledProjectReferences( + List source + ) { + if (source == null || source.isEmpty()) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (LauncherSettingsSnapshot.InstalledProjectReference reference : source) { + if (reference == null || normalized.size() >= MAX_LAUNCHER_SYNC_LIST_ITEMS) { + continue; + } + LauncherSettingsSnapshot.InstalledProjectReference copy = + new LauncherSettingsSnapshot.InstalledProjectReference(); + copy.setId(limit(reference.getId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setProjectId(limit(reference.getProjectId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSlug(limit(reference.getSlug(), MAX_LAUNCHER_SYNC_STRING)); + copy.setTitle(limit(reference.getTitle(), MAX_LAUNCHER_SYNC_STRING)); + copy.setClassification(limit(reference.getClassification(), MAX_LAUNCHER_SYNC_STRING)); + copy.setVersionNumber(limit(reference.getVersionNumber(), MAX_LAUNCHER_SYNC_STRING)); + copy.setDependencyType(limit(reference.getDependencyType(), MAX_LAUNCHER_SYNC_STRING)); + copy.setSource(limit(reference.getSource(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalId(limit(reference.getExternalId(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalUrl(limit(reference.getExternalUrl(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalFileUrl(limit(reference.getExternalFileUrl(), MAX_LAUNCHER_SYNC_STRING)); + copy.setExternalFileName(limit(reference.getExternalFileName(), MAX_LAUNCHER_SYNC_STRING)); + copy.setCachedFileUrl(limit(reference.getCachedFileUrl(), MAX_LAUNCHER_SYNC_STRING)); + copy.setIcon(limit(reference.getIcon(), MAX_LAUNCHER_SYNC_STRING)); + copy.setOptional(reference.getOptional()); + copy.setEmbedded(reference.getEmbedded()); + normalized.add(copy); + } + return normalized; + } + + private List normalizeStringList(List source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + List normalized = new ArrayList<>(); + for (String value : source) { + String next = limit(value, MAX_LAUNCHER_SYNC_STRING); + if (!next.isBlank() && !normalized.contains(next)) { + normalized.add(next); + } + if (normalized.size() >= MAX_LAUNCHER_SYNC_LIST_ITEMS) { + break; + } + } + return normalized; + } + + private static String limit(String value, int maxLength) { + String trimmed = value == null ? "" : value.trim(); + return trimmed.length() <= maxLength ? trimmed : trimmed.substring(0, maxLength); + } + + private static String defaultValue(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + @Scheduled(cron = "0 0 0 * * ?") public void cleanupDeletedUsers() { accountLifecycleService.cleanupDeletedUsers(); diff --git a/backend/src/main/java/net/modtale/service/user/account/CurrentUserResolutionService.java b/backend/src/main/java/net/modtale/service/user/account/CurrentUserResolutionService.java index 34604623..6ff4fa84 100644 --- a/backend/src/main/java/net/modtale/service/user/account/CurrentUserResolutionService.java +++ b/backend/src/main/java/net/modtale/service/user/account/CurrentUserResolutionService.java @@ -47,9 +47,8 @@ public User resolveCurrentUser(Authentication authentication) { if (userId != null) { User user = userRepository.findById(userId).orElse(null); - if (user != null && !user.isDeleted()) { - return user; - } + // A stable ID must never fall back to a reused username. + return user != null && !user.isDeleted() ? user : null; } if (username != null) { diff --git a/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java b/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java index 77c3b765..5a03eb36 100644 --- a/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java +++ b/backend/src/main/java/net/modtale/service/user/account/OAuthAvatarHealingService.java @@ -125,7 +125,7 @@ private String refreshAvatarFromLinkedProvider(User user) { } } default -> { - // No reliable server-side refresh path for this provider without stored tokens. + // No reliable backend refresh path for this provider without stored tokens. } } } catch (RuntimeException ex) { diff --git a/backend/src/main/java/net/modtale/service/worldlist/WorldModListArchiveService.java b/backend/src/main/java/net/modtale/service/worldlist/WorldModListArchiveService.java new file mode 100644 index 00000000..72b9c23d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/worldlist/WorldModListArchiveService.java @@ -0,0 +1,115 @@ +package net.modtale.service.worldlist; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import net.modtale.exception.StorageDownloadException; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.service.storage.StorageService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; + +@Service +public class WorldModListArchiveService { + + private static final Logger logger = LoggerFactory.getLogger(WorldModListArchiveService.class); + private final StorageService storageService; + private final ObjectWriter manifestWriter; + + public WorldModListArchiveService(StorageService storageService, ObjectMapper mapper) { + this.storageService = storageService; + this.manifestWriter = mapper.writerWithDefaultPrettyPrinter(); + } + + public byte[] generateZip(WorldModList list) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + Set entries = new HashSet<>(); + writeEntry(zip, entries, "modtale-list.json", manifestWriter.writeValueAsBytes(list)); + writeEntry(zip, entries, "README.txt", readme(list).getBytes(StandardCharsets.UTF_8)); + + for (WorldModList.Item item : list.getMods()) { + if (!item.isDownloadable() || item.getFileUrl() == null || item.getFileUrl().isBlank()) { + continue; + } + try { + byte[] file = storageService.download(item.getFileUrl()); + writeEntry(zip, entries, filename(item), file); + } catch (StorageDownloadException ex) { + logger.warn("Skipping unavailable world list file {} for list {}", item.getFileUrl(), list.getId(), ex); + } + } + } + return bytes.toByteArray(); + } + + private void writeEntry(ZipOutputStream zip, Set entries, String rawName, byte[] data) throws IOException { + String entryName = unique(entries, sanitize(rawName)); + zip.putNextEntry(new ZipEntry(entryName)); + zip.write(data == null ? new byte[0] : data); + zip.closeEntry(); + } + + private String filename(WorldModList.Item item) { + String base = firstText(item.getTitle(), item.getSlug(), item.getProjectId(), item.getModId(), "mod"); + String version = firstText(item.getVersionNumber(), "latest"); + return base + "-" + version + ".jar"; + } + + private String readme(WorldModList list) { + return "Modtale world mod list\n" + + "World: " + firstText(list.getWorldName(), "Unknown world") + "\n" + + "List: " + firstText(list.getTitle(), "Shared mod list") + "\n" + + "Game version: " + firstText(list.getGameVersion(), "Not specified") + "\n\n" + + "This ZIP contains the downloadable Modtale projects from the shared list. " + + "Some local or external entries may appear only in modtale-list.json."; + } + + private static String unique(Set entries, String filename) { + String candidate = filename; + int counter = 2; + while (!entries.add(candidate)) { + int dot = filename.lastIndexOf('.'); + candidate = dot > 0 + ? filename.substring(0, dot) + "-" + counter + filename.substring(dot) + : filename + "-" + counter; + counter++; + } + return candidate; + } + + private static String sanitize(String filename) { + String sanitized = firstText(filename, "modtale-list-file") + .replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + if (sanitized.isBlank()) { + return "modtale-list-file"; + } + String lower = sanitized.toLowerCase(Locale.ROOT); + if (lower.endsWith(".txt") || lower.endsWith(".json") || lower.endsWith(".jar") || lower.endsWith(".zip")) { + return sanitized; + } + return sanitized + ".jar"; + } + + private static String firstText(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } +} diff --git a/backend/src/main/java/net/modtale/service/worldlist/WorldModListMapper.java b/backend/src/main/java/net/modtale/service/worldlist/WorldModListMapper.java new file mode 100644 index 00000000..89149a6c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/worldlist/WorldModListMapper.java @@ -0,0 +1,86 @@ +package net.modtale.service.worldlist; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.model.worldlist.WorldModList; +import org.springframework.stereotype.Component; + +@Component +final class WorldModListMapper { + + private final String frontendUrl; + + WorldModListMapper(AppFrontendProperties frontendProperties) { + this.frontendUrl = trimTrailingSlash(frontendProperties.url()); + } + + WorldModListDTO toDTO(WorldModList list) { + if (list == null) { + return null; + } + int downloadable = (int) list.getMods().stream() + .filter(WorldModList.Item::isDownloadable) + .count(); + String shareUrl = frontendUrl + "/lists/" + list.getId(); + String downloadUrl = "/lists/" + list.getId() + "/download"; + String launcherInstallUrl = "modtale://install-list?listId=" + encode(list.getId()) + "&url=" + encode(shareUrl); + return new WorldModListDTO( + list.getId(), + list.getTitle(), + list.getWorldName(), + list.getGameVersion(), + list.getOwnerUsername(), + list.getCreatedAt(), + list.getLastViewedAt(), + list.getExpiresAt(), + list.getViewCount(), + list.getDownloadCount(), + list.getMods().size(), + downloadable, + shareUrl, + downloadUrl, + launcherInstallUrl, + list.getMods().stream().map(this::toItemDTO).toList() + ); + } + + private WorldModListDTO.Item toItemDTO(WorldModList.Item item) { + return new WorldModListDTO.Item( + item.getId(), + item.getModId(), + item.getProjectId(), + item.getSlug(), + item.getTitle(), + item.getAuthorId(), + item.getAuthor(), + item.getDescription(), + item.getVersionNumber(), + item.getClassification(), + item.getSource(), + item.getExternalId(), + item.getExternalUrl(), + item.getIcon(), + item.getBannerUrl(), + item.getDownloadCount(), + item.getFavoriteCount(), + item.getUpdatedAt(), + item.isDownloadable(), + item.getUnavailableReason() + ); + } + + private static String trimTrailingSlash(String value) { + String normalized = value == null || value.isBlank() ? "https://modtale.net" : value.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8).replace("+", "%20"); + } +} diff --git a/backend/src/main/java/net/modtale/service/worldlist/WorldModListService.java b/backend/src/main/java/net/modtale/service/worldlist/WorldModListService.java new file mode 100644 index 00000000..f026d2cb --- /dev/null +++ b/backend/src/main/java/net/modtale/service/worldlist/WorldModListService.java @@ -0,0 +1,343 @@ +package net.modtale.service.worldlist; + +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.modtale.exception.InvalidProjectRequestException; +import net.modtale.exception.ResourceNotFoundException; +import net.modtale.model.dto.request.worldlist.CreateWorldModListRequest; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectDependency; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.user.User; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.repository.worldlist.WorldModListRepository; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +public class WorldModListService { + + private static final Duration EXPIRY_WINDOW = Duration.ofDays(30); + + private final WorldModListRepository repository; + private final ProjectService projectService; + private final ProjectVersionAccessService versionAccessService; + private final AccessControlService accessControlService; + private final WorldModListArchiveService archiveService; + private final WorldModListMapper mapper; + + public WorldModListService( + WorldModListRepository repository, + ProjectService projectService, + ProjectVersionAccessService versionAccessService, + AccessControlService accessControlService, + WorldModListArchiveService archiveService, + WorldModListMapper mapper + ) { + this.repository = repository; + this.projectService = projectService; + this.versionAccessService = versionAccessService; + this.accessControlService = accessControlService; + this.archiveService = archiveService; + this.mapper = mapper; + } + + public WorldModListDTO create(CreateWorldModListRequest request, User owner) { + if (owner == null) { + throw new InvalidProjectRequestException("Sign in before sharing a world mod list."); + } + List requestedMods = request.mods() == null ? List.of() : request.mods(); + Map items = new LinkedHashMap<>(); + for (CreateWorldModListRequest.Item requested : requestedMods) { + WorldModList.Item item = normalizeItem(requested, request.gameVersion(), owner); + String key = itemKey(item); + if (!key.isBlank()) { + items.putIfAbsent(key, item); + } + } + if (items.isEmpty()) { + throw new InvalidProjectRequestException("Pick at least one enabled mod before sharing this list."); + } + + Instant now = Instant.now(); + WorldModList list = new WorldModList(); + list.setOwnerId(owner.getId()); + list.setOwnerUsername(owner.getUsername()); + list.setTitle(firstText(request.title(), request.worldName(), "Shared world mods")); + list.setWorldName(firstText(request.worldName(), "Hytale world")); + list.setGameVersion(value(request.gameVersion())); + list.setCreatedAt(now); + list.setLastViewedAt(now); + list.setExpiresAt(now.plus(EXPIRY_WINDOW)); + list.setMods(items.values().stream().toList()); + return mapper.toDTO(repository.save(list)); + } + + public WorldModListDTO view(String id) { + return mapper.toDTO(touch(findActive(id), true, false)); + } + + public WorldModListDTO metadataForInstall(String id) { + return mapper.toDTO(touch(findActive(id), true, false)); + } + + public Download download(String id) throws IOException { + WorldModList list = touch(findActive(id), true, true); + return new Download(filename(list), archiveService.generateZip(list)); + } + + @Scheduled(cron = "${app.world-lists.cleanup-cron:0 20 3 * * ?}") + public void cleanupExpiredLists() { + repository.deleteByExpiresAtBefore(Instant.now()); + } + + private WorldModList touch(WorldModList list, boolean view, boolean download) { + refreshPublicProjectMetadata(list); + Instant now = Instant.now(); + list.setLastViewedAt(now); + list.setExpiresAt(now.plus(EXPIRY_WINDOW)); + if (view) { + list.setViewCount(list.getViewCount() + 1); + } + if (download) { + list.setDownloadCount(list.getDownloadCount() + 1); + } + return repository.save(list); + } + + private WorldModList findActive(String id) { + WorldModList list = repository.findById(id == null ? "" : id.trim()).orElse(null); + if (list == null || isExpired(list)) { + throw new ResourceNotFoundException("That shared mod list is gone or has expired."); + } + return list; + } + + private boolean isExpired(WorldModList list) { + return list.getExpiresAt() != null && Instant.now().isAfter(list.getExpiresAt()); + } + + private void refreshPublicProjectMetadata(WorldModList list) { + if (list == null || list.getMods() == null || list.getMods().isEmpty()) { + return; + } + for (WorldModList.Item item : list.getMods()) { + Project project = projectFor(item); + if (project == null || !accessControlService.isPubliclyReadable(project)) { + continue; + } + applyProjectMetadata(item, project); + item.setSource(ProjectDependency.Source.MODTALE); + } + } + + private WorldModList.Item normalizeItem(CreateWorldModListRequest.Item requested, String gameVersion, User owner) { + WorldModList.Item item = new WorldModList.Item(); + if (requested == null) { + item.setUnavailableReason("Empty list item."); + return item; + } + + item.setModId(value(requested.modId())); + item.setProjectId(value(requested.projectId())); + item.setSlug(value(requested.slug())); + item.setTitle(firstText(requested.title(), requested.modId(), requested.projectId(), "Unknown mod")); + item.setVersionNumber(value(requested.versionNumber())); + item.setClassification(requested.classification()); + item.setSource(requested.source() == null ? sourceFor(requested) : requested.source()); + item.setExternalId(value(requested.externalId())); + item.setExternalUrl(value(requested.externalUrl())); + item.setIcon(value(requested.icon())); + + if (item.getSource() == ProjectDependency.Source.MODTALE || !item.getProjectId().isBlank()) { + enrichModtaleItem(item, gameVersion, owner); + } else { + item.setDownloadable(false); + item.setUnavailableReason("Listed only; Modtale cannot package this external or local file."); + } + return item; + } + + private void enrichModtaleItem(WorldModList.Item item, String gameVersion, User owner) { + Project project = projectFor(item); + if (project == null || !accessControlService.isPubliclyReadable(project) || !accessControlService.canReadProject(project, owner)) { + item.setDownloadable(false); + item.setUnavailableReason("Project is not public on Modtale."); + return; + } + + ProjectVersion version = versionFor(project, item.getVersionNumber(), gameVersion); + applyProjectMetadata(item, project); + item.setSource(ProjectDependency.Source.MODTALE); + item.setVersionNumber(version == null ? item.getVersionNumber() : version.getVersionNumber()); + item.setFileUrl(version == null ? "" : value(version.getFileUrl())); + item.setDownloadable(version != null && version.getFileUrl() != null && !version.getFileUrl().isBlank()); + if (!item.isDownloadable()) { + item.setUnavailableReason("No downloadable public version could be resolved."); + } + } + + private void applyProjectMetadata(WorldModList.Item item, Project project) { + item.setProjectId(project.getId()); + item.setSlug(firstText(project.getSlug(), project.getId())); + item.setTitle(firstText(project.getTitle(), item.getTitle())); + item.setAuthorId(value(project.getAuthorId())); + item.setAuthor(value(project.getAuthor())); + item.setDescription(value(project.getDescription())); + item.setClassification(project.getClassification()); + item.setIcon(firstText(project.getImageUrl(), item.getIcon())); + item.setBannerUrl(value(project.getBannerUrl())); + item.setDownloadCount(project.getDownloadCount()); + item.setFavoriteCount(project.getFavoriteCount()); + item.setUpdatedAt(value(project.getUpdatedAt())); + } + + private Project projectFor(WorldModList.Item item) { + if (item == null) { + return null; + } + String projectId = value(item.getProjectId()); + if (!projectId.isBlank()) { + Project project = projectService.getRawProjectById(projectId); + if (project != null) { + return project; + } + } + String slug = value(item.getSlug()); + if (!slug.isBlank()) { + Project project = projectService.getRawProjectByRouteKey(slug); + if (project != null) { + return project; + } + } + for (String candidate : routeKeyCandidates(item)) { + Project project = projectService.getRawProjectByRouteKey(candidate); + if (project != null) { + return project; + } + } + return null; + } + + private Set routeKeyCandidates(WorldModList.Item item) { + LinkedHashSet candidates = new LinkedHashSet<>(); + addRouteKeyCandidates(candidates, item.getExternalId()); + addRouteKeyCandidates(candidates, item.getModId()); + addRouteKeyCandidates(candidates, item.getTitle()); + return candidates; + } + + private void addRouteKeyCandidates(Set candidates, String value) { + String normalized = value(value); + if (normalized.isBlank()) { + return; + } + candidates.add(normalized); + addSlugCandidates(candidates, normalized); + + int separator = normalized.lastIndexOf(':'); + if (separator >= 0 && separator < normalized.length() - 1) { + String suffix = normalized.substring(separator + 1).trim(); + if (!suffix.isBlank()) { + candidates.add(suffix); + addSlugCandidates(candidates, suffix); + } + } + } + + private void addSlugCandidates(Set candidates, String value) { + String camelSeparated = value.replaceAll("([a-z0-9])([A-Z])", "$1-$2"); + String slug = camelSeparated.toLowerCase(java.util.Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (!slug.isBlank()) { + candidates.add(slug); + } + + String compact = value.toLowerCase(java.util.Locale.ROOT) + .replaceAll("[^a-z0-9]+", ""); + if (!compact.isBlank()) { + candidates.add(compact); + } + } + + private ProjectVersion versionFor(Project project, String versionNumber, String gameVersion) { + ProjectVersion version = null; + if (versionNumber != null && !versionNumber.isBlank()) { + version = versionAccessService.findByVersionNumber(project, versionNumber, gameVersion); + } + if (isApproved(version)) { + return version; + } + return project.getVersions() == null ? null : project.getVersions().stream() + .filter(this::isApproved) + .filter(candidate -> supportsGameVersion(candidate, gameVersion)) + .max(Comparator.comparing(ProjectVersion::getReleaseDate, Comparator.nullsLast(String::compareTo))) + .orElse(null); + } + + private boolean isApproved(ProjectVersion version) { + return version != null && version.getReviewStatus() == ProjectVersion.ReviewStatus.APPROVED; + } + + private boolean supportsGameVersion(ProjectVersion version, String gameVersion) { + return gameVersion == null + || gameVersion.isBlank() + || version.getGameVersions() == null + || version.getGameVersions().stream().anyMatch(gameVersion::equalsIgnoreCase); + } + + private ProjectDependency.Source sourceFor(CreateWorldModListRequest.Item item) { + return item.projectId() == null || item.projectId().isBlank() + ? ProjectDependency.Source.OTHER + : ProjectDependency.Source.MODTALE; + } + + private String itemKey(WorldModList.Item item) { + if (!item.getProjectId().isBlank()) { + return item.getSource() + ":" + item.getProjectId(); + } + if (!item.getExternalId().isBlank()) { + return item.getSource() + ":" + item.getExternalId(); + } + return firstText(item.getModId(), item.getTitle()); + } + + private String filename(WorldModList list) { + String base = firstText(list.getWorldName(), list.getTitle(), "world-mod-list") + .replaceAll("[^A-Za-z0-9._-]+", "-") + .replaceAll("-+", "-") + .replaceAll("(^-|-$)", ""); + return (base.isBlank() ? "world-mod-list" : base) + "-mods.zip"; + } + + private static String value(String value) { + return value == null ? "" : value.trim(); + } + + private static String firstText(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } + + public record Download(String filename, byte[] bytes) { + } +} diff --git a/backend/src/main/java/net/modtale/status/StatusSnapshotFileStore.java b/backend/src/main/java/net/modtale/status/StatusSnapshotFileStore.java index f603c1e8..014655b0 100644 --- a/backend/src/main/java/net/modtale/status/StatusSnapshotFileStore.java +++ b/backend/src/main/java/net/modtale/status/StatusSnapshotFileStore.java @@ -33,7 +33,7 @@ public List readHistory() { try { SnapshotState state = objectMapper.readValue(path.toFile(), SnapshotState.class); - return state.history() != null ? state.history() : List.of(); + return state != null && state.history() != null ? state.history() : List.of(); } catch (IOException e) { logger.warn("Could not read detached status snapshot cache at {}", path, e); return List.of(); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 153a7d59..7f404585 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -76,9 +76,6 @@ spring.security.oauth2.client.provider.hytale.user-name-attribute=sub app.backend.url=${BACKEND_URL:http://localhost:8080} app.frontend.url=${FRONTEND_URL:http://localhost:5173} app.cors.allowed-origins=${FRONTEND_URL:http://localhost:5173} -app.curseforge.api-key=${CURSEFORGE_API_KEY:} -app.curseforge.hytale-game-id=${CURSEFORGE_HYTALE_GAME_ID:0} - server.port=8080 server.http2.enabled=true server.forward-headers-strategy=framework @@ -137,9 +134,9 @@ app.warden.api-key=${WARDEN_API_KEY:} app.warden.max-attempts=${WARDEN_MAX_ATTEMPTS:3} app.warden.request-timeout-seconds=${WARDEN_REQUEST_TIMEOUT_SECONDS:75} -app.security.pre-auth-secret=${PRE_AUTH_SECRET:c6677126-3ae0-4318-807e-b1af48b9f36c} +app.security.pre-auth-secret=${PRE_AUTH_SECRET:} app.security.pre-auth-expiry-seconds=600 -app.seeding.enabled=${APP_SEEDING_ENABLED:${SEEDING_ENABLED:false}} +app.seeding.enabled=${APP_SEEDING_ENABLED:false} app.seeding.mode=${APP_SEEDING_MODE:${SEEDING_MODE:clone}} app.seeding.reset=${APP_SEEDING_RESET:${SEEDING_RESET:false}} app.seeding.source-db=${APP_SEEDING_SOURCE_DB:${SEEDING_SOURCE_DB:modtale}} diff --git a/backend/src/test/java/net/modtale/config/auth/ApiKeyAuthFilterSessionTest.java b/backend/src/test/java/net/modtale/config/auth/ApiKeyAuthFilterSessionTest.java new file mode 100644 index 00000000..49a9403f --- /dev/null +++ b/backend/src/test/java/net/modtale/config/auth/ApiKeyAuthFilterSessionTest.java @@ -0,0 +1,100 @@ +package net.modtale.config.auth; + +import jakarta.servlet.FilterChain; +import net.modtale.exception.UnauthorizedException; +import net.modtale.model.user.ApiKey; +import net.modtale.model.user.User; +import net.modtale.service.auth.ApiKeyService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ApiKeyAuthFilterSessionTest { + private final ApiKeyService service = mock(ApiKeyService.class); + private final HandlerExceptionResolver resolver = mock(HandlerExceptionResolver.class); + private final FilterChain chain = mock(FilterChain.class); + private final ApiKeyAuthFilter filter = new ApiKeyAuthFilter(service, resolver); + private final MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/projects"); + private final MockHttpServletResponse response = new MockHttpServletResponse(); + private SecurityContext sessionContext; + + @BeforeEach + void setUp() { + sessionContext = SecurityContextHolder.createEmptyContext(); + sessionContext.setAuthentication(new UsernamePasswordAuthenticationToken("session-user", null, List.of())); + SecurityContextHolder.setContext(sessionContext); + } + + @AfterEach + void cleanUp() { + SecurityContextHolder.clearContext(); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "invalid"}) + void rejectsInvalidCredentialsInsteadOfUsingSession(String key) throws Exception { + request.addHeader("X-MODTALE-KEY", key); + filter.doFilter(request, response, chain); + verifyNoInteractions(chain); + verify(resolver).resolveException(eq(request), eq(response), isNull(), isA(UnauthorizedException.class)); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals("session-user", sessionContext.getAuthentication().getPrincipal()); + } + + @Test + void rejectsKeyWhoseOwnerNoLongerExists() throws Exception { + request.addHeader("X-MODTALE-KEY", "orphan"); + when(service.resolveKey("orphan")).thenReturn(new ApiKey()); + filter.doFilter(request, response, chain); + verifyNoInteractions(chain); + verify(resolver).resolveException(eq(request), eq(response), isNull(), isA(UnauthorizedException.class)); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void authenticatesKeyWithoutMutatingSharedSessionContext() throws Exception { + ApiKey key = new ApiKey(); + User user = new User(); + request.addHeader("X-MODTALE-KEY", "valid"); + when(service.resolveKey("valid")).thenReturn(key); + when(service.getUserFromKey(key)).thenReturn(user); + filter.doFilter(request, response, chain); + verify(chain).doFilter(request, response); + var authentication = SecurityContextHolder.getContext().getAuthentication(); + assertSame(user, authentication.getPrincipal()); + assertTrue(authentication.getAuthorities().stream().anyMatch(a -> a.getAuthority().equals("ROLE_API"))); + assertEquals("session-user", sessionContext.getAuthentication().getPrincipal()); + verifyNoInteractions(resolver); + } + + @Test + void preservesSessionWhenNoCredentialIsSupplied() throws Exception { + filter.doFilter(request, response, chain); + verify(chain).doFilter(request, response); + assertSame(sessionContext, SecurityContextHolder.getContext()); + verifyNoInteractions(service, resolver); + } + + @Test + void ignoresPathsOutsideApiVersionBoundary() throws Exception { + request.setRequestURI("/api/v10/projects"); + request.addHeader("X-MODTALE-KEY", "invalid"); + filter.doFilter(request, response, chain); + verify(chain).doFilter(request, response); + verifyNoInteractions(service, resolver); + } +} diff --git a/backend/src/test/java/net/modtale/config/auth/ApiKeyAuthFilterTest.java b/backend/src/test/java/net/modtale/config/auth/ApiKeyAuthFilterTest.java index e6a0f63b..88e6f1c5 100644 --- a/backend/src/test/java/net/modtale/config/auth/ApiKeyAuthFilterTest.java +++ b/backend/src/test/java/net/modtale/config/auth/ApiKeyAuthFilterTest.java @@ -62,7 +62,7 @@ void ignoresRequestsOutsideApiNamespace() throws Exception { } @Test - void ignoresBlankApiKeyHeaders() throws Exception { + void rejectsBlankApiKeyHeaders() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/projects"); request.addHeader("X-MODTALE-KEY", " "); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -71,7 +71,8 @@ void ignoresBlankApiKeyHeaders() throws Exception { filter.doFilterInternal(request, response, chain); verifyNoInteractions(apiKeyService); - verify(chain).doFilter(request, response); + verifyNoInteractions(chain); + verify(exceptionResolver).resolveException(eq(request), eq(response), isNull(), any(UnauthorizedException.class)); assertNull(SecurityContextHolder.getContext().getAuthentication()); } diff --git a/backend/src/test/java/net/modtale/config/auth/HytaleAuthorizationRequestResolverTest.java b/backend/src/test/java/net/modtale/config/auth/HytaleAuthorizationRequestResolverTest.java index b10365e9..67ddf8d6 100644 --- a/backend/src/test/java/net/modtale/config/auth/HytaleAuthorizationRequestResolverTest.java +++ b/backend/src/test/java/net/modtale/config/auth/HytaleAuthorizationRequestResolverTest.java @@ -18,20 +18,28 @@ class HytaleAuthorizationRequestResolverTest { void addsS256PkceToConfidentialHytaleAuthorizationRequest() { HytaleAuthorizationRequestResolver resolver = resolver(); - OAuth2AuthorizationRequest authorizationRequest = resolver.resolve(requestFor("hytale")); + OAuth2AuthorizationRequest authorizationRequest = + resolver.resolve(requestFor("hytale")); assertNotNull(authorizationRequest); assertNotNull(authorizationRequest.getAttribute(PkceParameterNames.CODE_VERIFIER)); assertEquals( "S256", - authorizationRequest.getAdditionalParameters().get(PkceParameterNames.CODE_CHALLENGE_METHOD) + authorizationRequest.getAdditionalParameters() + .get(PkceParameterNames.CODE_CHALLENGE_METHOD) + ); + assertNotNull( + authorizationRequest.getAdditionalParameters() + .get(PkceParameterNames.CODE_CHALLENGE) ); - assertNotNull(authorizationRequest.getAdditionalParameters().get(PkceParameterNames.CODE_CHALLENGE)); } private static HytaleAuthorizationRequestResolver resolver() { return new HytaleAuthorizationRequestResolver( - new InMemoryClientRegistrationRepository(registration("hytale"), registration("other")) + new InMemoryClientRegistrationRepository( + registration("hytale"), + registration("other") + ) ); } diff --git a/backend/src/test/java/net/modtale/config/core/PublicApiEndpointMatcherTest.java b/backend/src/test/java/net/modtale/config/core/PublicApiEndpointMatcherTest.java new file mode 100644 index 00000000..3e006b44 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/core/PublicApiEndpointMatcherTest.java @@ -0,0 +1,29 @@ +package net.modtale.config.core; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PublicApiEndpointMatcherTest { + + @Test + void artifactIdentificationIsPublicReadOnlyFunctionality() { + assertTrue(PublicApiEndpointMatcher.isPublicOperation( + "/api/v1/projects/external/identify", + "POST" + )); + } + + @Test + void accountBackedProjectAndListWritesRemainPrivate() { + assertFalse(PublicApiEndpointMatcher.isPublicOperation( + "/api/v1/projects/project-1/favorite", + "POST" + )); + assertFalse(PublicApiEndpointMatcher.isPublicOperation( + "/api/v1/lists", + "POST" + )); + } +} diff --git a/backend/src/test/java/net/modtale/config/db/MongoConfigTest.java b/backend/src/test/java/net/modtale/config/db/MongoConfigTest.java new file mode 100644 index 00000000..effff5af --- /dev/null +++ b/backend/src/test/java/net/modtale/config/db/MongoConfigTest.java @@ -0,0 +1,20 @@ +package net.modtale.config.db; + +import java.util.Locale; +import net.modtale.model.user.OAuthProvider; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MongoConfigTest { + @Test + void providerConversionDoesNotDependOnServerLocale() { + Locale original = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + assertEquals(OAuthProvider.DISCORD, new MongoConfig.StringToOAuthProviderConverter().convert("discord")); + } finally { + Locale.setDefault(original); + } + } +} diff --git a/backend/src/test/java/net/modtale/config/properties/AppSecurityPropertiesTest.java b/backend/src/test/java/net/modtale/config/properties/AppSecurityPropertiesTest.java new file mode 100644 index 00000000..cdf169c3 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/properties/AppSecurityPropertiesTest.java @@ -0,0 +1,24 @@ +package net.modtale.config.properties; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AppSecurityPropertiesTest { + @Test + void missingSecretsAreGeneratedPerConfigurationInsteadOfUsingPublicDefaults() { + var first = properties(null); + var second = properties(""); + assertFalse(first.preAuthSecret().isBlank()); + assertNotEquals(first.preAuthSecret(), second.preAuthSecret()); + } + + @Test + void explicitSecretIsPreservedForMultipleInstances() { + assertEquals("configured-secret", properties("configured-secret").preAuthSecret()); + } + + private AppSecurityProperties properties(String secret) { + return new AppSecurityProperties(secret, 600, 120, 2, 12, 15, 120, 25, 2); + } +} diff --git a/backend/src/test/java/net/modtale/config/security/ApiCorsPolicyTest.java b/backend/src/test/java/net/modtale/config/security/ApiCorsPolicyTest.java new file mode 100644 index 00000000..2cea26f4 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/security/ApiCorsPolicyTest.java @@ -0,0 +1,65 @@ +package net.modtale.config.security; + +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.cors.DefaultCorsProcessor; + +import static org.junit.jupiter.api.Assertions.*; + +class ApiCorsPolicyTest { + @ParameterizedTest + @ValueSource(strings = {"https://modtale.net", "https://www.modtale.net", "https://preview-123.run.app"}) + void trustedFrontendsCanUseSessionCredentials(String origin) throws Exception { + var response = process(origin, "/api/v1/user/me", "GET", null); + assertEquals(origin, response.getHeader("Access-Control-Allow-Origin")); + assertEquals("true", response.getHeader("Access-Control-Allow-Credentials")); + } + + @ParameterizedTest + @ValueSource(strings = {"https://attacker.example", "https://other-preview.run.app", "null"}) + void arbitraryOriginsCannotReadSessionAuthenticatedResponses(String origin) throws Exception { + var response = process(origin, "/api/v1/user/me", "GET", null); + assertEquals("*", response.getHeader("Access-Control-Allow-Origin")); + assertNull(response.getHeader("Access-Control-Allow-Credentials")); + } + + @Test + void thirdPartyApiKeyPreflightsRemainSupported() throws Exception { + var response = process("https://third-party.example", "/api/v1/projects", "OPTIONS", "X-Modtale-Key, Content-Type"); + assertEquals(200, response.getStatus()); + assertEquals("*", response.getHeader("Access-Control-Allow-Origin")); + assertNull(response.getHeader("Access-Control-Allow-Credentials")); + assertTrue(response.getHeader("Access-Control-Allow-Headers").contains("X-Modtale-Key")); + } + + @Test + void untrustedOriginsCannotSendCsrfHeaders() throws Exception { + var response = process("https://attacker.example", "/api/v1/projects", "OPTIONS", "X-XSRF-TOKEN"); + assertEquals(403, response.getStatus()); + } + + @Test + void onlyConfiguredPreviewCanAccessRestrictedEndpoints() throws Exception { + assertEquals(403, process("https://other-preview.run.app", "/api/v1/admin/users", "GET", null).getStatus()); + var response = process("https://preview-123.run.app", "/api/v1/admin/users", "GET", null); + assertEquals("https://preview-123.run.app", response.getHeader("Access-Control-Allow-Origin")); + assertEquals("true", response.getHeader("Access-Control-Allow-Credentials")); + } + + private MockHttpServletResponse process(String origin, String path, String method, String headers) throws Exception { + var source = ApiCorsPolicy.create(Set.of("https://modtale.net", "https://*.modtale.net", "https://preview-123.run.app")); + var request = new MockHttpServletRequest(method, path); + request.addHeader("Origin", origin); + if (headers != null) { + request.addHeader("Access-Control-Request-Method", "POST"); + request.addHeader("Access-Control-Request-Headers", headers); + } + var response = new MockHttpServletResponse(); + new DefaultCorsProcessor().processRequest(source.getCorsConfiguration(request), request, response); + return response; + } +} diff --git a/backend/src/test/java/net/modtale/config/security/ApiCsrfRequestMatcherTest.java b/backend/src/test/java/net/modtale/config/security/ApiCsrfRequestMatcherTest.java new file mode 100644 index 00000000..52b2ffc3 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/security/ApiCsrfRequestMatcherTest.java @@ -0,0 +1,61 @@ +package net.modtale.config.security; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; + +import static org.junit.jupiter.api.Assertions.*; + +class ApiCsrfRequestMatcherTest { + @ParameterizedTest + @ValueSource(strings = { + "/api/v1/auth/change-password", "/api/v1/auth/credentials", "/api/v1/auth/password", + "/api/v1/auth/mfa/verify", "/api/v1/auth/launcher/issue", "/api/v1/auth/logout", + "/api/v1/user/api-keys", "/api/v1/projects/example" + }) + void sessionMutationsRequireTokenEvenWithEmptyKey(String path) throws Exception { + var request = new MockHttpServletRequest("POST", path); + request.addHeader("X-MODTALE-KEY", ""); + var response = new MockHttpServletResponse(); + var filter = new CsrfFilter(new HttpSessionCsrfTokenRepository()); + filter.setRequireCsrfProtectionMatcher(new ApiCsrfRequestMatcher()); + filter.doFilter(request, response, (req, res) -> fail("Request without CSRF token reached application")); + assertEquals(403, response.getStatus()); + } + + @Test + void validCsrfTokenAllowsSessionMutation() throws Exception { + var request = new MockHttpServletRequest("POST", "/api/v1/auth/change-password"); + var response = new MockHttpServletResponse(); + var repository = new HttpSessionCsrfTokenRepository(); + var token = repository.generateToken(request); + repository.saveToken(token, request, response); + request.addHeader(token.getHeaderName(), token.getToken()); + var filter = new CsrfFilter(repository); + filter.setRequestHandler(new org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler()); + filter.setRequireCsrfProtectionMatcher(new ApiCsrfRequestMatcher()); + boolean[] reachedApplication = {false}; + filter.doFilter(request, response, (req, res) -> reachedApplication[0] = true); + assertTrue(reachedApplication[0]); + } + + @ParameterizedTest + @ValueSource(strings = {"/api/v1/auth/signin", "/api/v1/auth/launcher/exchange", "/api/v1/users/batch", "/api/v1/projects/external/identify"}) + void preservesPublicPostOperations(String path) { + assertFalse(new ApiCsrfRequestMatcher().matches(new MockHttpServletRequest("POST", path))); + assertTrue(new ApiCsrfRequestMatcher().matches(new MockHttpServletRequest("DELETE", path))); + } + + @Test + void apiKeyExemptionIsConfinedToApiNamespace() { + var request = new MockHttpServletRequest("POST", "/api/v1/projects"); + request.addHeader("X-MODTALE-KEY", "key"); + assertFalse(new ApiCsrfRequestMatcher().matches(request)); + request.setRequestURI("/logout"); + assertTrue(new ApiCsrfRequestMatcher().matches(request)); + } +} diff --git a/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java b/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java new file mode 100644 index 00000000..ca884636 --- /dev/null +++ b/backend/src/test/java/net/modtale/config/security/SecurityConfigLauncherOAuthTest.java @@ -0,0 +1,124 @@ +package net.modtale.config.security; + +import java.util.Map; +import java.util.Set; +import net.modtale.config.auth.ApiKeyAuthFilter; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.user.User; +import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; +import net.modtale.service.auth.LocalUserDetailsService; +import net.modtale.service.auth.OAuth2LoginService; +import net.modtale.service.auth.OidcLoginService; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SecurityConfigLauncherOAuthTest { + + @Test + void oauthSuccessRedirectsLauncherOAuthToLoopbackCallbackWithCode() throws Exception { + AccountService accountService = mock(AccountService.class); + LauncherAuthService launcherAuthService = mock(LauncherAuthService.class); + SecurityConfig config = config(accountService, launcherAuthService); + + User user = new User(); + user.setId("user-1"); + user.setUsername("ada"); + user.setRoles(java.util.List.of("USER")); + when(accountService.getPublicProfile("ada")).thenReturn(user); + when(launcherAuthService.issueCode(user, "http://127.0.0.1:49152/callback", "state-123")) + .thenReturn(new LauncherAuthService.LauncherAuthGrant( + "launcher-code", + "http://127.0.0.1:49152/callback", + "state-123", + 300 + )); + + MockHttpServletRequest request = launcherOAuthRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + OAuth2AuthenticationToken authentication = authentication(); + SecurityContextHolder.getContext().setAuthentication(authentication); + + config.oauthSuccessHandler().onAuthenticationSuccess(request, response, authentication); + + assertEquals( + "http://127.0.0.1:49152/callback?code=launcher-code&state=state-123", + response.getRedirectedUrl() + ); + SecurityContextHolder.clearContext(); + } + + @Test + void oauthFailureRedirectsLauncherOAuthToLoopbackCallbackWithError() throws Exception { + SecurityConfig config = config(mock(AccountService.class), mock(LauncherAuthService.class)); + MockHttpServletRequest request = launcherOAuthRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + config.oauthFailureHandler().onAuthenticationFailure( + request, + response, + new OAuth2AuthenticationException(new OAuth2Error("provider_error"), "Provider failed") + ); + + assertEquals( + "http://127.0.0.1:49152/callback?error=Provider+failed&state=state-123", + response.getRedirectedUrl() + ); + } + + private static SecurityConfig config(AccountService accountService, LauncherAuthService launcherAuthService) { + return new SecurityConfig( + mock(ApiKeyAuthFilter.class), + mock(RateLimitFilter.class), + mock(OAuth2LoginService.class), + mock(OidcLoginService.class), + mock(OAuth2AuthorizedClientRepository.class), + mock(LocalUserDetailsService.class), + mock(PasswordEncoder.class), + accountService, + mock(AuthenticationService.class), + launcherAuthService, + new AppFrontendProperties("http://localhost:5173") + ); + } + + private static MockHttpServletRequest launcherOAuthRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, + "http://127.0.0.1:49152/callback" + ); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE, + "state-123" + ); + return request; + } + + private static OAuth2AuthenticationToken authentication() { + DefaultOAuth2User principal = new DefaultOAuth2User( + Set.of(new SimpleGrantedAuthority("ROLE_USER")), + Map.of("id", "user-1", "login", "ada"), + "login" + ); + return new OAuth2AuthenticationToken( + principal, + principal.getAuthorities(), + "github" + ); + } +} diff --git a/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java b/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java new file mode 100644 index 00000000..9247b72e --- /dev/null +++ b/backend/src/test/java/net/modtale/config/security/SecurityFilterChainTest.java @@ -0,0 +1,72 @@ +package net.modtale.config.security; + +import net.modtale.config.auth.ApiKeyAuthFilter; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.service.auth.*; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class SecurityFilterChainTest { + @Test + void realChainKeepsCsrfAndHasNoLegacyPasswordLoginFilter() { + try (var context = new AnnotationConfigWebApplicationContext()) { + context.setServletContext(new MockServletContext()); + context.register(TestSecurity.class); + context.refresh(); + var filters = context.getBean(SecurityFilterChain.class).getFilters(); + assertTrue(filters.stream().anyMatch(CsrfFilter.class::isInstance)); + assertFalse(filters.stream().anyMatch(UsernamePasswordAuthenticationFilter.class::isInstance)); + } + } + + @Test + void localhostTextInsideRemoteFrontendDoesNotDisableSecureCookies() { + var response = new org.springframework.mock.web.MockHttpServletResponse(); + var request = new org.springframework.mock.web.MockHttpServletRequest(); + configuration("https://localhost.attacker.test").cookieSerializer().writeCookieValue( + new org.springframework.session.web.http.CookieSerializer.CookieValue(request, response, "session")); + assertTrue(response.getHeader("Set-Cookie").contains("Secure")); + } + + @Configuration + @EnableWebSecurity + static class TestSecurity { + @Bean + ClientRegistrationRepository clients() { + return mock(ClientRegistrationRepository.class); + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + var configuration = configuration("https://preview-example.run.app"); + return configuration.securityFilterChain(http, mock(OAuth2AuthorizationRequestResolver.class)); + } + } + + private static SecurityConfig configuration(String frontendUrl) { + return new SecurityConfig( + mock(ApiKeyAuthFilter.class), mock(RateLimitFilter.class), + mock(OAuth2LoginService.class), mock(OidcLoginService.class), + mock(OAuth2AuthorizedClientRepository.class), mock(LocalUserDetailsService.class), + mock(PasswordEncoder.class), mock(AccountService.class), + mock(AuthenticationService.class), mock(LauncherAuthService.class), + new AppFrontendProperties(frontendUrl) + ); + } +} diff --git a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java index e343f467..2d124959 100644 --- a/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/auth/AuthControllerTest.java @@ -2,19 +2,26 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import net.modtale.model.dto.request.auth.LauncherAuthExchangeRequest; +import net.modtale.model.dto.request.auth.LauncherAuthIssueRequest; import net.modtale.model.dto.request.auth.SignInRequest; import net.modtale.model.user.User; import net.modtale.service.auth.AuthenticationMutationService; import net.modtale.service.auth.AuthenticationService; +import net.modtale.service.auth.LauncherAuthService; import net.modtale.service.auth.TwoFactorService; import net.modtale.service.user.account.AccountService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.web.context.SecurityContextRepository; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -26,6 +33,7 @@ class AuthControllerTest { private AuthenticationMutationService authenticationMutationService; private AccountService accountService; private TwoFactorService twoFactorService; + private LauncherAuthService launcherAuthService; private SecurityContextRepository securityContextRepository; @BeforeEach @@ -34,12 +42,14 @@ void setUp() { authenticationMutationService = mock(AuthenticationMutationService.class); accountService = mock(AccountService.class); twoFactorService = mock(TwoFactorService.class); + launcherAuthService = mock(LauncherAuthService.class); securityContextRepository = mock(SecurityContextRepository.class); controller = new AuthController( authenticationService, authenticationMutationService, accountService, twoFactorService, + launcherAuthService, securityContextRepository ); } @@ -75,6 +85,79 @@ void logoutClearsTheSecurityContextAndExpiresSessionCookies() { verify(securityContextRepository).saveContext(org.springframework.security.core.context.SecurityContextHolder.createEmptyContext(), request, response); } + @Test + void issueLauncherAuthCodeReturnsGrantForCurrentUser() { + User user = new User(); + user.setId("user-1"); + + LauncherAuthIssueRequest requestPayload = new LauncherAuthIssueRequest(); + requestPayload.setRedirectUri("http://127.0.0.1:49152/callback"); + requestPayload.setState("state-123"); + + when(accountService.requireCurrentUser(null, "authorizing the Modtale Launcher")).thenReturn(user); + when(launcherAuthService.issueCode(user, "http://127.0.0.1:49152/callback", "state-123")) + .thenReturn(new LauncherAuthService.LauncherAuthGrant( + "launcher-code", + "http://127.0.0.1:49152/callback", + "state-123", + 300 + )); + + var response = controller.issueLauncherAuthCode(requestPayload, null); + + assertEquals(200, response.getStatusCode().value()); + assertEquals("launcher-code", response.getBody().code()); + assertEquals("state-123", response.getBody().state()); + assertEquals(300, response.getBody().expiresIn()); + } + + @Test + void beginLauncherOAuthStoresCallbackAndRedirectsToProviderAuthorization() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + controller.beginLauncherOAuthLogin( + "github", + "http://127.0.0.1:49152/callback", + "state-123", + request, + response + ); + + assertEquals("/oauth2/authorization/github", response.getRedirectedUrl()); + assertEquals( + "http://127.0.0.1:49152/callback", + request.getSession().getAttribute(LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE) + ); + assertEquals( + "state-123", + request.getSession().getAttribute(LauncherAuthService.OAUTH_STATE_SESSION_ATTRIBUTE) + ); + verify(launcherAuthService).validateLoopbackRedirectUri("http://127.0.0.1:49152/callback"); + } + + @Test + void exchangeLauncherAuthCodeCreatesSessionForLauncherClient() { + User user = new User(); + user.setId("user-1"); + user.setRoles(java.util.List.of("USER")); + + LauncherAuthExchangeRequest requestPayload = new LauncherAuthExchangeRequest(); + requestPayload.setCode("launcher-code"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(launcherAuthService.consumeCode("launcher-code")).thenReturn(user); + + var result = controller.exchangeLauncherAuthCode(requestPayload, request, response); + + assertEquals(200, result.getStatusCode().value()); + assertNotNull(request.getSession(false)); + verify(securityContextRepository).saveContext(any(SecurityContext.class), eq(request), eq(response)); + org.springframework.security.core.context.SecurityContextHolder.clearContext(); + } + @Test void removePasswordDelegatesForTheCurrentUser() { User user = new User(); diff --git a/backend/src/test/java/net/modtale/controller/auth/CsrfControllerTest.java b/backend/src/test/java/net/modtale/controller/auth/CsrfControllerTest.java new file mode 100644 index 00000000..e22889b4 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/auth/CsrfControllerTest.java @@ -0,0 +1,17 @@ +package net.modtale.controller.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.security.web.csrf.DefaultCsrfToken; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class CsrfControllerTest { + @Test + void returnsCurrentTokenAndPreventsCaching() { + var response = new CsrfController().token(new DefaultCsrfToken("X-XSRF-TOKEN", "_csrf", "token-value")); + assertNotNull(response.getBody()); + assertEquals("token-value", response.getBody().token()); + assertEquals("no-store", response.getHeaders().getCacheControl()); + } +} diff --git a/backend/src/test/java/net/modtale/controller/project/ExternalProjectControllerTest.java b/backend/src/test/java/net/modtale/controller/project/ExternalProjectControllerTest.java new file mode 100644 index 00000000..e3617adf --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/project/ExternalProjectControllerTest.java @@ -0,0 +1,65 @@ +package net.modtale.controller.project; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import net.modtale.service.project.version.CurseForgeApiClient; +import net.modtale.service.project.version.ExternalProjectReferenceService; +import org.junit.jupiter.api.Test; + +class ExternalProjectControllerTest { + + @Test + void exposesNormalizedUncachedCurseForgeBrowseAndDetailResponses() { + CurseForgeApiClient api = mock(CurseForgeApiClient.class); + CurseForgeApiClient.CurseForgeFile file = new CurseForgeApiClient.CurseForgeFile( + "8227810", "Simple Compost 1.2", "SimpleCompost.jar", "1.2", "RELEASE", + "2026-09-01T00:00:00Z", 2048L, Map.of("sha1", "a".repeat(40)), + List.of("2026.09"), 4, true, 12); + CurseForgeApiClient.CurseForgeProject project = new CurseForgeApiClient.CurseForgeProject( + "1450386", "simple-compost", "Simple Compost", "Compost things", "https://img.example/icon.png", + true, List.of(file), "https://www.curseforge.com/hytale/mods/simple-compost", + List.of("Builder"), List.of("Gameplay"), List.of("https://img.example/shot.png"), + "2026-09-01T00:00:00Z", 321, "

Full description

"); + when(api.searchMods("compost", "2026.09", 0, 20, "downloads")) + .thenReturn(new CurseForgeApiClient.CurseForgeSearchResult(List.of(project), 0, 20, 1)); + when(api.getProject(1450386)).thenReturn(java.util.Optional.of(project)); + net.modtale.service.project.version.ArtifactIdentityService identities = + mock(net.modtale.service.project.version.ArtifactIdentityService.class); + when(identities.removeModtaleAliases(org.mockito.ArgumentMatchers.any())).thenAnswer(call -> call.getArgument(0)); + ExternalProjectController controller = new ExternalProjectController( + mock(ExternalProjectReferenceService.class), api, identities); + + var browse = controller.browseCurseForge("compost", "2026.09", 0, 20, "downloads"); + var detail = controller.getCurseForgeProject(1450386); + + assertEquals("no-store", browse.getHeaders().getCacheControl()); + assertEquals("curseforge:1450386", browse.getBody().content().getFirst().id()); + assertEquals("CURSEFORGE", browse.getBody().content().getFirst().source()); + assertEquals("8227810", browse.getBody().content().getFirst().versions().getFirst().id()); + assertTrue(detail.getBody().distributionAllowed()); + assertEquals(List.of("Gameplay"), detail.getBody().tags()); + } + + @Test + void exposesOnlyTheProviderResolvedDownloadAndIntegrityMetadata() { + CurseForgeApiClient api = mock(CurseForgeApiClient.class); + when(api.getDownload(1450386, 8227810)).thenReturn(java.util.Optional.of( + new CurseForgeApiClient.CurseForgeDownload( + "https://mediafilez.forgecdn.net/files/8227/810/SimpleCompost.jar", + "SimpleCompost.jar", 2048L, Map.of("sha1", "a".repeat(40))))); + ExternalProjectController controller = new ExternalProjectController( + mock(ExternalProjectReferenceService.class), api, + mock(net.modtale.service.project.version.ArtifactIdentityService.class)); + + var response = controller.getCurseForgeDownload(1450386, 8227810); + + assertEquals("no-store", response.getHeaders().getCacheControl()); + assertEquals("CURSEFORGE", response.getBody().source()); + assertEquals("a".repeat(40), response.getBody().hashes().get("sha1")); + } +} diff --git a/backend/src/test/java/net/modtale/controller/project/WikiProxyControllerTest.java b/backend/src/test/java/net/modtale/controller/project/WikiProxyControllerTest.java index 02973b1c..5b625c8c 100644 --- a/backend/src/test/java/net/modtale/controller/project/WikiProxyControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/project/WikiProxyControllerTest.java @@ -79,6 +79,6 @@ void handleWikiUpstreamUsesProblemDetailFormatting() { var response = controller.handleWikiUpstream(error); assertEquals(502, response.getStatusCode().value()); - assertTrue(response.getBody().getDetail().contains("Wiki upstream is unavailable.")); + assertEquals("Wiki upstream request failed.", response.getBody().getDetail()); } } diff --git a/backend/src/test/java/net/modtale/controller/system/OgAssetPolicyTest.java b/backend/src/test/java/net/modtale/controller/system/OgAssetPolicyTest.java new file mode 100644 index 00000000..d1b96106 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/system/OgAssetPolicyTest.java @@ -0,0 +1,29 @@ +package net.modtale.controller.system; + +import java.net.URI; +import net.modtale.config.properties.AppR2Properties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class OgAssetPolicyTest { + private final OgAssetPolicy policy = new OgAssetPolicy(new AppR2Properties(null, null, null, null, "https://storage.example.test")); + + @ParameterizedTest + @ValueSource(strings = {"http://127.0.0.1/private", "http://169.254.169.254/", "https://cdn.modtale.net.evil.test/image", + "https://cdn.modtale.net:444/image", "https://user@cdn.modtale.net/image", "file:///etc/passwd", + "//attacker.test/image", "/api/v1/files/../../../actuator", "/api/v1/files/%2e%2e/%2e%2e/private", "/actuator/health"}) + void rejectsUntrustedOriginsAndUnsafeRelativePaths(String value) { + assertNull(policy.resolve(value)); + } + + @Test + void allowsKnownStorageOriginsAndRestrictedLocalAssetRoutes() { + assertEquals(URI.create("https://cdn.modtale.net/icon.png"), policy.resolve("https://cdn.modtale.net/icon.png")); + assertEquals(URI.create("https://storage.example.test/icon.png"), policy.resolve("https://storage.example.test/icon.png")); + assertEquals(URI.create("https://modtale.net/assets/favicon.svg"), policy.resolve("/assets/favicon.svg")); + assertEquals(URI.create("http://localhost:8080/api/v1/files/icon.png"), policy.resolve("/api/v1/files/icon.png")); + } +} diff --git a/backend/src/test/java/net/modtale/controller/system/OgImageControllerTest.java b/backend/src/test/java/net/modtale/controller/system/OgImageControllerTest.java index 6cfa845f..f263f07a 100644 --- a/backend/src/test/java/net/modtale/controller/system/OgImageControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/system/OgImageControllerTest.java @@ -10,13 +10,27 @@ class OgImageControllerTest { + @Test + void rejectsOversizedRasterDimensionsBeforeAllocatingTheImage() throws Exception { + var image = new java.awt.image.BufferedImage(2, 2, java.awt.image.BufferedImage.TYPE_INT_RGB); + var output = new java.io.ByteArrayOutputStream(); + javax.imageio.ImageIO.write(image, "png", output); + byte[] png = output.toByteArray(); + java.nio.ByteBuffer.wrap(png).putInt(16, 50_000).putInt(20, 50_000); + var crc = new java.util.zip.CRC32(); + crc.update(png, 12, 17); + java.nio.ByteBuffer.wrap(png).putInt(29, (int) crc.getValue()); + org.junit.jupiter.api.Assertions.assertNull(org.springframework.test.util.ReflectionTestUtils.invokeMethod( + controller, "decodeFetchedImage", png, "image/png", "https://cdn.modtale.net/image.png")); + } + private ProjectService projectService; private OgImageController controller; @BeforeEach void setUp() { projectService = mock(ProjectService.class); - controller = new OgImageController(projectService); + controller = new OgImageController(projectService, new net.modtale.config.properties.AppR2Properties(null, null, null, null, null)); } @Test diff --git a/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java b/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java index 7795c525..bfb0e8a8 100644 --- a/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/user/UserControllerTest.java @@ -2,6 +2,7 @@ import net.modtale.model.dto.request.user.UpdateProfileRequest; import net.modtale.model.dto.response.common.ResourceUrlResponse; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.media.MediaUploadService; @@ -117,6 +118,28 @@ void followUserDelegatesUsingTheAuthenticatedUserId() { verify(socialService).followUser("user-1", "user-2"); } + @Test + void launcherSettingsEndpointsUseCurrentUser() { + User currentUser = user("user-1", "ada"); + LauncherSettingsSnapshot snapshot = new LauncherSettingsSnapshot(); + when(accountService.requireCurrentUser(null, "loading launcher settings")).thenReturn(currentUser); + when(accountService.requireCurrentUser(null, "syncing launcher settings")).thenReturn(currentUser); + when(accountService.getLauncherSettings("user-1")).thenReturn(snapshot); + when(accountService.updateLauncherSettings("user-1", snapshot)).thenReturn(snapshot); + when(accountService.updateLauncherSettingsPreferences("user-1", snapshot)).thenReturn(snapshot); + + var getResponse = controller.getLauncherSettings(null); + var putResponse = controller.updateLauncherSettings(snapshot, null); + var prefsResponse = controller.updateLauncherSettingsPreferences(snapshot, null); + + assertEquals(200, getResponse.getStatusCode().value()); + assertSame(snapshot, getResponse.getBody()); + assertEquals(200, putResponse.getStatusCode().value()); + assertSame(snapshot, putResponse.getBody()); + assertEquals(200, prefsResponse.getStatusCode().value()); + assertSame(snapshot, prefsResponse.getBody()); + } + private static User user(String id, String username) { User user = new User(); user.setId(id); diff --git a/backend/src/test/java/net/modtale/exception/ErrorMessageUtilsTest.java b/backend/src/test/java/net/modtale/exception/ErrorMessageUtilsTest.java index d983f6b3..e462dbe4 100644 --- a/backend/src/test/java/net/modtale/exception/ErrorMessageUtilsTest.java +++ b/backend/src/test/java/net/modtale/exception/ErrorMessageUtilsTest.java @@ -8,6 +8,22 @@ class ErrorMessageUtilsTest { + @Test + void serverErrorsDoNotExposeInternalExceptionMessages() { + RuntimeException error = new RuntimeException("Database failed at mongodb://user:password@internal-host/db"); + var response = ErrorMessageUtils.internalServerError(error, "Could not save changes."); + assertEquals("Could not save changes.", response.getBody().getDetail()); + assertEquals("Could not save changes.", response.getBody().getProperties().get("error")); + var unhandled = new GlobalExceptionHandler().handleAllOtherExceptions(error); + assertEquals("The server could not complete the request.", unhandled.getBody().getDetail()); + } + + @Test + void clientErrorsRetainActionableValidationDetails() { + var response = ErrorMessageUtils.badRequest(new IllegalArgumentException("Name is already in use"), "Invalid name"); + assertEquals("Invalid name: Name is already in use", response.getBody().getDetail()); + } + @Test void describeUsesTheMostSpecificNonGenericCause() { RuntimeException error = new RuntimeException( diff --git a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java index 6a1ddaa5..0ad7499b 100644 --- a/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java +++ b/backend/src/test/java/net/modtale/mapper/ProjectMapperTest.java @@ -136,7 +136,6 @@ void versionAndDependencyMappingsHonorOptionalReviewData() { dependency.setTitle("Core Display"); dependency.setClassification(ProjectClassification.PLUGIN); dependency.setSlug("core"); - dependency.setEnvironment(ProjectDependency.Environment.SERVER); ProjectDependencyDTO dependencyDto = ProjectMapper.toDependencyDTO(dependency); assertNull(withoutReview.reviewStatus()); @@ -146,7 +145,6 @@ void versionAndDependencyMappingsHonorOptionalReviewData() { assertEquals("modtale:core", dependencyDto.projectId()); assertEquals(ProjectDependency.DependencyType.EMBEDDED, dependencyDto.dependencyType()); assertEquals(ProjectDependency.Source.MODTALE, dependencyDto.source()); - assertEquals(ProjectDependency.Environment.SERVER, dependencyDto.environment()); assertEquals("/icons/core.png", dependencyDto.icon()); assertEquals("Core Display", dependencyDto.title()); assertEquals(ProjectClassification.PLUGIN, dependencyDto.classification()); @@ -209,7 +207,7 @@ private static Project baseProject() { project.setCustomLicenseOpenSource(true); project.setLastTrendingNotification("2026-01-02"); project.setLinks(Map.of("docs", "https://example.com/docs")); - project.setTypes(List.of("SERVER")); + project.setTypes(List.of("PLUGIN")); project.setAllowModpacks(true); project.setAllowComments(true); project.setHmWikiEnabled(true); diff --git a/backend/src/test/java/net/modtale/service/auth/LauncherAuthServiceTest.java b/backend/src/test/java/net/modtale/service/auth/LauncherAuthServiceTest.java new file mode 100644 index 00000000..23fd8094 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/auth/LauncherAuthServiceTest.java @@ -0,0 +1,61 @@ +package net.modtale.service.auth; + +import java.util.Optional; +import net.modtale.exception.InvalidAuthenticationRequestException; +import net.modtale.model.user.User; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LauncherAuthServiceTest { + + private UserRepository userRepository; + private LauncherAuthService service; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + service = new LauncherAuthService(userRepository); + } + + @Test + void issueCodeAllowsOnlyLoopbackRedirectsAndConsumesOnce() { + User user = new User(); + user.setId("user-1"); + + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + + LauncherAuthService.LauncherAuthGrant grant = service.issueCode( + user, + "http://127.0.0.1:49152/callback", + "state-123" + ); + + assertNotNull(grant.code()); + assertEquals("http://127.0.0.1:49152/callback", grant.redirectUri()); + assertEquals("state-123", grant.state()); + assertTrue(grant.expiresIn() > 0); + assertEquals(1, service.getActiveCodeCount()); + assertEquals(user, service.consumeCode(grant.code())); + assertNull(service.consumeCode(grant.code())); + } + + @Test + void issueCodeRejectsExternalRedirects() { + User user = new User(); + user.setId("user-1"); + + assertThrows( + InvalidAuthenticationRequestException.class, + () -> service.issueCode(user, "https://evil.example/callback", "state") + ); + } +} diff --git a/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java b/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java index 7ba7e98a..7cdb6bfa 100644 --- a/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java +++ b/backend/src/test/java/net/modtale/service/auth/OAuth2LoginServiceTest.java @@ -9,6 +9,7 @@ import net.modtale.service.user.account.AccountService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.registration.ClientRegistration; @@ -80,18 +81,13 @@ void loadUserFallsBackToLoginFlowForAnonymousRequests() { } @Test - void loadUserRejectsGitLabAsAnAnonymousSignInMethod() { + void loadUserRejectsGitlabAsSignInProvider() { AccountService accountService = mock(AccountService.class); AuthenticationService authenticationService = mock(AuthenticationService.class); ObjectProvider requestProvider = mock(ObjectProvider.class); - DefaultOAuth2User upstreamUser = oauthUser("oauth-1", "ada-gl"); - TestOAuth2LoginService service = new TestOAuth2LoginService( - accountService, - authenticationService, - requestProvider, - upstreamUser - ); + DefaultOAuth2User upstreamUser = oauthUser("oauth-1", "ada-gitlab"); + TestOAuth2LoginService service = new TestOAuth2LoginService(accountService, authenticationService, requestProvider, upstreamUser); OAuth2AuthenticationException error = assertThrows( OAuth2AuthenticationException.class, @@ -99,11 +95,37 @@ void loadUserRejectsGitLabAsAnAnonymousSignInMethod() { ); assertEquals("login_failure", error.getError().getErrorCode()); - verify(authenticationService, never()).processUserLogin( - org.mockito.ArgumentMatchers.anyString(), - org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.anyString() + verify(authenticationService, never()).processUserLogin(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void loadUserUsesLoginFlowForLauncherOAuthEvenWithExistingBrowserSession() { + AccountService accountService = mock(AccountService.class); + AuthenticationService authenticationService = mock(AuthenticationService.class); + ObjectProvider requestProvider = mock(ObjectProvider.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + Authentication authentication = mock(Authentication.class); + request.setUserPrincipal(authentication); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, + "http://127.0.0.1:49152/callback" ); + + when(requestProvider.getIfAvailable()).thenReturn(request); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("ada"); + + DefaultOAuth2User upstreamUser = oauthUser("oauth-1", "ada-gh"); + DefaultOAuth2User signedInUser = oauthUser("user-1", "Ada"); + + TestOAuth2LoginService service = new TestOAuth2LoginService(accountService, authenticationService, requestProvider, upstreamUser); + when(authenticationService.processUserLogin("github", upstreamUser, "access-token")).thenReturn(signedInUser); + + OAuth2User result = service.loadUser(oauthRequest("github")); + + assertSame(signedInUser, result); + verify(authenticationService).processUserLogin("github", upstreamUser, "access-token"); + verify(authenticationService, never()).linkAccount(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); } @Test diff --git a/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java b/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java index 91e303d5..505fd556 100644 --- a/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java +++ b/backend/src/test/java/net/modtale/service/auth/OidcLoginServiceTest.java @@ -83,6 +83,37 @@ void loadUserFallsBackToLoginFlowWhenNoAuthenticatedUserExists() { verify(authenticationService, never()).linkAccount(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); } + @Test + void loadUserUsesLoginFlowForLauncherOAuthEvenWithExistingBrowserSession() { + AccountService accountService = mock(AccountService.class); + AuthenticationService authenticationService = mock(AuthenticationService.class); + ObjectProvider requestProvider = mock(ObjectProvider.class); + MockHttpServletRequest request = new MockHttpServletRequest(); + Authentication authentication = mock(Authentication.class); + request.setUserPrincipal(authentication); + request.getSession().setAttribute( + LauncherAuthService.OAUTH_REDIRECT_URI_SESSION_ATTRIBUTE, + "http://127.0.0.1:49152/callback" + ); + + when(requestProvider.getIfAvailable()).thenReturn(request); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("ada"); + + OidcUser upstreamUser = oidcUser(); + DefaultOAuth2User appUser = oauthUser("user-1", "Ada"); + + TestOidcLoginService service = new TestOidcLoginService(accountService, authenticationService, requestProvider, upstreamUser); + when(authenticationService.processUserLogin("google", upstreamUser, "access-token")).thenReturn(appUser); + + OidcUser result = service.loadUser(oidcRequest("google")); + + assertInstanceOf(OidcLoginService.CustomOidcUser.class, result); + assertEquals("Ada", result.getAttribute("login")); + verify(authenticationService).processUserLogin("google", upstreamUser, "access-token"); + verify(authenticationService, never()).linkAccount(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + } + @Test void loadUserMapsOidcAccountCollisionsToTheExpectedOAuthErrorCode() { AccountService accountService = mock(AccountService.class); diff --git a/backend/src/test/java/net/modtale/service/project/version/ArtifactIdentityServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/ArtifactIdentityServiceTest.java new file mode 100644 index 00000000..76c28433 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/version/ArtifactIdentityServiceTest.java @@ -0,0 +1,92 @@ +package net.modtale.service.project.version; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import java.util.List; +import java.util.Map; +import net.modtale.model.dto.project.ArtifactIdentityDTO; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectVersion; +import net.modtale.repository.project.ProjectRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ArtifactIdentityServiceTest { + private ProjectRepository repository; + private CurseForgeApiClient curseForge; + private ArtifactIdentityService service; + + @BeforeEach + void setUp() { + repository = mock(ProjectRepository.class); + curseForge = mock(CurseForgeApiClient.class); + when(repository.findPublishedIdentityIndex()).thenReturn(List.of()); + when(repository.findPublishedByVersionHashes(anyList())).thenReturn(List.of()); + when(repository.findPublishedByManifestIds(anyList())).thenReturn(List.of()); + when(repository.findPublishedByCurseForgeFingerprints(anyList())).thenReturn(List.of()); + when(curseForge.matchArtifacts(anyList())).thenReturn(Map.of()); + service = new ArtifactIdentityService(repository, curseForge); + } + + @Test + void exactSha256WinsAndCarriesTheInstalledVersionId() { + Project project = project("p1", "real-mod", "Real Mod"); + ProjectVersion version = version("v1", "1.2.3", "a".repeat(64), "author:real", 123L); + project.setVersions(List.of(version)); + when(repository.findPublishedByVersionHashes(anyList())).thenReturn(List.of(project)); + + ArtifactIdentityDTO.Response response = service.identify(request("a".repeat(64), 123L, "author:real", "")); + + assertEquals(1, response.matches().size()); + assertEquals("p1", response.matches().getFirst().projectId()); + assertEquals("v1", response.matches().getFirst().versionId()); + assertEquals("sha256", response.matches().getFirst().evidence()); + } + + @Test + void duplicateManifestIdsAreTreatedAsAmbiguous() { + Project one = project("p1", "one", "Same"); + Project two = project("p2", "two", "Same"); + one.setVersions(List.of(version("v1", "1", null, "author:same", null))); + two.setVersions(List.of(version("v2", "1", null, "author:same", null))); + when(repository.findPublishedByManifestIds(anyList())).thenReturn(List.of(one, two)); + assertTrue(service.identify(request(null, null, "author:same", "")).matches().isEmpty()); + } + + @Test + void removesCurseForgeCatalogDuplicateByExactBinaryFingerprint() { + Project project = project("p1", "real-mod", "Real Mod"); + project.setVersions(List.of(version("v1", "1", null, "author:real", 777L))); + when(repository.findPublishedIdentityIndex()).thenReturn(List.of(project)); + CurseForgeApiClient.CurseForgeFile file = new CurseForgeApiClient.CurseForgeFile("9", "1", "mod.jar", "1", + "RELEASE", null, 2L, Map.of(), List.of(), 4, true, 2, 777L); + CurseForgeApiClient.CurseForgeProject cf = new CurseForgeApiClient.CurseForgeProject("42", "real-mod", "Real Mod", + "", "", true, List.of(file)); + CurseForgeApiClient.CurseForgeSearchResult filtered = service.removeModtaleAliases( + new CurseForgeApiClient.CurseForgeSearchResult(List.of(cf), 0, 20, 1)); + assertTrue(filtered.projects().isEmpty()); + assertEquals(0, filtered.totalCount()); + } + + private static ArtifactIdentityDTO.Request request(String hash, Long fingerprint, String manifest, String website) { + return new ArtifactIdentityDTO.Request(List.of(new ArtifactIdentityDTO.Artifact("file.jar", hash, fingerprint, + manifest, "1.2.3", website))); + } + + private static Project project(String id, String slug, String title) { + Project project = new Project(); + project.setId(id); project.setSlug(slug); project.setTitle(title); project.setClassification(ProjectClassification.PLUGIN); + return project; + } + + private static ProjectVersion version(String id, String number, String hash, String manifest, Long fingerprint) { + ProjectVersion version = new ProjectVersion(); + version.setId(id); version.setVersionNumber(number); version.setHash(hash); version.setManifestId(manifest); + version.setManifestVersion(number); version.setCurseForgeFingerprint(fingerprint); + return version; + } +} diff --git a/backend/src/test/java/net/modtale/service/project/version/CurseForgeApiClientTest.java b/backend/src/test/java/net/modtale/service/project/version/CurseForgeApiClientTest.java index 70ff6734..912373c5 100644 --- a/backend/src/test/java/net/modtale/service/project/version/CurseForgeApiClientTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/CurseForgeApiClientTest.java @@ -1,169 +1,176 @@ package net.modtale.service.project.version; -import java.util.List; -import java.util.Optional; -import net.modtale.config.properties.AppCurseForgeProperties; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.web.client.MockRestServiceServer; -import org.springframework.web.client.RestTemplate; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound; -import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + class CurseForgeApiClientTest { @Test - void usesTheDocumentedExactFileEndpointPersistsMetadataAndCachesSuccesses() { - RestTemplate restTemplate = new RestTemplate(); - MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); - - server.expect(once(), requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + void browsesNyoCfAndNormalizesFiltersSortingAndPagination() { + Fixture fixture = fixture(); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/search?q=map&limit=20&offset=20&include_files=true")) .andExpect(method(HttpMethod.GET)) - .andExpect(header("x-api-key", "approved-test-key")) .andExpect(header("User-Agent", "Modtale/1.0 (+https://modtale.net)")) .andRespond(withSuccess(""" - {"data":[{"id":1450386,"gameId":1234,"name":"Simple Compost","slug":"simple-compost","summary":"Compost things","isAvailable":true,"allowModDistribution":false,"logo":{"thumbnailUrl":"https://example.test/icon.png"}}]} - """, MediaType.APPLICATION_JSON)); - server.expect(once(), requestTo("https://api.curseforge.com/v1/mods/1450386/files/8227810")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header("x-api-key", "approved-test-key")) - .andRespond(withSuccess(""" - {"data":{"id":8227810,"modId":1450386,"isAvailable":true,"displayName":"1.0.0","fileName":"SimpleCompost-1.0.0.jar","releaseType":1,"fileStatus":4,"fileDate":"2026-08-01T00:00:00Z","fileLength":2048,"gameVersions":["2026.08"],"hashes":[{"algo":1,"value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"algo":2,"value":"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}} + {"data":[ + {"id":2,"name":"Zed","slug":"zed","download_count":10,"primary_author":"Z","categories":["Utility"],"recent_files":[{"id":20,"file_name":"zed.jar","display_name":"Zed 1","release_type":"release","file_date":"2026-08-01T00:00:00Z","game_versions":["0.6"]}]}, + {"id":1,"name":"Alpha","slug":"alpha","download_count":20,"primary_author":"A","categories":[],"recent_files":[{"id":10,"file_name":"alpha.jar","display_name":"Alpha 1","release_type":"release","file_date":"2026-09-01T00:00:00Z","game_versions":["0.5"]}]} + ],"pagination":{"total":60}} """, MediaType.APPLICATION_JSON)); - CurseForgeApiClient.CurseForgeProject project = client.resolveProject("simple-compost", "8227810").orElseThrow(); - - assertEquals("1450386", project.id()); - assertEquals("Simple Compost", project.title()); - assertEquals(1, project.files().size()); - assertEquals("8227810", project.files().getFirst().id()); - assertEquals("RELEASE", project.files().getFirst().releaseType()); - assertEquals(2048L, project.files().getFirst().fileSize()); - assertEquals("a".repeat(40), project.files().getFirst().hashes().get("sha1")); - assertEquals("b".repeat(32), project.files().getFirst().hashes().get("md5")); - assertEquals(List.of("2026.08"), project.files().getFirst().gameVersions()); - assertEquals(false, project.distributionAllowed()); - - CurseForgeApiClient.CurseForgeProject cached = client.resolveProject("simple-compost", "8227810").orElseThrow(); - assertEquals(project, cached); - server.verify(); + CurseForgeApiClient.CurseForgeSearchResult result = fixture.client.searchMods("map", "0.6", 1, 20, "name"); + + assertEquals(List.of("zed"), result.projects().stream().map(CurseForgeApiClient.CurseForgeProject::slug).toList()); + assertEquals(20, result.index()); + assertEquals(60, result.totalCount()); + assertEquals("RELEASE", result.projects().getFirst().files().getFirst().releaseType()); + fixture.server.verify(); } @Test - void listsRecentFilesAndFiltersUnavailableOrMismatchedResults() { - RestTemplate restTemplate = new RestTemplate(); - MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); - server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) - .andRespond(withSuccess(""" - {"data":[{"id":1450386,"gameId":1234,"name":"Simple Compost","slug":"simple-compost","isAvailable":true}]} - """, MediaType.APPLICATION_JSON)); - server.expect(requestTo("https://api.curseforge.com/v1/mods/1450386/files?pageSize=50")) + void loadsNyoCfProjectFilesDescriptionAndGallery() { + Fixture fixture = fixture(); + expectProject(fixture.server, "simple-compost"); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/files")) .andRespond(withSuccess(""" - {"data":[ - {"id":3,"modId":1450386,"isAvailable":true,"displayName":"older","fileDate":"2026-08-01T00:00:00Z"}, - {"id":4,"modId":1450386,"isAvailable":true,"displayName":"newer","fileDate":"2026-09-01T00:00:00Z"}, - {"id":5,"modId":1450386,"isAvailable":false,"displayName":"withdrawn"}, - {"id":6,"modId":999,"isAvailable":true,"displayName":"wrong project"} - ]} + [{"id":3,"display_name":"older","file_name":"older.jar","file_date":"2026-08-01T00:00:00Z"}, + {"id":4,"display_name":"newer","file_name":"newer.jar","file_date":"2026-09-01T00:00:00Z"}] """, MediaType.APPLICATION_JSON)); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/description")) + .andRespond(withSuccess("{\"mod_id\":1450386,\"description\":\"

Compost

\"}", MediaType.APPLICATION_JSON)); - CurseForgeApiClient.CurseForgeProject project = client.resolveProject("simple-compost", null).orElseThrow(); + CurseForgeApiClient.CurseForgeProject project = fixture.client.resolveProject("simple-compost", null).orElseThrow(); assertEquals(List.of("4", "3"), project.files().stream().map(CurseForgeApiClient.CurseForgeFile::id).toList()); - server.verify(); + assertEquals(List.of("Builder"), project.authors()); + assertEquals(List.of("Gameplay"), project.categories()); + assertEquals(List.of("https://media.forgecdn.net/shot.png"), project.screenshots()); + assertEquals("

Compost

", project.description()); + fixture.server.verify(); } @Test - void rejectsProviderResponsesThatDoNotMatchTheRequestedGameOrSlug() { - RestTemplate restTemplate = new RestTemplate(); - MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); - server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) - .andRespond(withSuccess(""" - {"data":[{"id":1450386,"gameId":4321,"name":"Other","slug":"other","isAvailable":true}]} - """, MediaType.APPLICATION_JSON)); + void resolvesRequestedFilesOnlyThroughExactNyoCfMetadata() { + Fixture fixture = fixture(); + expectProject(fixture.server, "simple-compost"); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/files/8747324")) + .andRespond(withSuccess(exactFileJson(null), MediaType.APPLICATION_JSON)); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/description")) + .andRespond(withResourceNotFound()); + + CurseForgeApiClient.CurseForgeFile file = fixture.client.resolveProject("simple-compost", "8747324") + .orElseThrow().files().getFirst(); - assertTrue(client.resolveProject("simple-compost", null).isEmpty()); - server.verify(); + assertEquals("298f58e5f18c34af847916b4068e2b9fef2f87a0", file.hashes().get("sha1")); + assertEquals("fa60d71f39e775a70e8a997246aec95b", file.hashes().get("md5")); + assertEquals(679752086L, file.fingerprint()); + fixture.server.verify(); } @Test - void failsClosedToTheStableReferenceFlowWhenProviderIsUnavailable() { - RestTemplate restTemplate = new RestTemplate(); - MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); - server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) - .andRespond(withResourceNotFound()); + void usesNyoCfDownloadUrlAndExactIntegrityMetadata() { + Fixture fixture = fixture(); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/files/8747324")) + .andRespond(withSuccess(exactFileJson("https://edge.forgecdn.net/files/8747/324/SimpleCompost.jar"), MediaType.APPLICATION_JSON)); - assertTrue(client.resolveProject("simple-compost", null).isEmpty()); - server.verify(); + CurseForgeApiClient.CurseForgeDownload download = fixture.client.getDownload(1450386, 8747324).orElseThrow(); + + assertEquals("https://edge.forgecdn.net/files/8747/324/SimpleCompost.jar", download.downloadUrl()); + assertEquals(100897L, download.fileSize()); + assertEquals("298f58e5f18c34af847916b4068e2b9fef2f87a0", download.hashes().get("sha1")); + fixture.server.verify(); } @Test - void makesNoRequestWithoutBothAnApprovedKeyAndGameId() { - RestTemplate restTemplate = new RestTemplate(); - MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - CurseForgeApiClient client = new CurseForgeApiClient( - new AppCurseForgeProperties("", 0), - restTemplate - ); + void usesProviderPublicDeliveryWhenNyoCfOmitsADirectUrl() { + Fixture fixture = fixture(); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/files/8747324")) + .andRespond(withSuccess(exactFileJson(null), MediaType.APPLICATION_JSON)); - Optional result = client.resolveProject("simple-compost", null); + CurseForgeApiClient.CurseForgeDownload download = fixture.client.getDownload(1450386, 8747324).orElseThrow(); - assertTrue(result.isEmpty()); - server.verify(); + assertEquals("https://www.curseforge.com/api/v1/mods/1450386/files/8747324/download", download.downloadUrl()); + fixture.server.verify(); } @Test - void rejectsARequestedFileThatIsMissingOrWithdrawn() { - RestTemplate restTemplate = new RestTemplate(); - MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - CurseForgeApiClient client = new CurseForgeApiClient(properties(), restTemplate); - server.expect(requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) + void rejectsMismatchedExactFileMetadata() { + Fixture fixture = fixture(); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/files/8747324")) + .andRespond(withSuccess(exactFileJson(null).replace("\"mod_id\":1450386", "\"mod_id\":999"), MediaType.APPLICATION_JSON)); + + assertTrue(fixture.client.getDownload(1450386, 8747324).isEmpty()); + fixture.server.verify(); + } + + @Test + void identifiesInstalledArtifactByExactFingerprintNotFilenameAlone() { + Fixture fixture = fixture(); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/batch-search")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().json("{\"queries\":[\"SimpleCompost-1.0.0.jar\"]}")) .andRespond(withSuccess(""" - {"data":[{"id":1450386,"gameId":1234,"name":"Simple Compost","slug":"simple-compost","isAvailable":true}]} + {"results":{"SimpleCompost-1.0.0.jar":[{"id":1450386,"slug":"simple-compost"}]}} """, MediaType.APPLICATION_JSON)); - server.expect(requestTo("https://api.curseforge.com/v1/mods/1450386/files/8227810")) - .andRespond(withSuccess("{\"data\":{\"id\":8227810,\"modId\":1450386,\"isAvailable\":false}}", MediaType.APPLICATION_JSON)); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/files")) + .andRespond(withSuccess("[{\"id\":8747324,\"file_name\":\"SimpleCompost-1.0.0.jar\"}]", MediaType.APPLICATION_JSON)); + fixture.server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/1450386/files/8747324")) + .andRespond(withSuccess(exactFileJson(null), MediaType.APPLICATION_JSON)); + + Map matches = fixture.client.matchArtifacts(List.of( + new CurseForgeApiClient.CurseForgeArtifact(679752086L, "/mods/SimpleCompost-1.0.0.jar"), + new CurseForgeApiClient.CurseForgeArtifact(123L, "C:\\Hytale\\mods\\SimpleCompost-1.0.0.jar"))); + + assertEquals(1, matches.size()); + assertEquals(1450386L, matches.get(679752086L).projectId()); + assertEquals(8747324L, matches.get(679752086L).fileId()); + assertTrue(!matches.containsKey(123L)); + fixture.server.verify(); + } - assertTrue(client.resolveProject("simple-compost", "8227810").isEmpty()); - server.verify(); + private static void expectProject(MockRestServiceServer server, String id) { + server.expect(requestTo("https://nyocf.junyo.dev/api/v1/hytale/mods/" + id)) + .andRespond(withSuccess(""" + {"id":1450386,"game_id":70216,"name":"Simple Compost","slug":"simple-compost", + "summary":"Compost things","is_available":true,"download_count":798, + "links":{"website":"https://www.curseforge.com/hytale/mods/simple-compost"}, + "logo":{"thumbnail_url":"https://media.forgecdn.net/icon.png"}, + "authors":[{"name":"Builder"}],"categories":[{"name":"Gameplay"}], + "screenshots":[{"url":"https://media.forgecdn.net/shot.png"}], + "dates":{"modified":"2026-09-01T00:00:00Z"}} + """, MediaType.APPLICATION_JSON)); } - @Test - void handlesRateLimitsAndMalformedResponsesWithoutRetryingOrLeakingErrors() { - RestTemplate throttledTemplate = new RestTemplate(); - MockRestServiceServer throttledServer = MockRestServiceServer.bindTo(throttledTemplate).build(); - CurseForgeApiClient throttledClient = new CurseForgeApiClient(properties(), throttledTemplate); - throttledServer.expect(once(), requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) - .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS).header("Retry-After", "120")); - - assertTrue(throttledClient.resolveProject("simple-compost", null).isEmpty()); - throttledServer.verify(); - - RestTemplate malformedTemplate = new RestTemplate(); - MockRestServiceServer malformedServer = MockRestServiceServer.bindTo(malformedTemplate).build(); - CurseForgeApiClient malformedClient = new CurseForgeApiClient(properties(), malformedTemplate); - malformedServer.expect(once(), requestTo("https://api.curseforge.com/v1/mods/search?gameId=1234&slug=simple-compost&pageSize=1")) - .andRespond(withSuccess("not-json", MediaType.APPLICATION_JSON)); - - assertTrue(malformedClient.resolveProject("simple-compost", null).isEmpty()); - malformedServer.verify(); + private static String exactFileJson(String downloadUrl) { + String download = downloadUrl == null ? "" : ",\"download_url\":\"" + downloadUrl + "\""; + return """ + {"id":8747324,"mod_id":1450386,"game_id":70216,"display_name":"SimpleCompost 1.0.0", + "file_name":"SimpleCompost-1.0.0.jar","release_type":"release","is_available":true, + "file_date":"2026-08-27T15:11:51.190Z","file_length":100897,"download_count":18, + "game_versions":["Early Access"],"hashes":{"sha1":"298f58e5f18c34af847916b4068e2b9fef2f87a0", + "md5":"fa60d71f39e775a70e8a997246aec95b","fingerprint":679752086}%s} + """.formatted(download); } - private static AppCurseForgeProperties properties() { - return new AppCurseForgeProperties("approved-test-key", 1234); + private static Fixture fixture() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + return new Fixture(new CurseForgeApiClient(restTemplate), server); } + + private record Fixture(CurseForgeApiClient client, MockRestServiceServer server) {} } diff --git a/backend/src/test/java/net/modtale/service/project/version/CurseForgeLiveContractTest.java b/backend/src/test/java/net/modtale/service/project/version/CurseForgeLiveContractTest.java new file mode 100644 index 00000000..16b2872b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/project/version/CurseForgeLiveContractTest.java @@ -0,0 +1,59 @@ +package net.modtale.service.project.version; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CurseForgeLiveContractTest { + + @Test + void nyoCfSupportsBrowseDetailIdentityAndVerifiedDownload() throws Exception { + assumeTrue("true".equalsIgnoreCase(System.getenv("NYOCF_LIVE_TESTS")), + "Set NYOCF_LIVE_TESTS=true to run the nyoCF integration contract."); + CurseForgeApiClient client = new CurseForgeApiClient(); + + CurseForgeApiClient.CurseForgeSearchResult catalog = client.searchMods("Simple Compost", null, 0, 20, "downloads"); + CurseForgeApiClient.CurseForgeProject card = catalog.projects().stream() + .filter(project -> "1450386".equals(project.id())).findFirst().orElseThrow(); + CurseForgeApiClient.CurseForgeProject project = client.getProject(Long.parseLong(card.id())).orElseThrow(); + CurseForgeApiClient.CurseForgeFile listed = project.files().getFirst(); + CurseForgeApiClient.CurseForgeProject exactReference = client.resolveProject(project.slug(), listed.id()).orElseThrow(); + CurseForgeApiClient.CurseForgeFile exact = exactReference.files().getFirst(); + CurseForgeApiClient.CurseForgeDownload download = client.getDownload( + Long.parseLong(project.id()), Long.parseLong(exact.id())).orElseThrow(); + + assertFalse(exact.hashes().isEmpty()); + assertTrue(exact.fingerprint() != null && exact.fingerprint() >= 0); + CurseForgeApiClient.CurseForgeFingerprintMatch identity = client.matchArtifacts(List.of( + new CurseForgeApiClient.CurseForgeArtifact(exact.fingerprint(), exact.fileName()))) + .get(exact.fingerprint()); + assertEquals(Long.parseLong(project.id()), identity.projectId()); + assertEquals(Long.parseLong(exact.id()), identity.fileId()); + + HttpResponse response = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build().send( + HttpRequest.newBuilder(URI.create(download.downloadUrl())) + .header("User-Agent", "Modtale/1.0 (+https://modtale.net)") + .header("Referer", "https://www.curseforge.com/").GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertEquals(200, response.statusCode()); + assertEquals(download.fileSize().longValue(), response.body().length); + assertTrue(response.body().length > 4); + assertEquals('P', response.body()[0]); + assertEquals('K', response.body()[1]); + String expectedSha1 = download.hashes().get("sha1"); + if (expectedSha1 != null) { + assertEquals(expectedSha1, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-1").digest(response.body()))); + } + } +} diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java index 21c11181..d4b59093 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionDependencyServiceTest.java @@ -63,36 +63,32 @@ void resolveRequestedDependenciesRequiresAtLeastTwoDependenciesForModpacks() { } @Test - void resolveRequestedDependenciesPreservesOptionalAndEnvironmentForModpacks() { - when(projectService.getRawProjectById("client-mod")) - .thenReturn(project("client-mod", "Client Mod", ProjectStatus.PUBLISHED, "1.0.0")); - when(projectService.getRawProjectById("server-mod")) - .thenReturn(project("server-mod", "Server Mod", ProjectStatus.PUBLISHED, "2.0.0")); - DependencyReferenceRequest client = dependency( - "client-mod", + void resolveRequestedDependenciesPreservesOptionalTypesForModpacks() { + when(projectService.getRawProjectById("first-mod")) + .thenReturn(project("first-mod", "First Mod", ProjectStatus.PUBLISHED, "1.0.0")); + when(projectService.getRawProjectById("second-mod")) + .thenReturn(project("second-mod", "Second Mod", ProjectStatus.PUBLISHED, "2.0.0")); + DependencyReferenceRequest first = dependency( + "first-mod", "1.0.0", ProjectDependency.DependencyType.OPTIONAL ); - client.setEnvironment(ProjectDependency.Environment.CLIENT); - DependencyReferenceRequest server = dependency( - "server-mod", + DependencyReferenceRequest second = dependency( + "second-mod", "2.0.0", ProjectDependency.DependencyType.REQUIRED ); - server.setEnvironment(ProjectDependency.Environment.SERVER); VersionDependencyService.ResolvedDependencies resolved = service.resolveRequestedDependencies( - List.of(client, server), + List.of(first, second), true, false ); assertEquals(ProjectDependency.DependencyType.OPTIONAL, resolved.dependencies().getFirst().getDependencyType()); - assertEquals(ProjectDependency.Environment.CLIENT, - resolved.dependencies().getFirst().getEnvironment()); - assertEquals(ProjectDependency.Environment.SERVER, - resolved.dependencies().get(1).getEnvironment()); + assertEquals(ProjectDependency.DependencyType.REQUIRED, + resolved.dependencies().get(1).getDependencyType()); } @Test diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java index ff322eaa..634ac30d 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java @@ -6,13 +6,13 @@ import net.modtale.exception.InvalidDownloadTokenException; import net.modtale.exception.InvalidVersionRequestException; import net.modtale.exception.ResourceNotFoundException; +import net.modtale.exception.UnauthorizedException; import net.modtale.model.dto.response.project.BundleDownloadUrlResponse; import net.modtale.model.dto.response.project.DownloadUrlResponse; import net.modtale.model.project.Project; import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; -import net.modtale.model.project.ModpackTarget; import net.modtale.model.user.User; import net.modtale.service.analytics.AnalyticsEligibilityService; import net.modtale.service.analytics.TrackingService; @@ -28,7 +28,6 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -72,14 +71,15 @@ void setUp() { @Test void createDownloadUrlAndBundleUrlGenerateShortLivedTokenRoutes() { User user = new User(); + user.setId("user-1"); Project project = project("project-1", "Sky Tools", ProjectClassification.PLUGIN); ProjectVersion version = version("version-1", "1.0.0", "files/mod.jar"); when(projectService.getProjectById("project-1", user)).thenReturn(project); when(projectVersionAccessService.requireByVersionNumber(org.mockito.Mockito.eq(project), org.mockito.Mockito.eq("1.0.0"), org.mockito.Mockito.eq("1.21.0"), org.mockito.Mockito.any())) .thenReturn(version); - when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0")).thenReturn("download-token"); - when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0", List.of("dep-1"))).thenReturn("bundle-token"); + when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0", null, "user-1")).thenReturn("download-token"); + when(downloadTokenService.generateToken("project-1", "1.0.0", "1.21.0", List.of("dep-1"), "user-1")).thenReturn("bundle-token"); when(downloadTokenService.getTokenValiditySeconds()).thenReturn(300); DownloadUrlResponse download = service.createDownloadUrl("project-1", "1.0.0", "1.21.0", user); @@ -99,6 +99,61 @@ void createDownloadUrlRejectsMissingProjects() { ); } + @Test + void curseForgeModpackDownloadUrlsAreLauncherOnly() { + User user = new User(); + user.setId("user-1"); + Project pack = project("pack-1", "Sky Pack", ProjectClassification.MODPACK); + ProjectVersion version = version("version-1", "1.0.0", "modpacks/pack.zip"); + version.setDependencies(List.of(ProjectDependency.curseForge( + "1450386", "Simple Compost", "1.0.0", + "https://www.curseforge.com/hytale/mods/simple-compost", + ProjectDependency.DependencyType.REQUIRED + ))); + + when(projectService.getProjectById("pack-1", user)).thenReturn(pack); + when(projectVersionAccessService.requireByVersionNumber( + org.mockito.Mockito.eq(pack), org.mockito.Mockito.eq("1.0.0"), + org.mockito.Mockito.isNull(), org.mockito.Mockito.any())).thenReturn(version); + when(downloadTokenService.generateToken("pack-1", "1.0.0", null, null, "user-1")) + .thenReturn("launcher-token"); + when(downloadTokenService.getTokenValiditySeconds()).thenReturn(300); + + assertThrows(InvalidVersionRequestException.class, + () -> service.createDownloadUrl("pack-1", "1.0.0", null, user)); + DownloadUrlResponse response = service.createDownloadUrl("pack-1", "1.0.0", null, user, true); + + assertEquals("/download/launcher-token", response.downloadUrl()); + } + + @Test + void curseForgeModpackTokenCanOnlyBeRedeemedByLauncher() throws Exception { + User user = new User(); + Project pack = project("pack-1", "Sky Pack", ProjectClassification.MODPACK); + ProjectVersion version = version("version-1", "1.0.0", "modpacks/pack.zip"); + version.setDependencies(List.of(ProjectDependency.curseForge( + "1450386", "Simple Compost", "1.0.0", + "https://www.curseforge.com/hytale/mods/simple-compost", + ProjectDependency.DependencyType.REQUIRED + ))); + + when(downloadTokenService.validateAndConsume("web-token")).thenReturn(token("pack-1", "1.0.0", null, null)); + when(downloadTokenService.validateAndConsume("launcher-token")).thenReturn(token("pack-1", "1.0.0", null, null)); + when(projectService.getRawProjectById("pack-1")).thenReturn(pack); + when(accessControlService.canReadProject(pack, user)).thenReturn(true); + when(projectVersionAccessService.requireByVersionNumber( + org.mockito.Mockito.eq(pack), org.mockito.Mockito.eq("1.0.0"), + org.mockito.Mockito.isNull(), org.mockito.Mockito.any())).thenReturn(version); + when(downloadService.generateModpackZip(pack, version, user)).thenReturn(new byte[]{9, 8, 7}); + + assertThrows(InvalidVersionRequestException.class, + () -> service.downloadVersion("web-token", false, null, null, null, user)); + VersionDownloadPayload payload = service.downloadVersion( + "launcher-token", true, null, null, null, user, true); + + assertArrayEquals(new byte[]{9, 8, 7}, payload.bytes()); + } + @Test void downloadVersionConsumesTokenChecksReadAccessTracksAndReturnsStoredArtifact() throws Exception { User user = new User(); @@ -154,52 +209,6 @@ void downloadVersionGeneratesModpackZipAndTracksDependencies() throws Exception verify(trackingService).logDownload("dep-1", null, "author-name", true, "198.51.100.9"); } - @Test - void rejectsBrowserDownloadForModpackContainingCurseForgeProjects() { - User user = new User(); - Project pack = project("pack-1", "Sky Pack!", ProjectClassification.MODPACK); - ProjectVersion version = version("version-1", "1.0.0", null); - version.setDependencies(List.of(ProjectDependency.curseForge( - "1450386", "Simple Compost", "1.0.0", - "https://www.curseforge.com/hytale/mods/simple-compost/files/8227810", - ProjectDependency.DependencyType.REQUIRED - ))); - - when(projectService.getProjectById("pack-1", user)).thenReturn(pack); - when(projectVersionAccessService.requireByVersionNumber( - org.mockito.Mockito.eq(pack), org.mockito.Mockito.eq("1.0.0"), - org.mockito.Mockito.isNull(), org.mockito.Mockito.any() - )).thenReturn(version); - - InvalidVersionRequestException error = assertThrows( - InvalidVersionRequestException.class, - () -> service.createDownloadUrl("pack-1", "1.0.0", null, user) - ); - - assertTrue(error.getMessage().contains("Modtale Launcher")); - verify(downloadTokenService, never()).generateToken("pack-1", "1.0.0", null); - } - - @Test - void downloadVersionGeneratesNamedServerVariantFromToken() throws Exception { - User user = new User(); - Project pack = project("pack-1", "Sky Pack!", ProjectClassification.MODPACK); - ProjectVersion version = version("version-1", "1.0.0", "modpacks/pack.zip"); - when(downloadTokenService.validateAndConsume("server-token")).thenReturn(new DownloadTokenService.DownloadToken( - "pack-1", "1.0.0", null, null, ModpackTarget.SERVER, Instant.now().plusSeconds(60) - )); - when(projectService.getRawProjectById("pack-1")).thenReturn(pack); - when(accessControlService.canReadProject(pack, user)).thenReturn(true); - when(projectVersionAccessService.requireByVersionNumber(org.mockito.Mockito.eq(pack), org.mockito.Mockito.eq("1.0.0"), org.mockito.Mockito.isNull(), org.mockito.Mockito.any())) - .thenReturn(version); - when(downloadService.generateModpackZip(pack, version, user, ModpackTarget.SERVER)).thenReturn(new byte[]{7}); - - VersionDownloadPayload payload = service.downloadVersion("server-token", true, null, "198.51.100.9", null, user); - - assertEquals("Sky_Pack_-1.0.0-server.zip", payload.filename()); - assertArrayEquals(new byte[]{7}, payload.bytes()); - } - @Test void downloadBundleTracksOnlySelectedNonEmbeddedDependenciesAndReturnsZipName() throws Exception { User user = new User(); @@ -246,6 +255,25 @@ void downloadRejectsInvalidTokensOrUnreadableProjects() { assertThrows(ResourceNotFoundException.class, () -> service.downloadVersion("unreadable", false, null, null, null, user)); } + @Test + void downloadRejectsUserBoundTokenWithoutMatchingSession() { + User user = new User(); + user.setId("other-user"); + + when(downloadTokenService.validateAndConsume("token")).thenReturn( + new DownloadTokenService.DownloadToken( + "project-1", + "1.0.0", + null, + null, + "user-1", + Instant.now().plusSeconds(60) + ) + ); + + assertThrows(UnauthorizedException.class, () -> service.downloadVersion("token", false, null, null, null, user)); + } + private static DownloadTokenService.DownloadToken token( String projectId, String version, diff --git a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java index 492c995f..b2821fbb 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadServiceTest.java @@ -214,8 +214,8 @@ private static byte[] validEmptyArchive() throws IOException { try (ByteArrayOutputStream output = new ByteArrayOutputStream(); java.util.zip.ZipOutputStream zip = new java.util.zip.ZipOutputStream(output)) { writeEntry(zip, "modpack.json", "{\"formatVersion\":1,\"game\":\"hytale\",\"files\":[]}"); - writeEntry(zip, "manifest.json", "{\"format\":\"modtale-pack\",\"schemaVersion\":1,\"pack\":{},\"game\":{},\"dependencies\":[]}"); - writeEntry(zip, "modtale.lock.json", "{\"format\":\"modtale-lock\",\"lockVersion\":1,\"pack\":{},\"gameVersions\":[],\"entries\":[]}"); + writeEntry(zip, "manifest.json", "{\"format\":\"modtale-pack\",\"schemaVersion\":1,\"pack\":{},\"game\":{\"id\":\"hytale\",\"versions\":[]},\"dependencies\":[]}"); + writeEntry(zip, "modtale.lock.json", "{\"format\":\"modtale-lock\",\"lockVersion\":1,\"game\":\"hytale\",\"pack\":{},\"gameVersions\":[],\"entries\":[]}"); zip.finish(); return output.toByteArray(); } diff --git a/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java b/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java index b752a35e..f2e6cd39 100644 --- a/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/DownloadTokenServiceTest.java @@ -3,7 +3,6 @@ import java.time.Instant; import java.util.List; import java.util.Map; -import net.modtale.model.project.ModpackTarget; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -23,9 +22,40 @@ private Map getTokens() { return (Map) ReflectionTestUtils.getField(downloadTokenService, "tokens"); } + @Test + void concurrentRequestsCanConsumeTokenOnlyOnce() throws Exception { + String token = downloadTokenService.generateToken("project-1", "1.0"); + var start = new java.util.concurrent.CountDownLatch(1); + try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + var attempts = new java.util.ArrayList>(); + for (int i = 0; i < 32; i++) { + attempts.add(executor.submit(() -> { + start.await(); + return downloadTokenService.validateAndConsume(token); + })); + } + start.countDown(); + int successes = 0; + for (var attempt : attempts) { + if (attempt.get(5, java.util.concurrent.TimeUnit.SECONDS) != null) successes++; + } + assertEquals(1, successes); + } + } + + @Test + void missingTokensAreRejectedAndDependencySelectionIsSnapshotted() { + assertNull(downloadTokenService.validateAndConsume(null)); + assertNull(downloadTokenService.validateAndConsume(" ")); + var selected = new java.util.ArrayList<>(List.of("dependency")); + String token = downloadTokenService.generateToken("project", "1.0", null, selected); + selected.clear(); + assertEquals(List.of("dependency"), downloadTokenService.validateAndConsume(token).getSelectedDependencies()); + } + @Test void generateTokenStoresPayloadAndConsumesItOnce() { - String token = downloadTokenService.generateToken("project-1", "1.2.3", "1.0.0", List.of("dep-a", "dep-b")); + String token = downloadTokenService.generateToken("project-1", "1.2.3", "1.0.0", List.of("dep-a", "dep-b"), "user-1"); assertNotNull(token); assertTrue(downloadTokenService.getActiveTokenCount() >= 1); @@ -36,24 +66,13 @@ void generateTokenStoresPayloadAndConsumesItOnce() { assertEquals("project-1", result.getProjectId()); assertEquals("1.2.3", result.getVersion()); assertEquals("1.0.0", result.getGameVersion()); + assertEquals("user-1", result.getUserId()); assertEquals(List.of("dep-a", "dep-b"), result.getSelectedDependencies()); assertTrue(result.isUsed()); assertNull(downloadTokenService.validateAndConsume(token)); assertEquals(0, downloadTokenService.getActiveTokenCount()); } - @Test - void generateTokenPreservesModpackTarget() { - String token = downloadTokenService.generateToken( - "pack-1", "1.2.3", "2026.9", null, ModpackTarget.SERVER - ); - - DownloadTokenService.DownloadToken result = downloadTokenService.validateAndConsume(token); - - assertNotNull(result); - assertEquals(ModpackTarget.SERVER, result.getModpackTarget()); - } - @Test void generateTokenOverloadsCreateDistinctTokens() { String first = downloadTokenService.generateToken("project-1", "1.0.0"); diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java index ea5f222b..9fa86171 100644 --- a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java +++ b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveServiceTest.java @@ -17,7 +17,6 @@ import net.modtale.model.project.ProjectClassification; import net.modtale.model.project.ProjectDependency; import net.modtale.model.project.ProjectVersion; -import net.modtale.model.project.ModpackTarget; import net.modtale.repository.project.ProjectRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -139,7 +138,6 @@ void generateModpackZipWritesExactIntegrityLockfileAndSeparateIntentManifest() t ProjectDependency.DependencyType.OPTIONAL ); curseForge.setExternalFileUrl("https://www.curseforge.com/hytale/mods/external-mod/files/8227810"); - curseForge.setEnvironment(ProjectDependency.Environment.CLIENT); version.setDependencies(List.of(hosted, curseForge)); Project hostedProject = dependencyProject("plugin", ProjectClassification.PLUGIN); @@ -157,10 +155,14 @@ void generateModpackZipWritesExactIntegrityLockfileAndSeparateIntentManifest() t JsonNode lock = new ObjectMapper().readTree(entries.get("modtale.lock.json")); assertEquals("modtale-pack", manifest.get("format").asText()); + assertEquals("hytale", manifest.at("/game/id").asText()); + assertFalse(manifest.has("target")); assertEquals(List.of("2026.8", "2026.9"), new ObjectMapper().convertValue(manifest.at("/game/versions"), List.class)); assertFalse(manifest.toString().contains("cachedFileUrl")); assertEquals("modtale-lock", lock.get("format").asText()); + assertEquals("hytale", lock.get("game").asText()); + assertFalse(lock.has("target")); assertEquals("BUNDLED", lock.at("/entries/0/distribution").asText()); assertEquals("plugin.jar", lock.at("/entries/0/path").asText()); assertEquals(bytes("plugin-binary").length, lock.at("/entries/0/size").asInt()); @@ -169,10 +171,32 @@ void generateModpackZipWritesExactIntegrityLockfileAndSeparateIntentManifest() t assertEquals("1450386", lock.at("/entries/1/provider/projectId").asText()); assertEquals("8227810", lock.at("/entries/1/provider/fileId").asText()); assertEquals("OPTIONAL", lock.at("/entries/1/dependencyType").asText()); - assertEquals("CLIENT", lock.at("/entries/1/environment").asText()); + assertFalse(lock.at("/entries/1").has("environment")); assertFalse(entries.containsKey("External-Mod-1.0.0.jar")); } + @Test + void generateModpackZipIncludesOneUnifiedOverrideTree() throws Exception { + Project pack = pack(); + ProjectVersion version = version("1.0.0", null); + version.setOverrideFileUrl("modpack-overrides/source.zip"); + when(archiveSupport.download("modpack-overrides/source.zip")).thenReturn(zip(Map.of( + "overrides/Mods/example/game.json", "{}", + "overrides/Saves/example/config.json", "{}" + ))); + when(archiveSupport.newZipMultipartFile(eq("sky-pack-1.0.0.zip"), any())) + .thenAnswer(invocation -> mock(MultipartFile.class)); + when(archiveSupport.upload(any(MultipartFile.class), eq("modpacks"))).thenReturn("modpacks/generated.zip"); + + Map entries = unzip(service.generateModpackZip(pack, version)); + JsonNode lock = new ObjectMapper().readTree(entries.get("modtale.lock.json")); + + assertTrue(entries.containsKey("overrides/Mods/example/game.json")); + assertTrue(entries.containsKey("overrides/Saves/example/config.json")); + assertEquals(2, lock.path("overrides").size()); + assertFalse(lock.at("/overrides/0").has("environment")); + } + @Test void generateModpackZipIsByteForByteDeterministicForTheSameInputs() throws Exception { Project pack = pack(); @@ -198,56 +222,6 @@ void generateModpackZipIsByteForByteDeterministicForTheSameInputs() throws Excep assertArrayEquals(first, second); } - @Test - void generateModpackZipFiltersClientAndServerVariantsWithoutCachingThem() throws Exception { - Project pack = pack(); - ProjectVersion version = version("1.0.0", "modpacks/universal.zip"); - ProjectDependency common = new ProjectDependency("common", "Common", "1.0.0"); - ProjectDependency client = new ProjectDependency("client", "Client", "1.0.0"); - client.setEnvironment(ProjectDependency.Environment.CLIENT); - ProjectDependency server = new ProjectDependency("server", "Server", "1.0.0"); - server.setEnvironment(ProjectDependency.Environment.SERVER); - version.setDependencies(List.of(common, client, server)); - version.setOverrideFileUrl("modpack-overrides/source.zip"); - when(archiveSupport.download("modpack-overrides/source.zip")).thenReturn(zip(Map.of( - "overrides/common/config.json", "common", - "overrides/client/ui.toml", "client", - "overrides/server/server.properties", "server" - ))); - - for (ProjectDependency dependency : version.getDependencies()) { - ProjectVersion dependencyVersion = version("1.0.0", "files/" + dependency.getProjectId() + ".jar"); - when(archiveSupport.resolveDependency(dependency)).thenReturn(new DownloadArchiveSupport.ResolvedDependency( - dependencyProject(dependency.getProjectId(), ProjectClassification.PLUGIN), dependencyVersion - )); - when(archiveSupport.download(dependencyVersion.getFileUrl())).thenReturn(bytes(dependency.getProjectId())); - when(archiveSupport.extractOriginalFilename(dependencyVersion.getFileUrl())).thenReturn(dependency.getProjectId() + ".jar"); - } - - Map clientEntries = unzip(service.generateModpackZip(pack, version, ModpackTarget.CLIENT)); - JsonNode clientLock = new ObjectMapper().readTree(clientEntries.get("modtale.lock.json")); - Map serverEntries = unzip(service.generateModpackZip(pack, version, ModpackTarget.SERVER)); - JsonNode serverLock = new ObjectMapper().readTree(serverEntries.get("modtale.lock.json")); - - assertEquals("CLIENT", clientLock.get("target").asText()); - assertTrue(clientEntries.containsKey("common.jar")); - assertTrue(clientEntries.containsKey("client.jar")); - assertFalse(clientEntries.containsKey("server.jar")); - assertTrue(clientEntries.containsKey("overrides/common/config.json")); - assertTrue(clientEntries.containsKey("overrides/client/ui.toml")); - assertFalse(clientEntries.containsKey("overrides/server/server.properties")); - assertEquals(2, clientLock.path("overrides").size()); - assertEquals("SERVER", serverLock.get("target").asText()); - assertTrue(serverEntries.containsKey("common.jar")); - assertFalse(serverEntries.containsKey("client.jar")); - assertTrue(serverEntries.containsKey("server.jar")); - assertTrue(serverEntries.containsKey("overrides/common/config.json")); - assertFalse(serverEntries.containsKey("overrides/client/ui.toml")); - assertTrue(serverEntries.containsKey("overrides/server/server.properties")); - assertEquals("modpacks/universal.zip", version.getFileUrl()); - verify(projectRepository, never()).save(pack); - } - @Test void generateModpackZipPreventsCaseInsensitiveAndTraversalFilenameCollisions() throws Exception { Project pack = pack(); @@ -373,8 +347,8 @@ private static byte[] bytes(String value) { private static byte[] validEmptyArchive() throws IOException { return zip(Map.of( "modpack.json", "{\"formatVersion\":1,\"game\":\"hytale\",\"files\":[]}", - "manifest.json", "{\"format\":\"modtale-pack\",\"schemaVersion\":1,\"pack\":{},\"game\":{},\"dependencies\":[]}", - "modtale.lock.json", "{\"format\":\"modtale-lock\",\"lockVersion\":1,\"pack\":{},\"gameVersions\":[],\"entries\":[]}" + "manifest.json", "{\"format\":\"modtale-pack\",\"schemaVersion\":1,\"pack\":{},\"game\":{\"id\":\"hytale\",\"versions\":[]},\"dependencies\":[]}", + "modtale.lock.json", "{\"format\":\"modtale-lock\",\"lockVersion\":1,\"game\":\"hytale\",\"pack\":{},\"gameVersions\":[],\"entries\":[]}" )); } diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveValidatorTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveValidatorTest.java index fb2250ad..120b80a8 100644 --- a/backend/src/test/java/net/modtale/service/storage/ModpackArchiveValidatorTest.java +++ b/backend/src/test/java/net/modtale/service/storage/ModpackArchiveValidatorTest.java @@ -143,31 +143,22 @@ void rejectsMalformedCurseForgeProviderIntegrityMetadata() throws Exception { } @Test - void rejectsUnknownDependencyEnvironments() throws Exception { - byte[] content = bytes("trusted"); - String invalidLock = lockEntry("MODTALE", "BUNDLED", "plugin.jar", content, false) - .replace("\"environment\":\"COMMON\"", "\"environment\":\"BROWSER\""); - byte[] archive = archive(List.of( + void rejectsNonHytaleManifestsAndLockfiles() throws Exception { + byte[] foreignManifest = archive(List.of( + entry("modpack.json", legacyManifest()), + entry("manifest.json", manifest().replace("\"hytale\"", "\"minecraft\"")), + entry("modtale.lock.json", emptyLock()) + )); + byte[] foreignLock = archive(List.of( entry("modpack.json", legacyManifest()), entry("manifest.json", manifest()), - entry("modtale.lock.json", invalidLock), - new ArchiveEntry("plugin.jar", content) + entry("modtale.lock.json", emptyLock().replace("\"hytale\"", "\"minecraft\"")) )); assertTrue(assertThrows(IOException.class, - () -> ModpackArchiveValidator.validate(archive)).getMessage().contains("unknown environment")); - } - - @Test - void rejectsInconsistentArchiveTargets() throws Exception { - byte[] archive = archive(List.of( - entry("modpack.json", legacyManifest().replace("\"files\"", "\"target\":\"CLIENT\",\"files\"")), - entry("manifest.json", manifest().replace("\"pack\"", "\"target\":\"CLIENT\",\"pack\"")), - entry("modtale.lock.json", emptyLock().replace("\"pack\"", "\"target\":\"SERVER\",\"pack\"")) - )); - + () -> ModpackArchiveValidator.validate(foreignManifest)).getMessage().contains("manifest")); assertTrue(assertThrows(IOException.class, - () -> ModpackArchiveValidator.validate(archive)).getMessage().contains("inconsistent target")); + () -> ModpackArchiveValidator.validate(foreignLock)).getMessage().contains("lockfile")); } private static String emptyLock() { @@ -175,6 +166,7 @@ private static String emptyLock() { { "format": "modtale-lock", "lockVersion": 1, + "game": "hytale", "pack": {}, "gameVersions": [], "entries": [] @@ -187,6 +179,7 @@ private static String curseForgeLock(String fileUrl) { { "format": "modtale-lock", "lockVersion": 1, + "game": "hytale", "pack": {}, "gameVersions": [], "entries": [{ @@ -195,7 +188,6 @@ private static String curseForgeLock(String fileUrl) { "version": "1.0.0", "source": "CURSEFORGE", "dependencyType": "REQUIRED", - "environment": "COMMON", "distribution": "REFERENCE_ONLY", "url": "%s", "fileUrl": "%s", @@ -230,10 +222,11 @@ private static String lockEntry( { "format": "modtale-lock", "lockVersion": 1, + "game": "hytale", "pack": {}, "gameVersions": [], "entries": [ - {"source":"%s","environment":"COMMON","distribution":"%s"%s} + {"source":"%s","distribution":"%s"%s} ] } """.formatted(source, distribution, integrity); @@ -251,7 +244,7 @@ private static String manifest() { "format": "modtale-pack", "schemaVersion": 1, "pack": {}, - "game": {}, + "game": {"id":"hytale","versions":[]}, "dependencies": [] } """; diff --git a/backend/src/test/java/net/modtale/service/storage/ModpackOverrideArchiveTest.java b/backend/src/test/java/net/modtale/service/storage/ModpackOverrideArchiveTest.java index 696f6c66..27f80190 100644 --- a/backend/src/test/java/net/modtale/service/storage/ModpackOverrideArchiveTest.java +++ b/backend/src/test/java/net/modtale/service/storage/ModpackOverrideArchiveTest.java @@ -18,26 +18,27 @@ class ModpackOverrideArchiveTest { @Test - void readsLayeredOverridesAndPreservesPortablePaths() throws Exception { + void readsOverridesAndPreservesPortablePaths() throws Exception { List files = ModpackOverrideArchive.read(new ByteArrayInputStream(zip(Map.of( - "overrides/common/config/game.json", "{}", - "overrides/client/config/ui.toml", "scale=2", - "overrides/server/config/server.properties", "pvp=true" + "overrides/Mods/example/game.json", "{}", + "overrides/Mods/example/ui.toml", "scale=2", + "overrides/Saves/example/config.json", "{}" )))); assertEquals(3, files.size()); - assertTrue(files.stream().anyMatch(file -> file.path().equals("overrides/client/config/ui.toml"))); + assertTrue(files.stream().anyMatch(file -> file.path().equals("overrides/Mods/example/ui.toml"))); } @Test void rejectsTraversalWrongRootsCaseCollisionsScriptsAndNestedArchives() throws Exception { for (Map entries : List.of( - Map.of("overrides/common/../secret.txt", "bad"), + Map.of("overrides/../secret.txt", "bad"), Map.of("config/game.json", "bad"), - new LinkedHashMap<>(Map.of("overrides/common/A.txt", "one", "overrides/common/a.TXT", "two")), - Map.of("overrides/client/install.ps1", "bad"), - Map.of("overrides/server/mods.zip", "bad"), - Map.of("overrides/common/config/NUL.txt", "bad") + new LinkedHashMap<>(Map.of("overrides/Mods/A.txt", "one", "overrides/mods/a.TXT", "two")), + Map.of("overrides/Mods/install.ps1", "bad"), + Map.of("overrides/Mods/mods.zip", "bad"), + Map.of("overrides/Mods/config/NUL.txt", "bad"), + Map.of("overrides/config/settings.json", "not a Hytale root") )) { assertThrows(IOException.class, () -> ModpackOverrideArchive.read(new ByteArrayInputStream(zip(entries)))); } diff --git a/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java b/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java index af61beab..14cf52f3 100644 --- a/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java +++ b/backend/src/test/java/net/modtale/service/user/account/AccountServiceTest.java @@ -1,6 +1,7 @@ package net.modtale.service.user.account; import java.util.Optional; +import net.modtale.model.user.LauncherSettingsSnapshot; import net.modtale.model.user.User; import net.modtale.repository.user.UserRepository; import net.modtale.service.security.validation.SanitizationService; @@ -9,8 +10,12 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.argThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -66,6 +71,93 @@ void getPublicProfileReturnsNullForBlankIdentifiers() { assertNull(accountService.getPublicProfile(" ")); } + @Test + void launcherSettingsAreNormalizedBeforeSaving() { + User user = user("user-1", "ada"); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + LauncherSettingsSnapshot snapshot = new LauncherSettingsSnapshot(); + snapshot.setSettingsHash(" hash "); + LauncherSettingsSnapshot.Preferences preferences = new LauncherSettingsSnapshot.Preferences(); + preferences.setGameVersion(" 1.0 "); + snapshot.setPreferences(preferences); + LauncherSettingsSnapshot.InstalledProject installed = new LauncherSettingsSnapshot.InstalledProject(); + installed.setProjectId(" project-1 "); + installed.setSlug(" slug-one "); + installed.setTitle(" Project One "); + installed.setClassification(" MODPACK "); + installed.setInstalledVersion(" 2.0 "); + installed.setSource(""); + installed.setInstallType(""); + installed.setModpackUnlocked(true); + installed.setDependencyProjectIds(java.util.List.of("dep-1", "dep-1", " ")); + LauncherSettingsSnapshot.InstalledProjectReference bundled = + new LauncherSettingsSnapshot.InstalledProjectReference(); + bundled.setProjectId(" bundled-1 "); + bundled.setSlug(" bundled-slug "); + bundled.setVersionNumber(" 1.5 "); + bundled.setSource(" MODTALE "); + bundled.setExternalId(" external-one "); + installed.setBundledProjects(java.util.List.of(bundled)); + snapshot.setInstalledProjects(java.util.List.of(installed)); + + LauncherSettingsSnapshot saved = accountService.updateLauncherSettings("user-1", snapshot); + + assertEquals("hash", saved.getSettingsHash()); + assertEquals("1.0", saved.getPreferences().getGameVersion()); + assertEquals(1, saved.getInstalledProjects().size()); + assertEquals("project-1", saved.getInstalledProjects().getFirst().getProjectId()); + assertEquals("slug-one", saved.getInstalledProjects().getFirst().getSlug()); + assertEquals("Project One", saved.getInstalledProjects().getFirst().getTitle()); + assertEquals("MODPACK", saved.getInstalledProjects().getFirst().getClassification()); + assertEquals("MODTALE", saved.getInstalledProjects().getFirst().getSource()); + assertEquals("DIRECT", saved.getInstalledProjects().getFirst().getInstallType()); + assertTrue(saved.getInstalledProjects().getFirst().isModpackUnlocked()); + assertEquals(java.util.List.of("dep-1"), saved.getInstalledProjects().getFirst().getDependencyProjectIds()); + assertEquals("bundled-1", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getProjectId()); + assertEquals("bundled-slug", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getSlug()); + assertEquals("1.5", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getVersionNumber()); + assertEquals("MODTALE", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getSource()); + assertEquals("external-one", + saved.getInstalledProjects().getFirst().getBundledProjects().getFirst().getExternalId()); + assertNotNull(saved.getUpdatedAt()); + verify(userRepository).save(argThat(savedUser -> savedUser.getLauncherSettings() == saved)); + } + + @Test + void launcherPreferenceUpdatePreservesStoredInstalledProjects() { + User user = user("user-1", "ada"); + LauncherSettingsSnapshot stored = new LauncherSettingsSnapshot(); + LauncherSettingsSnapshot.InstalledProject installed = new LauncherSettingsSnapshot.InstalledProject(); + installed.setProjectId("project-1"); + installed.setInstalledVersion("2.0"); + stored.setInstalledProjects(java.util.List.of(installed)); + user.setLauncherSettings(stored); + when(userRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + LauncherSettingsSnapshot update = new LauncherSettingsSnapshot(); + update.setSettingsHash(" full-local-hash "); + LauncherSettingsSnapshot.Preferences preferences = new LauncherSettingsSnapshot.Preferences(); + preferences.setGameVersion(" 2.1 "); + update.setPreferences(preferences); + + LauncherSettingsSnapshot saved = accountService.updateLauncherSettingsPreferences("user-1", update); + + assertEquals("full-local-hash", saved.getSettingsHash()); + assertEquals("2.1", saved.getPreferences().getGameVersion()); + assertEquals(1, saved.getInstalledProjects().size()); + assertEquals("project-1", saved.getInstalledProjects().getFirst().getProjectId()); + assertEquals("2.0", saved.getInstalledProjects().getFirst().getInstalledVersion()); + assertNotNull(saved.getUpdatedAt()); + verify(userRepository).save(argThat(savedUser -> savedUser.getLauncherSettings() == saved)); + } + @Test void hytaleConnectionsCannotBeMadePublic() { assertThrows( diff --git a/backend/src/test/java/net/modtale/service/user/account/CurrentUserResolutionServiceTest.java b/backend/src/test/java/net/modtale/service/user/account/CurrentUserResolutionServiceTest.java new file mode 100644 index 00000000..3cc3d601 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/user/account/CurrentUserResolutionServiceTest.java @@ -0,0 +1,40 @@ +package net.modtale.service.user.account; + +import java.util.List; +import java.util.Optional; +import net.modtale.model.user.User; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CurrentUserResolutionServiceTest { + private final UserRepository repository = mock(UserRepository.class); + private final CurrentUserResolutionService service = new CurrentUserResolutionService(repository); + + @Test + void missingStableIdDoesNotAuthenticateReusedUsername() { + User oldPrincipal = new User(); + oldPrincipal.setId("deleted-account"); + oldPrincipal.setUsername("reused-name"); + when(repository.findById("deleted-account")).thenReturn(Optional.empty()); + var authentication = new UsernamePasswordAuthenticationToken(oldPrincipal, null, List.of()); + assertNull(service.resolveCurrentUser(authentication)); + verify(repository, never()).findByUsernameIgnoreCase(anyString()); + } + + @Test + void currentAccountIsResolvedByIdAfterRename() { + User principal = new User(); + principal.setId("account"); + principal.setUsername("previous-name"); + User current = new User(); + current.setId("account"); + current.setUsername("new-name"); + when(repository.findById("account")).thenReturn(Optional.of(current)); + assertSame(current, service.resolveCurrentUser(new UsernamePasswordAuthenticationToken(principal, null, List.of()))); + verify(repository, never()).findByUsernameIgnoreCase(anyString()); + } +} diff --git a/backend/src/test/java/net/modtale/service/worldlist/WorldModListArchiveServiceTest.java b/backend/src/test/java/net/modtale/service/worldlist/WorldModListArchiveServiceTest.java new file mode 100644 index 00000000..41751248 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/worldlist/WorldModListArchiveServiceTest.java @@ -0,0 +1,77 @@ +package net.modtale.service.worldlist; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class WorldModListArchiveServiceTest { + + @Test + void generateZipIncludesManifestReadmeAndDownloadableFilesOnly() throws IOException { + StorageService storageService = mock(StorageService.class); + when(storageService.download("storage/cool.jar")).thenReturn("cool-bytes".getBytes(StandardCharsets.UTF_8)); + + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("Cozy World"); + list.setGameVersion("0.5.0"); + list.setCreatedAt(Instant.parse("2026-06-20T12:00:00Z")); + list.setLastViewedAt(Instant.parse("2026-06-20T12:30:00Z")); + list.setExpiresAt(Instant.parse("2026-07-20T12:00:00Z")); + list.setMods(List.of( + item("Cool Mod", "1.0.0", true, "storage/cool.jar"), + item("External Mod", "0.2.0", false, "") + )); + + byte[] archive = new WorldModListArchiveService(storageService, new ObjectMapper()).generateZip(list); + Map entries = entries(archive); + + assertTrue(entries.containsKey("modtale-list.json")); + assertTrue(entries.get("modtale-list.json").contains("\"createdAt\" : \"2026-06-20T12:00:00Z\"")); + assertTrue(entries.get("README.txt").contains("Cozy World")); + assertEquals("cool-bytes", entries.get("Cool-Mod-1.0.0.jar")); + assertFalse(entries.containsKey("External-Mod-0.2.0.jar")); + } + + private static WorldModList.Item item(String title, String version, boolean downloadable, String fileUrl) { + WorldModList.Item item = new WorldModList.Item(); + item.setId(title); + item.setTitle(title); + item.setVersionNumber(version); + item.setClassification(ProjectClassification.PLUGIN); + item.setSource(ProjectDependency.Source.MODTALE); + item.setDownloadable(downloadable); + item.setFileUrl(fileUrl); + return item; + } + + private static Map entries(byte[] archive) throws IOException { + Map entries = new HashMap<>(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entries.put(entry.getName(), new String(zip.readAllBytes(), StandardCharsets.UTF_8)); + } + } + return entries; + } +} diff --git a/backend/src/test/java/net/modtale/service/worldlist/WorldModListServiceTest.java b/backend/src/test/java/net/modtale/service/worldlist/WorldModListServiceTest.java new file mode 100644 index 00000000..56086cb6 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/worldlist/WorldModListServiceTest.java @@ -0,0 +1,301 @@ +package net.modtale.service.worldlist; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.model.dto.request.worldlist.CreateWorldModListRequest; +import net.modtale.model.dto.worldlist.WorldModListDTO; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectClassification; +import net.modtale.model.project.ProjectDependency; +import net.modtale.model.project.ProjectVersion; +import net.modtale.model.user.User; +import net.modtale.model.worldlist.WorldModList; +import net.modtale.repository.worldlist.WorldModListRepository; +import net.modtale.service.project.access.ProjectVersionAccessService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class WorldModListServiceTest { + + private WorldModListRepository repository; + private ProjectService projectService; + private ProjectVersionAccessService versionAccessService; + private AccessControlService accessControlService; + private WorldModListArchiveService archiveService; + private WorldModListService service; + + @BeforeEach + void setUp() { + repository = mock(WorldModListRepository.class); + projectService = mock(ProjectService.class); + versionAccessService = mock(ProjectVersionAccessService.class); + accessControlService = mock(AccessControlService.class); + archiveService = mock(WorldModListArchiveService.class); + service = new WorldModListService( + repository, + projectService, + versionAccessService, + accessControlService, + archiveService, + new WorldModListMapper(new AppFrontendProperties("https://modtale.test/")) + ); + when(repository.save(any(WorldModList.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void createEnrichesModtaleItemsDedupesAndLeavesExternalItemsListedOnly() { + User owner = owner(); + Project project = project(); + ProjectVersion version = version("1.2.3", "storage/mod.jar"); + project.setVersions(List.of(version)); + + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.isPubliclyReadable(project)).thenReturn(true); + when(accessControlService.canReadProject(project, owner)).thenReturn(true); + when(versionAccessService.findByVersionNumber(project, "1.2.3", "0.5.0")).thenReturn(version); + + CreateWorldModListRequest request = new CreateWorldModListRequest( + "My world share", + "Cozy World", + "0.5.0", + List.of( + new CreateWorldModListRequest.Item( + "group:mod", + "project-1", + "", + "Local name", + "1.2.3", + ProjectClassification.PLUGIN, + ProjectDependency.Source.MODTALE, + "", + "", + "" + ), + new CreateWorldModListRequest.Item( + "group:mod", + "project-1", + "", + "Duplicate", + "1.2.3", + ProjectClassification.PLUGIN, + ProjectDependency.Source.MODTALE, + "", + "", + "" + ), + new CreateWorldModListRequest.Item( + "local:only", + "", + "", + "Local Only", + "0.1.0", + ProjectClassification.PLUGIN, + ProjectDependency.Source.OTHER, + "local:only", + "", + "" + ) + ) + ); + + WorldModListDTO dto = service.create(request, owner); + + UUID.fromString(dto.id()); + assertEquals("My world share", dto.title()); + assertEquals("https://modtale.test/lists/" + dto.id(), dto.shareUrl()); + assertEquals("/lists/" + dto.id() + "/download", dto.downloadUrl()); + assertTrue(dto.launcherInstallUrl().startsWith("modtale://install-list?listId=" + dto.id())); + assertEquals(2, dto.modCount()); + assertEquals(1, dto.downloadableCount()); + assertEquals("Catalog Mod", dto.mods().getFirst().title()); + assertEquals("catalog-mod", dto.mods().getFirst().slug()); + assertEquals("mayuna", dto.mods().getFirst().author()); + assertEquals("A tiny catalog mod.", dto.mods().getFirst().description()); + assertEquals(42, dto.mods().getFirst().downloadCount()); + assertEquals(ProjectDependency.Source.MODTALE, dto.mods().getFirst().source()); + assertEquals("0.1.0", dto.mods().get(1).versionNumber()); + assertEquals("Listed only; Modtale cannot package this external or local file.", dto.mods().get(1).unavailableReason()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(WorldModList.class); + verify(repository).save(saved.capture()); + assertEquals(dto.id(), saved.getValue().getId()); + assertTrue(saved.getValue().getExpiresAt().isAfter(Instant.now().plusSeconds(29L * 24L * 60L * 60L))); + } + + @Test + void viewTouchesListAndExtendsExpiry() { + Instant oldExpiry = Instant.now().plusSeconds(3600); + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("World"); + list.setCreatedAt(Instant.now().minusSeconds(3600)); + list.setLastViewedAt(Instant.now().minusSeconds(1800)); + list.setExpiresAt(oldExpiry); + list.setViewCount(2); + list.setMods(List.of(externalItem())); + + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + + WorldModListDTO dto = service.view("list-1"); + + assertEquals(3, dto.viewCount()); + assertEquals(0, dto.downloadCount()); + assertTrue(dto.expiresAt().isAfter(oldExpiry)); + assertTrue(dto.lastViewedAt().isAfter(list.getCreatedAt())); + verify(repository).save(list); + } + + @Test + void viewHydratesStoredModtaleItemsWithCurrentProjectMetadata() { + Instant oldExpiry = Instant.now().plusSeconds(3600); + WorldModList.Item staleItem = new WorldModList.Item(); + staleItem.setId("item-1"); + staleItem.setProjectId("project-1"); + staleItem.setTitle("Old local title"); + staleItem.setSource(ProjectDependency.Source.OTHER); + staleItem.setDownloadable(true); + + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("World"); + list.setCreatedAt(Instant.now().minusSeconds(3600)); + list.setExpiresAt(oldExpiry); + list.setMods(List.of(staleItem)); + + Project project = project(); + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + when(projectService.getRawProjectById("project-1")).thenReturn(project); + when(accessControlService.isPubliclyReadable(project)).thenReturn(true); + + WorldModListDTO dto = service.view("list-1"); + + WorldModListDTO.Item item = dto.mods().getFirst(); + assertEquals("Catalog Mod", item.title()); + assertEquals("catalog-mod", item.slug()); + assertEquals("author-1", item.authorId()); + assertEquals("mayuna", item.author()); + assertEquals("A tiny catalog mod.", item.description()); + assertEquals("/banners/mod.png", item.bannerUrl()); + assertEquals(42, item.downloadCount()); + assertEquals(7, item.favoriteCount()); + assertEquals(ProjectDependency.Source.MODTALE, item.source()); + verify(repository).save(list); + } + + @Test + void viewHydratesStoredItemsBySlugLikeModIdWhenProjectIdIsMissing() { + WorldModList.Item staleItem = new WorldModList.Item(); + staleItem.setId("item-1"); + staleItem.setModId("AzureDoom:LevelingCore"); + staleItem.setTitle("LevelingCore"); + staleItem.setSource(ProjectDependency.Source.OTHER); + staleItem.setDownloadable(false); + + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("World"); + list.setCreatedAt(Instant.now().minusSeconds(3600)); + list.setExpiresAt(Instant.now().plusSeconds(3600)); + list.setMods(List.of(staleItem)); + + Project project = project(); + project.setSlug("leveling-core"); + project.setTitle("LevelingCore"); + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + when(projectService.getRawProjectByRouteKey("leveling-core")).thenReturn(project); + when(accessControlService.isPubliclyReadable(project)).thenReturn(true); + + WorldModListDTO dto = service.view("list-1"); + + WorldModListDTO.Item item = dto.mods().getFirst(); + assertEquals("project-1", item.projectId()); + assertEquals("leveling-core", item.slug()); + assertEquals("LevelingCore", item.title()); + assertEquals("mayuna", item.author()); + assertEquals(42, item.downloadCount()); + assertEquals(7, item.favoriteCount()); + assertEquals(ProjectDependency.Source.MODTALE, item.source()); + verify(repository).save(list); + } + + @Test + void downloadTouchesListAndBuildsArchive() throws IOException { + WorldModList list = new WorldModList(); + list.setId("list-1"); + list.setTitle("Shared list"); + list.setWorldName("A World"); + list.setExpiresAt(Instant.now().plusSeconds(3600)); + list.setMods(List.of(externalItem())); + + when(repository.findById("list-1")).thenReturn(Optional.of(list)); + when(archiveService.generateZip(list)).thenReturn(new byte[]{1, 2, 3}); + + WorldModListService.Download download = service.download("list-1"); + + assertEquals("A-World-mods.zip", download.filename()); + assertEquals(1, list.getViewCount()); + assertEquals(1, list.getDownloadCount()); + assertEquals(3, download.bytes().length); + } + + private static User owner() { + User user = new User(); + user.setId("user-1"); + user.setUsername("willow"); + return user; + } + + private static Project project() { + Project project = new Project(); + project.setId("project-1"); + project.setSlug("catalog-mod"); + project.setTitle("Catalog Mod"); + project.setAuthorId("author-1"); + project.setAuthor("mayuna"); + project.setDescription("A tiny catalog mod."); + project.setImageUrl("/icons/mod.png"); + project.setBannerUrl("/banners/mod.png"); + project.setClassification(ProjectClassification.PLUGIN); + project.setDownloadCount(42); + project.setFavoriteCount(7); + project.setUpdatedAt("2026-06-02T00:00:00Z"); + return project; + } + + private static ProjectVersion version(String versionNumber, String fileUrl) { + ProjectVersion version = new ProjectVersion(); + version.setId("version-1"); + version.setVersionNumber(versionNumber); + version.setGameVersions(List.of("0.5.0")); + version.setReviewStatus(ProjectVersion.ReviewStatus.APPROVED); + version.setReleaseDate("2026-06-01"); + version.setFileUrl(fileUrl); + return version; + } + + private static WorldModList.Item externalItem() { + WorldModList.Item item = new WorldModList.Item(); + item.setId("item-1"); + item.setTitle("Local Only"); + item.setSource(ProjectDependency.Source.OTHER); + item.setDownloadable(false); + return item; + } +} diff --git a/backend/src/test/java/net/modtale/status/StatusSnapshotFileStoreTest.java b/backend/src/test/java/net/modtale/status/StatusSnapshotFileStoreTest.java new file mode 100644 index 00000000..ab34f16e --- /dev/null +++ b/backend/src/test/java/net/modtale/status/StatusSnapshotFileStoreTest.java @@ -0,0 +1,26 @@ +package net.modtale.status; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class StatusSnapshotFileStoreTest { + @TempDir + Path directory; + + @Test + void nullOrCorruptSnapshotDoesNotPreventStatusStartup() throws Exception { + Path snapshot = directory.resolve("snapshot.json"); + var properties = new StatusServiceProperties(); + properties.setSnapshotPath(snapshot.toString()); + var store = new StatusSnapshotFileStore(properties); + for (String content : List.of("null", "{", "{}")) { + Files.writeString(snapshot, content); + assertEquals(List.of(), store.readHistory()); + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10e40a36..addcb10a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "modtale", "version": "1.0.0", - "hasInstallScript": true, "dependencies": { "@astrojs/node": "^11.1.5", "@astrojs/react": "^6.0.5", diff --git a/frontend/package.json b/frontend/package.json index 601e5abb..d0a2fd0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,8 +12,7 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "postinstall": "node scripts/patch-vitest-entry.mjs", - "test": "vitest run", + "test": "node scripts/run-tests.mjs run", "audit:lighthouse": "npx --yes @lhci/cli@0.15.1 autorun" }, "dependencies": { diff --git a/frontend/public/assets/launcher/patchly.png b/frontend/public/assets/launcher/patchly.png new file mode 100644 index 00000000..86e262bc Binary files /dev/null and b/frontend/public/assets/launcher/patchly.png differ diff --git a/frontend/public/assets/launcher/project.png b/frontend/public/assets/launcher/project.png new file mode 100644 index 00000000..21754683 Binary files /dev/null and b/frontend/public/assets/launcher/project.png differ diff --git a/frontend/public/assets/launcher/voile-mid.png b/frontend/public/assets/launcher/voile-mid.png new file mode 100644 index 00000000..ad1ab0f8 Binary files /dev/null and b/frontend/public/assets/launcher/voile-mid.png differ diff --git a/frontend/public/assets/launcher/voile.png b/frontend/public/assets/launcher/voile.png new file mode 100644 index 00000000..d08744ab Binary files /dev/null and b/frontend/public/assets/launcher/voile.png differ diff --git a/frontend/scripts/patch-vitest-entry.mjs b/frontend/scripts/patch-vitest-entry.mjs deleted file mode 100644 index b3291f5e..00000000 --- a/frontend/scripts/patch-vitest-entry.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { realpathSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const frontendRoot = path.resolve(scriptDir, '..'); -const vitestEntryPath = path.resolve(frontendRoot, 'node_modules/vitest/vitest.mjs'); - -const patchedEntry = `#!/usr/bin/env node -import { realpathSync } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const frontendRoot = realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')) - -process.chdir(frontendRoot) -for (let i = 0; i < process.argv.length; i += 1) { - const arg = process.argv[i] - const normalizedArg = arg.startsWith('/') ? realpathSync(arg) : null - if (normalizedArg === frontendRoot) { - process.argv[i] = '.' - continue - } - if (normalizedArg && normalizedArg.startsWith(\`\${frontendRoot}\${path.sep}\`)) { - process.argv[i] = \`.\${normalizedArg.slice(frontendRoot.length)}\` - } -} - -await import('./dist/cli.js') -`; - -try { - const current = await readFile(vitestEntryPath, 'utf8'); - if (current !== patchedEntry) { - await writeFile(vitestEntryPath, patchedEntry, 'utf8'); - } -} catch (error) { - console.warn(`Skipping Vitest entry patch: ${error instanceof Error ? error.message : String(error)}`); -} diff --git a/frontend/scripts/run-tests.mjs b/frontend/scripts/run-tests.mjs new file mode 100644 index 00000000..a487bc0b --- /dev/null +++ b/frontend/scripts/run-tests.mjs @@ -0,0 +1,17 @@ +import { spawnSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +// Resolve the checkout itself, even when invoked through a symlink. Do not +// modify Vitest's installed executable or derive our root from node_modules. +const frontendRoot = realpathSync(fileURLToPath(new URL('../', import.meta.url))); +const vitestEntry = fileURLToPath(new URL('./vitest.mjs', import.meta.resolve('vitest/package.json'))); +const result = spawnSync(process.execPath, [vitestEntry, ...process.argv.slice(2)], { + cwd: frontendRoot, + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error.message); +} +process.exitCode = result.status ?? 1; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 690b7378..101337d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, Suspense, lazy, useRef } from 'react'; +import React, { useState, useEffect, Suspense, lazy, useCallback, useRef } from 'react'; import { Route, Routes, useNavigate, useLocation, Navigate, BrowserRouter } from 'react-router-dom'; import { StaticRouter } from 'react-router'; import { HelmetProvider } from 'react-helmet-async'; @@ -38,6 +38,9 @@ const Dashboard = lazy(() => import('@/modules/user/views/Dashboard').then((modu const VerifyEmail = lazy(() => import('@/modules/auth/views/VerifyEmail').then((module) => ({ default: module.VerifyEmail }))); const ResetPassword = lazy(() => import('@/modules/auth/views/ResetPassword').then((module) => ({ default: module.ResetPassword }))); const MfaVerify = lazy(() => import('@/modules/auth/views/MfaVerify').then((module) => ({ default: module.MfaVerify }))); +const LauncherAuth = lazy(() => import('@/modules/auth/views/LauncherAuth').then((module) => ({ default: module.LauncherAuth }))); +const LauncherPage = lazy(() => import('@/modules/launcher/views/LauncherPage').then((module) => ({ default: module.LauncherPage }))); +const WorldModListView = lazy(() => import('@/modules/worldlist/views/WorldModListView').then((module) => ({ default: module.WorldModListView }))); const CreateProject = lazy(() => import('@/modules/project/views/CreateProject').then((module) => ({ default: module.CreateProject }))); const ProjectEditorView = lazy(() => import('@/modules/project/views/ProjectEditor').then((module) => ({ default: module.ProjectEditorView }))); const AdminPanel = lazy(() => import('@/modules/admin/views/AdminPanel').then((module) => ({ default: module.AdminPanel }))); @@ -46,6 +49,10 @@ const SwaggerDocs = lazy(() => import('@/modules/core/views/SwaggerDocs').then(( const RouteLoading = () =>
; +type FavoriteToggleOptions = { + onError?: () => void; +}; + const StatusRedirect = () => { const { t } = useTranslation('status'); @@ -73,39 +80,30 @@ const StatusRedirect = () => { ); }; -const hasLikelyAuthCookie = () => { - if (typeof document === 'undefined') return false; - const cookies = document.cookie || ''; - return /(?:^|;\s*)(SESSION|JSESSIONID|XSRF-TOKEN)=/.test(cookies); -}; -const projectRouteBase = (pathname: string) => { - const match = pathname.match(/^\/(project|mod|modpack|world)\/[^/]+/i); - return match ? match[0].toLowerCase() : ''; -}; +const setProjectLikedState = (user: User, projectId: string, liked: boolean): User => { + const likedProjectIds = user.likedProjectIds || []; + const alreadyLiked = likedProjectIds.includes(projectId); + + if (alreadyLiked === liked) return user; -const isProjectModalSubroute = (pathname: string) => ( - /^\/(project|mod|modpack|world)\/[^/]+\/(download|changelog|gallery)\/?$/i.test(pathname) -); + return { + ...user, + likedProjectIds: liked + ? [...likedProjectIds, projectId] + : likedProjectIds.filter(likedProjectId => likedProjectId !== projectId) + }; +}; const ScrollToTop = () => { const { pathname } = useLocation(); - const previousPathRef = useRef(null); + const previousPathnameRef = useRef(undefined); useEffect(() => { - const previousPath = previousPathRef.current; - const previousProjectBase = previousPath ? projectRouteBase(previousPath) : ''; - const nextProjectBase = projectRouteBase(pathname); - const isSameProjectModalTransition = Boolean( - previousPath - && previousProjectBase - && previousProjectBase === nextProjectBase - && (isProjectModalSubroute(previousPath) || isProjectModalSubroute(pathname)) - ); - - previousPathRef.current = pathname; - - if (isSameProjectModalTransition) { + const previousPathname = previousPathnameRef.current; + previousPathnameRef.current = pathname; + + if (previousPathname && SiteRoutes.isSameProjectModalContext(previousPathname, pathname)) { return; } @@ -123,6 +121,8 @@ const AppContent: React.FC = () => { const [showOnboarding, setShowOnboarding] = useState(false); const [isDarkMode, setIsDarkMode] = useState(true); const [statusModal, setStatusModal] = useState<{ type: 'success' | 'error' | 'warning' | 'info'; title: string; msg: string } | null>(null); + const userRef = useRef(null); + const pendingFavoriteIdsRef = useRef>(new Set()); const navigate = useNavigate(); const location = useLocation(); @@ -132,10 +132,11 @@ const AppContent: React.FC = () => { const params = new URLSearchParams(location.search); const oauthError = params.get('oauth_error'); if (oauthError) { - const decodedError = decodeURIComponent(oauthError).replace(/\+/g, ' '); - setGlobalError(decodedError); + setGlobalError(oauthError); clearPendingSignInMethod(); - navigate(location.pathname, { replace: true }); + params.delete('oauth_error'); + const remainingSearch = params.toString(); + navigate(`${location.pathname}${remainingSearch ? `?${remainingSearch}` : ''}`, { replace: true }); } }, [location, navigate]); @@ -161,35 +162,40 @@ const AppContent: React.FC = () => { }); }; - const fetchUser = async () => { - if (!hasLikelyAuthCookie()) { - setLoadingAuth(false); - return; - } + useEffect(() => { + userRef.current = user; + }, [user]); + const fetchUser = useCallback(async () => { + // Session cookies can be HttpOnly or scoped to another API host. + // Only the server can reliably tell whether this browser is signed in. try { const res = await api.get(`/user/me?t=${Date.now()}`); if (res.data) { - setUser(normalizeUser(res.data)); + const normalizedUser = normalizeUser(res.data); + userRef.current = normalizedUser; + setUser(normalizedUser); completeSignInMethod(); if ((res.data as any).is_new_account) { setShowOnboarding(true); } } } catch (e: any) { + userRef.current = null; setUser(null); } finally { setLoadingAuth(false); } - }; + }, []); useEffect(() => { fetchUser(); - }, []); + }, [fetchUser]); const handleLogout = async () => { try { await api.post('/auth/logout'); + userRef.current = null; setUser(null); setShowOnboarding(false); navigate(SiteRoutes.home()); @@ -201,20 +207,37 @@ const AppContent: React.FC = () => { const handleNavigate = (page: string) => { navigate(page === 'home' ? SiteRoutes.home() : `/${page}`); }; const handleUserClick = (userId: string, username?: string) => { navigate(SiteRoutes.creator(userId, username)); }; - const handleToggleFavorite = async (id: string) => { - if (!user) return; - const previousUser = user; - const likedProjectIds = user.likedProjectIds || []; - const isLiked = likedProjectIds.includes(id); - const newProjectLikes = isLiked ? likedProjectIds.filter(lid => lid !== id) : [...likedProjectIds, id]; - setUser({ ...user, likedProjectIds: newProjectLikes }); - try { - await api.post(`/projects/${id}/favorite`); - } catch (e) { - setUser(previousUser); - fetchUser(); - } - }; + const handleToggleFavorite = useCallback((id: string, options?: FavoriteToggleOptions) => { + if (!id || pendingFavoriteIdsRef.current.has(id)) return undefined; + + const currentUser = userRef.current; + if (!currentUser) return undefined; + + const wasLiked = (currentUser.likedProjectIds || []).includes(id); + const nextLiked = !wasLiked; + const nextUser = setProjectLikedState(currentUser, id, nextLiked); + + userRef.current = nextUser; + pendingFavoriteIdsRef.current.add(id); + setUser(nextUser); + + api.post(`/projects/${id}/favorite`) + .catch(() => { + setUser(latestUser => { + if (!latestUser || latestUser.id !== currentUser.id) return latestUser; + const revertedUser = setProjectLikedState(latestUser, id, wasLiked); + userRef.current = revertedUser; + return revertedUser; + }); + options?.onError?.(); + fetchUser(); + }) + .finally(() => { + pendingFavoriteIdsRef.current.delete(id); + }); + + return nextLiked; + }, [fetchUser]); const handleDownload = (id: string) => { if (!downloadedSessionIds.has(id)) setDownloadedSessionIds(prev => new Set(prev).add(id)); }; const onShowStatus = (type: 'success' | 'error' | 'warning' | 'info', title: string, msg: string) => setStatusModal({ type, title, msg }); @@ -344,6 +367,9 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/ui/MarkdownRichRenderer.tsx b/frontend/src/components/ui/MarkdownRichRenderer.tsx index 8bd203c5..298f7f04 100644 --- a/frontend/src/components/ui/MarkdownRichRenderer.tsx +++ b/frontend/src/components/ui/MarkdownRichRenderer.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, lazy, useEffect, useState } from 'react'; +import React, { Suspense, lazy, useEffect, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; @@ -99,6 +99,44 @@ const CodeFallback = ({ content }: { content: string }) => ( ); +const fallbackCopyText = (content: string) => { + if (typeof document === 'undefined') { + return false; + } + + const textarea = document.createElement('textarea'); + textarea.value = content; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.top = '-9999px'; + textarea.style.left = '-9999px'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + const legacyDocument = document as unknown as { execCommand?: (command: string) => boolean }; + + try { + return legacyDocument.execCommand?.('copy') ?? false; + } finally { + document.body.removeChild(textarea); + } +}; + +const copyText = async (content: string) => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(content); + return true; + } + } catch { + // Fall through to the textarea fallback for non-secure or embedded browser contexts. + } + + return fallbackCopyText(content); +}; + const MermaidFallback = ({ content }: { content: string }) => (
@@ -147,9 +185,16 @@ const DeferredMermaidChart = ({ content }: { content: string }) => { const CodeBlock = ({ node: _node, inline, className, children, ...props }: any) => { const [copied, setCopied] = useState(false); + const resetTimerRef = useRef(null); const match = /language-(\w+)/.exec(className || ''); const isBlock = !inline && (match || String(children).includes('\n')); + useEffect(() => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, []); + if (isBlock) { const lang = match ? match[1] : 'text'; const content = String(children).replace(/\n$/, ''); @@ -158,10 +203,20 @@ const CodeBlock = ({ node: _node, inline, className, children, ...props }: any) return ; } - const handleCopy = () => { - navigator.clipboard.writeText(content); + const handleCopy = async () => { + const copiedToClipboard = await copyText(content); + if (!copiedToClipboard) { + return; + } + setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + setCopied(false); + resetTimerRef.current = null; + }, 2000); }; return ( @@ -169,7 +224,8 @@ const CodeBlock = ({ node: _node, inline, className, children, ...props }: any)
{lang} diff --git a/frontend/src/data/seo-constants.ts b/frontend/src/data/seo-constants.ts index 3f7e3299..b07b1402 100644 --- a/frontend/src/data/seo-constants.ts +++ b/frontend/src/data/seo-constants.ts @@ -88,7 +88,7 @@ export const ROUTE_SEO: Record = { { href: '/plugins', label: 'Hytale Plugins', - description: 'Find server-side Hytale plugins and gameplay scripts.', + description: 'Find Hytale plugins and gameplay scripts.', }, { href: '/modpacks', @@ -111,6 +111,58 @@ export const ROUTE_SEO: Record = { }, ], }, + '/launcher': { + title: 'Modtale Launcher | Native Hytale Mod Manager', + h1: 'Modtale Launcher', + description: 'Download the Modtale Launcher for Windows, macOS, and Linux. Install, update, and manage Hytale projects with a native launcher built for Modtale releases.', + keywords: 'modtale launcher, hytale launcher, hytale mod manager, hytale mods launcher, download hytale mods, modtale download', + intro: 'The Modtale Launcher is a native desktop app for browsing Modtale projects, installing compatible Hytale releases, resolving dependencies, and keeping your local library ready to play.', + contentBlocks: [ + { + title: 'Desktop Launcher Packages', + body: 'Download a self-contained Modtale Launcher package for your desktop platform and manage Hytale mods, plugins, worlds, assets, and modpacks from one app.', + }, + { + title: 'Install Compatible Project Releases', + body: 'The launcher works with Modtale project metadata to help players choose compatible builds, review dependencies, and install projects into the right local Hytale folder.', + }, + { + title: 'Built Alongside the Modtale Platform', + body: 'Launcher releases are published from the same open-source Modtale project, with package formats for Windows, macOS, and Linux.', + }, + ], + relatedLinks: [ + { + href: '/mods', + label: 'Browse Hytale Projects', + description: 'Explore projects before opening them in the launcher.', + }, + { + href: '/modpacks', + label: 'Hytale Modpacks', + description: 'Find curated collections that benefit from dependency-aware installs.', + }, + { + href: '/upload', + label: 'Publish a Project', + description: 'Share your Hytale work with players on Modtale.', + }, + ], + faq: [ + { + question: 'Does the Modtale Launcher need Java installed?', + answer: 'No. The native launcher packages embed their own runtime, so players do not need to install a separate JDK or JRE.', + }, + { + question: 'Which desktop platforms does the Modtale Launcher support?', + answer: 'Modtale publishes launcher packages for Windows, macOS, and Linux. The launcher page detects your platform and links to the best available release asset when GitHub release metadata is available.', + }, + { + question: 'What does the launcher manage?', + answer: 'The launcher can browse Modtale projects, install compatible project releases, help with dependencies, check installed projects for updates, and connect to Hytale launch flows.', + }, + ], + }, '/status': { title: 'System Status | Modtale', h1: 'Modtale System Status', @@ -211,7 +263,7 @@ export const ROUTE_SEO: Record = { h1: 'Hytale Plugins', description: 'Browse Hytale plugins for servers and communities. Find admin tools, gameplay extensions, economy systems, minigames, moderation helpers, and reusable plugin libraries.', keywords: 'hytale plugins, hytale plugin, hytale server plugins, java plugins, hytale admin tools, hytale modding plugins, server automation', - intro: 'Browse Hytale plugins built for server operators, creators, and communities. Discover gameplay extensions, admin tooling, utility libraries, and server-side Java plugin projects from the Modtale ecosystem.', + intro: 'Browse Hytale plugins built for players, creators, and communities. Discover gameplay extensions, admin tooling, utility libraries, and Java plugin projects from the Modtale ecosystem.', contentBlocks: [ { title: 'Hytale Plugins for Real Server Needs', @@ -246,11 +298,11 @@ export const ROUTE_SEO: Record = { faq: [ { question: 'What is a Hytale plugin?', - answer: 'A Hytale plugin is a server-focused extension, typically packaged as a Java plugin, that adds new multiplayer features, moderation tools, game systems, or automation to a Hytale server environment.', + answer: 'A Hytale plugin is an extension, typically packaged as a Java plugin, that adds gameplay features, moderation tools, game systems, or automation to Hytale.', }, { question: 'How are Hytale plugins different from general Hytale mods?', - answer: 'Plugins usually focus on server behavior, administration, and shared gameplay systems, while broader Hytale mods can also include asset packs, worlds, standalone content releases, or client-facing gameplay changes.', + answer: 'Plugins are code extensions for gameplay, administration, and shared systems, while the broader mod label also includes asset packs, worlds, data assets, and standalone content releases.', }, { question: 'Can I use Modtale to publish Hytale plugins?', @@ -287,7 +339,7 @@ export const ROUTE_SEO: Record = { { href: '/plugins', label: 'Hytale Plugins', - description: 'Find server-side releases that can appear inside curated packs.', + description: 'Find plugin releases that can appear inside curated packs.', }, ], faq: [ @@ -423,7 +475,7 @@ export const ROUTE_SEO: Record = { { href: '/plugins', label: 'Hytale Plugins', - description: 'Pair data assets with server-side gameplay systems.', + description: 'Pair data assets with Hytale gameplay systems.', }, { href: '/mods', diff --git a/frontend/src/hooks/useScrollLock.ts b/frontend/src/hooks/useScrollLock.ts index a3794f85..6f43914f 100644 --- a/frontend/src/hooks/useScrollLock.ts +++ b/frontend/src/hooks/useScrollLock.ts @@ -1,10 +1,12 @@ import { useEffect } from 'react'; let scrollLockCount = 0; +let originalOverflow = ''; export const useScrollLock = (lock: boolean) => { useEffect(() => { if (lock) { + if (scrollLockCount === 0) originalOverflow = document.body.style.overflow; scrollLockCount++; document.body.style.overflow = 'hidden'; } @@ -13,7 +15,7 @@ export const useScrollLock = (lock: boolean) => { scrollLockCount--; if (scrollLockCount <= 0) { scrollLockCount = 0; - document.body.style.overflow = ''; + document.body.style.overflow = originalOverflow; } } }; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 569c3e19..e6a634c8 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -24,6 +24,7 @@ const en = { worlds: 'Worlds', artAssets: 'Art Assets', dataAssets: 'Data Assets', + launcher: 'Launcher', api: 'API', dashboard: 'Dashboard', createProject: 'Create Project', diff --git a/frontend/src/index.css b/frontend/src/index.css index 550af8db..03914796 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -21,6 +21,7 @@ margin: 0; padding: 0; min-height: 100vh; + letter-spacing: 0; } #root { diff --git a/frontend/src/modules/admin/components/PlatformAnalytics.tsx b/frontend/src/modules/admin/components/PlatformAnalytics.tsx index aaaa2872..33f74f7f 100644 --- a/frontend/src/modules/admin/components/PlatformAnalytics.tsx +++ b/frontend/src/modules/admin/components/PlatformAnalytics.tsx @@ -24,7 +24,7 @@ const SummaryCard = ({ title, value, subValue, trend, icon: Icon, color, isPerce

{title}

-
+
{value}{isPercent && %}
{subValue &&
{subValue}
} @@ -145,7 +145,7 @@ export function PlatformAnalytics() { )}
-

Platform Analytics

+

Platform Analytics

Monitor platform-wide statistics and growth.

diff --git a/frontend/src/modules/admin/components/UserManagement.tsx b/frontend/src/modules/admin/components/UserManagement.tsx index 0ff9a1af..fb09edd5 100644 --- a/frontend/src/modules/admin/components/UserManagement.tsx +++ b/frontend/src/modules/admin/components/UserManagement.tsx @@ -434,7 +434,7 @@ export function UserManagement({ setStatus, currentAdmin: initialAdmin }: { setS {foundUser.username}
-

{foundUser.username}

+

{foundUser.username}

Active Roles
diff --git a/frontend/src/modules/admin/components/VerificationQueue.tsx b/frontend/src/modules/admin/components/VerificationQueue.tsx index 8f58a317..f09898e1 100644 --- a/frontend/src/modules/admin/components/VerificationQueue.tsx +++ b/frontend/src/modules/admin/components/VerificationQueue.tsx @@ -59,7 +59,7 @@ export const VerificationQueue: React.FC = ({
-

+

{mod.title} {mod.classification}

diff --git a/frontend/src/modules/admin/views/AdminPanel.tsx b/frontend/src/modules/admin/views/AdminPanel.tsx index 31944f18..4ee3e984 100644 --- a/frontend/src/modules/admin/views/AdminPanel.tsx +++ b/frontend/src/modules/admin/views/AdminPanel.tsx @@ -291,7 +291,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'verification' && canReadReviewQueue && (
-

Verification Queue

+

Verification Queue

Review pending projects and updates.

{queueError && ( @@ -316,7 +316,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'reports' && canReadReports && (
-

Report Queue

+

Report Queue

Handle content violations and user reports.

{reportsError && ( @@ -343,7 +343,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'projects' && canUseProjectManagement && (
-

Project Management

+

Project Management

Manage, unlist, or delete any project.

@@ -353,7 +353,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'users' && canUseUserManagement && (
-

User Management

+

User Management

Manage roles, tiers, and user statuses.

@@ -363,7 +363,7 @@ export function AdminPanel({ currentUser }: AdminPanelProps) { {activeTab === 'logs' && canReadLogs && (
-

Audit Logs

+

Audit Logs

Review all administrative actions.

diff --git a/frontend/src/modules/admin/views/Review.tsx b/frontend/src/modules/admin/views/Review.tsx index c2cc7388..6312c7cd 100644 --- a/frontend/src/modules/admin/views/Review.tsx +++ b/frontend/src/modules/admin/views/Review.tsx @@ -328,7 +328,7 @@ export const Review: React.FC = ({ reviewingProject, onClose, onApp
-

{mod.title}

+

{mod.title}

{mod.id} @@ -875,7 +875,7 @@ export const Review: React.FC = ({ reviewingProject, onClose, onApp
-

+

{isNewProject ? "Approve Project?" : "Approve Update?"}

diff --git a/frontend/src/modules/auth/api/authClient.ts b/frontend/src/modules/auth/api/authClient.ts index 2e5615d9..ef5af365 100644 --- a/frontend/src/modules/auth/api/authClient.ts +++ b/frontend/src/modules/auth/api/authClient.ts @@ -103,5 +103,8 @@ export const authClient = { }, validateMfaLogin: async (data: { pre_auth_token: string | null; code: string }) => { return await api.post('/auth/mfa/validate-login', data); + }, + issueLauncherAuthCode: async (data: { redirectUri: string; state?: string | null }) => { + return await api.post('/auth/launcher/issue', data); } }; diff --git a/frontend/src/modules/auth/components/SignInModal.tsx b/frontend/src/modules/auth/components/SignInModal.tsx index 06994a14..55514941 100644 --- a/frontend/src/modules/auth/components/SignInModal.tsx +++ b/frontend/src/modules/auth/components/SignInModal.tsx @@ -1,8 +1,9 @@ +import { useScrollLock } from '@/hooks/useScrollLock'; import React, { useState, useEffect } from 'react'; import { X, ArrowRight, Loader2, ArrowLeft, CheckCircle2 } from 'lucide-react'; import { DiscordBrandIcon, GitHubBrandIcon, GoogleBrandIcon, HytaleBrandIcon } from '@/components/ui/icons/BrandIcons'; import { useLocation, useNavigate } from 'react-router-dom'; -import { BACKEND_URL, extractApiErrorMessage } from '@/utils/api'; +import { API_BASE_URL, extractApiErrorMessage } from '@/utils/api'; import { StatusModal } from '@/components/ui/StatusModal'; import { ModalPortal } from '@/components/ui/ModalPortal'; import { useToast } from '@/components/ui/Toast'; @@ -34,14 +35,14 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); - const [statusModal, setStatusModal] = useState<{ title: string; msg: string } | null>(null); + const [statusModal, setStatusModal] = useState<{ type?: 'error' | 'info'; title: string; msg: string } | null>(null); const [lastSignInMethod, setLastSignInMethod] = useState(null); + useScrollLock(isOpen); + useEffect(() => { setMounted(true); if (isOpen) setLastSignInMethod(getLastSignInMethod()); - if (isOpen) document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = ''; }; }, [isOpen]); if (!isOpen || !mounted) return null; @@ -79,7 +80,10 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) { const handleOAuthLogin = (provider: OAuthSignInMethod) => { stageSignInMethod(provider); - window.location.href = `${BACKEND_URL}/oauth2/authorization/${provider}`; + const params = new URLSearchParams(); + if (redirectTo) params.set('redirect', redirectTo); + const query = params.toString(); + window.location.href = `${API_BASE_URL}/auth/oauth/${provider}${query ? `?${query}` : ''}`; }; const handleSubmit = async (e: React.FormEvent) => { @@ -151,20 +155,20 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) {

{statusModal && ( setStatusModal(null)} /> )} -
e.stopPropagation()}> +
e.stopPropagation()}>
-

+

{mode === 'signin' ? 'Welcome Back' : (mode === 'register' ? 'Create Account' : 'Reset Password')}

@@ -184,7 +188,8 @@ export function SignInModal({ isOpen, onClose }: SignInModalProps) { + +

+
+
+ ); +} diff --git a/frontend/src/modules/core/components/Navbar.tsx b/frontend/src/modules/core/components/Navbar.tsx index b040effe..b34cff7f 100644 --- a/frontend/src/modules/core/components/Navbar.tsx +++ b/frontend/src/modules/core/components/Navbar.tsx @@ -1,5 +1,5 @@ import React, { lazy, Suspense, useState, useRef, useEffect } from 'react'; -import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, Code2, ChevronDown, LayoutGrid } from 'lucide-react'; +import { Menu, X, Upload, LayoutDashboard, User as UserIcon, LogOut, Shield, Users, LogIn, ChevronDown, LayoutGrid, MonitorDown } from 'lucide-react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { AnimatedThemeToggler } from '@/components/ui/AnimatedThemeToggler'; import { useMobile } from '@/context/MobileContext'; @@ -90,8 +90,8 @@ export const Navbar: React.FC = ({ const projectTypeLabel = (id: string, fallback: string) => { switch (id) { case 'All': return t('navigation:allProjects'); - case 'MODPACK': return t('navigation:modpacks'); case 'PLUGIN': return t('navigation:plugins'); + case 'MODPACK': return t('navigation:modpacks'); case 'SAVE': return t('navigation:worlds'); case 'ART': return t('navigation:artAssets'); case 'DATA': return t('navigation:dataAssets'); @@ -177,15 +177,15 @@ export const Navbar: React.FC = ({
- - {t('navigation:api')} + + {t('navigation:launcher')} {user && ( <> @@ -330,7 +330,7 @@ export const Navbar: React.FC = ({
- setIsMobileMenuOpen(false)} className="flex items-center p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left"> {t('navigation:api')} + setIsMobileMenuOpen(false)} className="flex items-center p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left"> {t('navigation:launcher')} {user && ( <> setIsMobileMenuOpen(false)} className="flex items-center p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 font-bold text-slate-700 dark:text-slate-200 text-left"> {t('navigation:dashboard')} diff --git a/frontend/src/modules/core/views/ApiDocs.tsx b/frontend/src/modules/core/views/ApiDocs.tsx index bdaec03d..a7f30ab3 100644 --- a/frontend/src/modules/core/views/ApiDocs.tsx +++ b/frontend/src/modules/core/views/ApiDocs.tsx @@ -441,7 +441,7 @@ const sampleUserSummary = { username: 'modtale_creator', avatarUrl: 'https://cdn.modtale.net/avatars/modtale_creator.png', bannerUrl: 'https://cdn.modtale.net/banners/modtale_creator.png', - bio: 'Creator of performance-focused Minecraft tools.', + bio: 'Creator of performance-focused Hytale tools.', createdAt: '2025-01-16T13:44:02Z', tier: 'STANDARD', roles: ['USER'], @@ -520,7 +520,7 @@ const sampleProject = { docs: 'https://docs.modtale.net/skyforge-utilities', issues: 'https://github.com/modtale/skyforge-utilities/issues', }, - types: ['SERVER'], + types: [], allowModpacks: true, allowComments: true, hmWikiEnabled: true, @@ -1387,7 +1387,7 @@ export const ApiDocs: React.FC = () => {
-

+

Modtale API v1

diff --git a/frontend/src/modules/discovery/components/BrowseFilters.tsx b/frontend/src/modules/discovery/components/BrowseFilters.tsx index 988bd450..f41f7d09 100644 --- a/frontend/src/modules/discovery/components/BrowseFilters.tsx +++ b/frontend/src/modules/discovery/components/BrowseFilters.tsx @@ -1,3 +1,4 @@ +import { useScrollLock } from '@/hooks/useScrollLock'; import React, { useState, useEffect, useRef, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { @@ -199,11 +200,7 @@ export const BrowseFilters: React.FC = React.memo(({ const hasLoadedGameVersionsRef = useRef(false); const selectedVersions = useMemo(() => parseSelectedVersions(selectedVersion), [selectedVersion]); - useEffect(() => { - if (isMobile && isFilterOpen) document.body.style.overflow = 'hidden'; - else document.body.style.overflow = ''; - return () => { document.body.style.overflow = ''; }; - }, [isMobile, isFilterOpen]); + useScrollLock(isMobile && isFilterOpen); useEffect(() => { const handleClick = (e: MouseEvent) => { @@ -442,25 +439,27 @@ export const BrowseFilters: React.FC = React.memo(({ ))}

- onItemsPerPageChange(Number(value))} - onOpen={() => setIsTagsOpen(false)} - options={BROWSE_ITEMS_PER_PAGE_OPTIONS.map(size => ({ - value: String(size), - label: String(size) - }))} - placeholder="12" - containerClassName="relative flex-none h-10 w-16" - buttonLabel={itemsPerPage} - buttonAriaLabel="Results per page" - buttonTitle="Results per page" - showSelectedCheck={false} - buttonClassName="w-full h-full flex items-center justify-center gap-1 border rounded-xl px-2 text-xs font-black transition-all whitespace-nowrap bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/[0.02] shadow-sm" - menuAlign="right" - menuClassName="w-16 max-w-[calc(100vw-2rem)] bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-xl py-1 z-[70] animate-in fade-in zoom-in-95 duration-200 overflow-hidden" - optionClassName="w-full px-2 py-2 text-sm font-bold flex justify-center items-center transition-colors text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/5" - /> + {!isMobile && ( + onItemsPerPageChange(Number(value))} + onOpen={() => setIsTagsOpen(false)} + options={BROWSE_ITEMS_PER_PAGE_OPTIONS.map(size => ({ + value: String(size), + label: String(size) + }))} + placeholder="12" + containerClassName="relative flex-none h-10 w-16" + buttonLabel={itemsPerPage} + buttonAriaLabel="Results per page" + buttonTitle="Results per page" + showSelectedCheck={false} + buttonClassName="w-full h-full flex items-center justify-center gap-1 border rounded-xl px-2 text-xs font-black transition-all whitespace-nowrap bg-white dark:bg-slate-900 border-slate-200 dark:border-white/10 text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/[0.02] shadow-sm" + menuAlign="right" + menuClassName="w-16 max-w-[calc(100vw-2rem)] bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-xl py-1 z-[70] animate-in fade-in zoom-in-95 duration-200 overflow-hidden" + optionClassName="w-full px-2 py-2 text-sm font-bold flex justify-center items-center transition-colors text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/5" + /> + )}

{title}

-
+
{value}{isPercent && %}
{subValue &&
{subValue}
} @@ -582,7 +582,7 @@ const InlineCommentThreadUI = ({ project, currentUser }: { project?: Project; cu

- The latest release fixed the dedicated server crash and made setup much smoother. + The latest release fixed the world startup crash and made setup much smoother.

@@ -715,7 +715,7 @@ const CompactFeaturedModCard = ({ project }: { project: Project }) => {
-

+

{project.title}

@@ -771,7 +771,7 @@ export const TrendingProjectsSection = ({
-

+

Trending

@@ -823,7 +823,7 @@ export const NewReleasesSection = ({
-

+

New Releases

@@ -861,7 +861,7 @@ export const ModpackPreviewSection = ({ randomProject }: { randomProject?: Proje return (
-

+

Modpacks, Upgraded

@@ -883,7 +883,7 @@ export const DirectDownloadsSection = () => { return (

-

+

Direct Downloads

@@ -901,11 +901,66 @@ export const DirectDownloadsSection = () => { ); }; +export const LauncherPreviewSection = () => { + return ( +

+
+

+ Modtale Launcher +

+

+ Native installs, updates, and Hytale launch flows. +

+

+ Download a desktop launcher that can browse Modtale, install compatible project releases, prompt for dependencies, and keep your local Hytale library organized. +

+
+ +
+
+ +
+
+
+
+ + + + Launcher preview +
+ Modtale Launcher browsing a project page +
+
+ {['Browse projects', 'Resolve dependencies', 'Check updates'].map((item) => ( +
+
+ ))} +
+
+
+ ); +}; + export const SmartDependenciesSection = ({ randomProject, previewProjects }: { randomProject?: Project; previewProjects?: Project[] }) => { return (
-

+

Smart Dependencies

@@ -927,7 +982,7 @@ export const ProjectAnalyticsSection = ({ showConversionRate = true }: { showCon return (

-

+

Project Analytics

@@ -949,7 +1004,7 @@ export const CommunityThreadsSection = ({ project, currentUser }: { project?: Pr return (

-

+

Comment Threads

@@ -971,7 +1026,7 @@ export const RealTimeAlertsSection = () => { return (

-

+

Push Notifications

@@ -993,7 +1048,7 @@ export const AccountPreferencesSection = () => { return (

-

+

Notification Control

diff --git a/frontend/src/modules/home/components/HeroMarquee.tsx b/frontend/src/modules/home/components/HeroMarquee.tsx index 48cc5b12..5eac3c84 100644 --- a/frontend/src/modules/home/components/HeroMarquee.tsx +++ b/frontend/src/modules/home/components/HeroMarquee.tsx @@ -57,7 +57,7 @@ export const FeaturedModCard = memo(({ project, priority = false }: { project: P

-

+

{project.title}

diff --git a/frontend/src/modules/home/views/Home.tsx b/frontend/src/modules/home/views/Home.tsx index 823bfabb..8f1b5b9d 100644 --- a/frontend/src/modules/home/views/Home.tsx +++ b/frontend/src/modules/home/views/Home.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; -import { Search, Upload, Code } from 'lucide-react'; +import { Search, Upload, Code, MonitorDown } from 'lucide-react'; import { GitHubBrandIcon } from '@/components/ui/icons/BrandIcons'; import { api } from '@/utils/api'; import { ROUTE_SEO } from '@/data/seo-constants'; @@ -16,6 +16,7 @@ import { NewReleasesSection, ModpackPreviewSection, DirectDownloadsSection, + LauncherPreviewSection, SmartDependenciesSection, ProjectAnalyticsSection, CommunityThreadsSection, @@ -593,7 +594,6 @@ export const Home: React.FC<{ return (
{homeSeo.title} @@ -1037,7 +1037,7 @@ export const Home: React.FC<{ />
-

+

The Hytale
Community
Repository @@ -1063,26 +1063,33 @@ export const Home: React.FC<{

- + {formatMetric(stats.totalProjects)} Projects