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 @@
-
+
-
+
@@ -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