Compare commits
199 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bcffd1e96 | |||
| 9674366312 | |||
| a7581a1d1b | |||
| 0ff442d61c | |||
| 96598510ee | |||
| 02cb1f5491 | |||
| 3856698d0a | |||
| 3a05f0cfb3 | |||
| fe5e7f7a60 | |||
| b7257f4e59 | |||
| 7cc852f0da | |||
| 525258be67 | |||
| 49bd3100ad | |||
| 6693bab9f9 | |||
| 8e0d64f7d3 | |||
| c97ffe8e47 | |||
| d0115170c2 | |||
| 9ec00d4de5 | |||
| 9527427782 | |||
| fbfc8b8a05 | |||
| e85000424d | |||
| 58ac60be12 | |||
| 77ef928060 | |||
| 00afac8e46 | |||
| d22cebc69a | |||
| 151d595360 | |||
| 7e02a58546 | |||
| 25dee9bfaf | |||
| f898d61077 | |||
| 0aaa3fc912 | |||
| 77fabccdb7 | |||
| 2648ef2567 | |||
| 405a57649a | |||
| 252df1d398 | |||
| 0ad81a1fd8 | |||
| dce864e947 | |||
| b9f1040dd5 | |||
| c7882adb24 | |||
| 9491d698e8 | |||
| 5b71ac3251 | |||
| 815a09be34 | |||
| c46f9eb212 | |||
| 28219e00e7 | |||
| daaf460b0c | |||
| 04f05f725c | |||
| b499aa2abe | |||
| 72df7dd1eb | |||
| 2e7774654b | |||
| 61ce873411 | |||
| 0af9657fea | |||
| 8507e480d6 | |||
| 905d18baf7 | |||
| b5e5d86790 | |||
| c35e94b0bc | |||
| c09ebe99cc | |||
| d5544a7731 | |||
| bc62f2a066 | |||
| 38adb96be4 | |||
| c76dd21eeb | |||
| c5bf4cda8a | |||
| 0b6a6b0842 | |||
| 9c4c4d4563 | |||
| 2d74b1a1fb | |||
| 26ba237142 | |||
| 7b75304c9f | |||
| 9004e14022 | |||
| e7a3dd698a | |||
| c12007c342 | |||
| f37cf8a159 | |||
| 07f2952599 | |||
| 6cf916876a | |||
| 27b11eabf9 | |||
| da31dec33e | |||
| a61bcccdac | |||
| 774de68966 | |||
| 44b77875f7 | |||
| 5469cf05f0 | |||
| 6832ae1198 | |||
| 619faaf1df | |||
| 34387ff202 | |||
| 76d3174264 | |||
| 723337754f | |||
| 2d79fc6c3d | |||
| 80972f5d37 | |||
| f0ade4ad28 | |||
| 737f10f190 | |||
| 813e9a60cb | |||
| 5c25b87cbe | |||
| e48c5fa9a2 | |||
| 303efeddf7 | |||
| c9bf3d0b52 | |||
| 9f64609722 | |||
| 89becbb92b | |||
| fef13a6d3d | |||
| c4973290a6 | |||
| c018268681 | |||
| 79cfb65590 | |||
| 9750845092 | |||
| 7e8171132b | |||
| 910792178b | |||
| 80c5647f3e | |||
| a306549907 | |||
| 295e3d2783 | |||
| 47dcdf8b61 | |||
| 8c6ae9ff33 | |||
| 04fee00a0b | |||
| 9e1fc1ebeb | |||
| 6eceefdacc | |||
| b147814cc4 | |||
| 5a58c81626 | |||
| 1cc460b0d8 | |||
| 104a2ec9e3 | |||
| e87ae56041 | |||
| c95459234b | |||
| f16f852b23 | |||
| b93d7bd19b | |||
| 9a69cef815 | |||
| 2424afbdda | |||
| 0a987467b5 | |||
| ab6f7ca0b2 | |||
| 42fa33d0bf | |||
| 006a3d95d6 | |||
| 1835965f44 | |||
| cb4e8acd9f | |||
| 02418a9531 | |||
| f89151d099 | |||
| dc86386bf8 | |||
| 929c40709a | |||
| a60560256d | |||
| aa583ab973 | |||
| 4103924b83 | |||
| c192bd2ec9 | |||
| 2005d2841f | |||
| c6295b48a5 | |||
| 7444c73a5a | |||
| cf0781d2fe | |||
| 6681cd0603 | |||
| c305a549d3 | |||
| 1f1dd94598 | |||
| 01ad2b3d21 | |||
| e4cddef343 | |||
| f15baefe7e | |||
| 585ed46f2d | |||
| dbbbab5320 | |||
| e5844b3da8 | |||
| fdbf88831a | |||
| 6affad1c8b | |||
| 370c3ff444 | |||
| e486e92f91 | |||
| 7465e962d4 | |||
| 99fe764c5e | |||
| 46f896b526 | |||
| a61572e8ef | |||
| a474996df4 | |||
| 5d6dd5ad38 | |||
| 21e4d3effd | |||
| 817df43af1 | |||
| 906b9d892c | |||
| 534723c9f6 | |||
| 1d5832ed3a | |||
| 844bd589e0 | |||
| 127af15623 | |||
| ff5fc5d5b2 | |||
| 158865d99f | |||
| 94674eca27 | |||
| a8b5d6e701 | |||
| e0675b2127 | |||
| 86582bdb8f | |||
| dc8009a785 | |||
| b5e658e1ff | |||
| 6a19c2d136 | |||
| 99e359ffbf | |||
| f16f4e8cb5 | |||
| 8597f2a272 | |||
| 11f67e2bc4 | |||
| 2e49a16e49 | |||
| 713fddeaa5 | |||
| 0dfefb0516 | |||
| 63d2aeaa43 | |||
| 07f0a7c0dc | |||
| 84bde6d5ed | |||
| 9695f8a6d7 | |||
| a2c410e8d2 | |||
| 271b5f6155 | |||
| ba4f7c1429 | |||
| c763e96596 | |||
| 23e9cbaec5 | |||
| ddd5defa40 | |||
| 723dcc524d | |||
| 46eba0a693 | |||
| b61980a623 | |||
| 65cc894e21 | |||
| 700996e100 | |||
| 546f0c0674 | |||
| e625eab689 | |||
| ef9e1b3ff8 | |||
| dd23191987 | |||
| 55312b1032 | |||
| a987643f8e |
@@ -1,24 +1,24 @@
|
||||
# Consolidated CI workflow for Astroglobe Nextcloud app
|
||||
# Consolidated CI workflow for Astrolabe Nextcloud app
|
||||
#
|
||||
# Runs on PRs that modify the astroglobe directory
|
||||
# Runs on PRs that modify the astrolabe directory
|
||||
# Based on Nextcloud app skeleton workflows
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Astroglobe CI
|
||||
name: Astrolabe CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'third_party/astroglobe/**'
|
||||
- '.github/workflows/astroglobe-ci.yml'
|
||||
- 'third_party/astrolabe/**'
|
||||
- '.github/workflows/astrolabe-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: astroglobe-ci-${{ github.head_ref || github.run_id }}
|
||||
group: astrolabe-ci-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -37,18 +37,18 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
frontend:
|
||||
- 'third_party/astroglobe/src/**'
|
||||
- 'third_party/astroglobe/package.json'
|
||||
- 'third_party/astroglobe/package-lock.json'
|
||||
- 'third_party/astroglobe/vite.config.js'
|
||||
- 'third_party/astroglobe/**/*.js'
|
||||
- 'third_party/astroglobe/**/*.ts'
|
||||
- 'third_party/astroglobe/**/*.vue'
|
||||
- 'third_party/astrolabe/src/**'
|
||||
- 'third_party/astrolabe/package.json'
|
||||
- 'third_party/astrolabe/package-lock.json'
|
||||
- 'third_party/astrolabe/vite.config.js'
|
||||
- 'third_party/astrolabe/**/*.js'
|
||||
- 'third_party/astrolabe/**/*.ts'
|
||||
- 'third_party/astrolabe/**/*.vue'
|
||||
php:
|
||||
- 'third_party/astroglobe/lib/**'
|
||||
- 'third_party/astroglobe/appinfo/**'
|
||||
- 'third_party/astroglobe/composer.json'
|
||||
- 'third_party/astroglobe/psalm.xml'
|
||||
- 'third_party/astrolabe/lib/**'
|
||||
- 'third_party/astrolabe/appinfo/**'
|
||||
- 'third_party/astrolabe/composer.json'
|
||||
- 'third_party/astrolabe/psalm.xml'
|
||||
|
||||
# Node.js build and lint
|
||||
node-build:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
name: Node.js build
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
path: third_party/astrolabe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
name: ESLint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
path: third_party/astrolabe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
name: Stylelint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
path: third_party/astrolabe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
name: PHP CS Fixer
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
name: Psalm
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
id: ocp-versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Install OCP for static analysis
|
||||
run: |
|
||||
@@ -253,14 +253,62 @@ jobs:
|
||||
- name: Run Psalm
|
||||
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
|
||||
|
||||
# PHPUnit Tests
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php-versions: ['8.1', '8.2', '8.3']
|
||||
|
||||
name: PHPUnit (PHP ${{ matrix.php-versions }})
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up PHP ${{ matrix.php-versions }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
extensions: ctype, curl, dom, gd, iconv, intl, json, mbstring, openssl, posix, sqlite, xml, zip
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Get OCP version matrix
|
||||
id: ocp-versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Install OCP for testing
|
||||
run: |
|
||||
OCP_VERSION=$(echo '${{ steps.ocp-versions.outputs.ocp-matrix }}' | jq -r '.include[0]."ocp-version"')
|
||||
composer require --dev "nextcloud/ocp:$OCP_VERSION" --ignore-platform-reqs --with-dependencies
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: composer run test:unit
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, node-build, eslint, stylelint, php-cs, psalm]
|
||||
needs: [changes, node-build, eslint, stylelint, php-cs, psalm, phpunit]
|
||||
if: always()
|
||||
name: astroglobe-ci-summary
|
||||
name: astrolabe-ci-summary
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: |
|
||||
@@ -268,7 +316,7 @@ jobs:
|
||||
echo "Frontend checks failed"
|
||||
exit 1
|
||||
fi
|
||||
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success') }}; then
|
||||
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success' || needs.phpunit.result != 'success') }}; then
|
||||
echo "PHP checks failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -87,21 +87,32 @@ jobs:
|
||||
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
||||
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
|
||||
|
||||
MCP_BUMPED=false
|
||||
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
||||
echo "Bumping MCP server version..."
|
||||
./scripts/bump-mcp.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
||||
MCP_BUMPED=true
|
||||
else
|
||||
echo "No commits found for MCP server since $last_mcp_tag"
|
||||
fi
|
||||
|
||||
# Bump Helm chart (scope: helm)
|
||||
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
|
||||
echo "Checking Helm chart for version bump..."
|
||||
HELM_HAS_COMMITS=false
|
||||
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
|
||||
echo "Bumping Helm chart version..."
|
||||
HELM_HAS_COMMITS=true
|
||||
fi
|
||||
|
||||
if [ "$HELM_HAS_COMMITS" = true ]; then
|
||||
echo "Bumping Helm chart version (helm-scoped commits)..."
|
||||
./scripts/bump-helm.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
elif [ "$MCP_BUMPED" = true ]; then
|
||||
echo "Bumping Helm chart version (appVersion changed)..."
|
||||
./scripts/bump-helm.sh --increment PATCH
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
fi
|
||||
|
||||
# Bump Astrolabe (scope: astrolabe)
|
||||
|
||||
@@ -33,9 +33,10 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@0ed5eeaa54d3b0170e79f1ff29996342cf0605f1 # v1.0.40
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "renovate-bot-cbcoutinho"
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@0ed5eeaa54d3b0170e79f1ff29996342cf0605f1 # v1.0.40
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -66,14 +66,14 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
+112
@@ -5,6 +5,118 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||
|
||||
## v0.63.1 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## v0.63.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## v0.62.0 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## v0.61.5 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## v0.61.4 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
||||
|
||||
## v0.61.3 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||
|
||||
## v0.61.2 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: bump helm chart version when MCP appVersion changes
|
||||
|
||||
## v0.61.1 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||
|
||||
## v0.61.0 (2026-01-14)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## v0.60.4 (2026-01-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
|
||||
## v0.60.3 (2025-12-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
|
||||
## v0.60.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
|
||||
|
||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
||||
|
||||
### Quick Query Script (Recommended for Agents)
|
||||
|
||||
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
|
||||
|
||||
```bash
|
||||
# Basic query
|
||||
./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users"
|
||||
|
||||
# Vertical output (one column per line) - useful for wide tables
|
||||
./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1"
|
||||
|
||||
# With different credentials
|
||||
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
|
||||
```
|
||||
|
||||
### Direct Docker Access
|
||||
|
||||
For interactive sessions or complex operations:
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||
@@ -264,6 +283,40 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
||||
|
||||
### SQLite Databases (MCP Services)
|
||||
|
||||
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
|
||||
|
||||
```bash
|
||||
# List tables
|
||||
./scripts/sqlitequery.py ".tables"
|
||||
|
||||
# Query specific service
|
||||
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||
./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients"
|
||||
./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords"
|
||||
|
||||
# With column headers
|
||||
./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5"
|
||||
|
||||
# JSON output
|
||||
./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions"
|
||||
|
||||
# View schema
|
||||
./scripts/sqlitequery.py -s oauth ".schema refresh_tokens"
|
||||
```
|
||||
|
||||
**Services**: `mcp` (default), `oauth`, `keycloak`, `basic`
|
||||
|
||||
**SQLite Tables**:
|
||||
- `refresh_tokens` - OAuth refresh tokens with user profiles
|
||||
- `audit_logs` - Security audit trail
|
||||
- `oauth_clients` - DCR OAuth client credentials
|
||||
- `oauth_sessions` - OAuth flow session state
|
||||
- `registered_webhooks` - Webhook registrations
|
||||
- `app_passwords` - Multi-user BasicAuth passwords
|
||||
- `alembic_version` - Migration tracking
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
**For detailed architecture, see:**
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Configure MCP server URL for Astrolabe background sync
|
||||
# This URL is used by Astrolabe to send app passwords to the MCP server
|
||||
|
||||
set -e
|
||||
|
||||
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
|
||||
# From Nextcloud's perspective (inside Docker network), we reach it via service name
|
||||
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
|
||||
|
||||
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
||||
|
||||
# Set the mcp_server_url in config.php via occ
|
||||
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
|
||||
|
||||
echo "MCP server URL configured successfully"
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.56.2"
|
||||
version = "0.57.26"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,149 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **volumes**: Fix /app/data not writable in basic auth mode due to readOnlyRootFilesystem security context
|
||||
- Always mount /app/data (uses emptyDir by default, PVC when dataStorage.enabled=true)
|
||||
- Resolves conflict where multi-user-basic and qdrant tried to mount different PVCs to same path
|
||||
- Unified dataStorage configuration for all /app/data persistence needs
|
||||
|
||||
### Changed
|
||||
- **volumes**: Deprecated separate token-storage and qdrant-data PVCs in favor of unified data-storage PVC
|
||||
- **volumes**: /app/data now always writable regardless of auth mode or features enabled
|
||||
|
||||
## nextcloud-mcp-server-0.57.26 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.25 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.24 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.23 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.22 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.21 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.20 (2026-01-29)
|
||||
|
||||
## nextcloud-mcp-server-0.57.19 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.18 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.17 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.16 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## nextcloud-mcp-server-0.57.15 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## nextcloud-mcp-server-0.57.14 (2026-01-26)
|
||||
|
||||
## nextcloud-mcp-server-0.57.13 (2026-01-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.12 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.11 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.10 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.9 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.8 (2026-01-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.7 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## nextcloud-mcp-server-0.57.6 (2026-01-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.5 (2026-01-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.4 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
||||
|
||||
## nextcloud-mcp-server-0.57.3 (2026-01-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.2 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||
|
||||
## nextcloud-mcp-server-0.57.1 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: bump helm chart version when MCP appVersion changes
|
||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||
|
||||
## nextcloud-mcp-server-0.57.0 (2026-01-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## nextcloud-mcp-server-0.56.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -4,6 +4,6 @@ dependencies:
|
||||
version: 1.16.3
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.36.0
|
||||
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
||||
generated: "2025-12-22T11:09:39.166328543Z"
|
||||
version: 1.40.0
|
||||
digest: sha256:d8cbf3eab778b3e28818dd1f9cbd71c99ce968fb2a46880b162f988a59a5fedf
|
||||
generated: "2026-01-30T11:10:10.104463708Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.56.2
|
||||
appVersion: "0.60.2"
|
||||
version: 0.57.26
|
||||
appVersion: "0.63.1"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -31,6 +31,6 @@ dependencies:
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.36.0"
|
||||
version: "1.40.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -118,6 +118,25 @@ ingress:
|
||||
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
||||
|
||||
#### Data Storage
|
||||
|
||||
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
|
||||
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
|
||||
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
|
||||
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
|
||||
| `dataStorage.existingClaim` | Use existing PVC | `""` |
|
||||
|
||||
**When to enable persistence:**
|
||||
- Multi-user basic auth with offline access (stores `tokens.db`)
|
||||
- Qdrant persistent mode (stores vector database)
|
||||
- Any feature requiring persistent app data
|
||||
|
||||
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
|
||||
|
||||
#### MCP Server Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|
||||
@@ -120,6 +120,55 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
||||
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
||||
{{- end }}
|
||||
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- if or $legacyMultiUserBasic $legacyQdrant }}
|
||||
|
||||
================================================================================
|
||||
DEPRECATION WARNING
|
||||
================================================================================
|
||||
|
||||
You are using deprecated persistence configuration that will be removed in a
|
||||
future release. Your deployment will continue to work, but please migrate to
|
||||
the new unified dataStorage configuration.
|
||||
|
||||
Deprecated settings detected:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.* (currently enabled)
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.* (currently enabled)
|
||||
{{- end }}
|
||||
|
||||
To migrate, update your values.yaml:
|
||||
|
||||
dataStorage:
|
||||
enabled: true
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
size: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
size: {{ .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
# storageClass: "" # Optional: specify storage class
|
||||
# existingClaim: "" # Optional: use existing PVC to preserve data
|
||||
|
||||
After migrating, remove the deprecated settings:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.enabled
|
||||
- auth.multiUserBasic.persistence.size
|
||||
- auth.multiUserBasic.persistence.storageClass
|
||||
- auth.multiUserBasic.persistence.accessMode
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.enabled
|
||||
- qdrant.localPersistence.size
|
||||
- qdrant.localPersistence.storageClass
|
||||
- qdrant.localPersistence.accessMode
|
||||
{{- end }}
|
||||
|
||||
================================================================================
|
||||
{{- end }}
|
||||
|
||||
For more information and documentation:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
@@ -127,6 +127,55 @@ Create the name of the PVC to use for Qdrant local persistent storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for /app/data storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
|
||||
{{- if .Values.dataStorage.existingClaim }}
|
||||
{{- .Values.dataStorage.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Determine if data storage PVC should be enabled (backward compatible)
|
||||
Checks new dataStorage.enabled OR legacy persistence configs
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
|
||||
{{- if .Values.dataStorage.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy multi-user-basic persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy qdrant persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the MCP server port
|
||||
*/}}
|
||||
|
||||
@@ -286,14 +286,8 @@ spec:
|
||||
- name: oauth-storage
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -305,15 +299,12 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
||||
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -16,38 +16,34 @@ spec:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
|
||||
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- $accessMode := .Values.dataStorage.accessMode }}
|
||||
{{- $storageClass := .Values.dataStorage.storageClass }}
|
||||
{{- $size := .Values.dataStorage.size }}
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- $size = .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
- {{ $accessMode }}
|
||||
{{- if $storageClass }}
|
||||
storageClassName: {{ $storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.qdrant.localPersistence.size }}
|
||||
storage: {{ $size }}
|
||||
{{- end }}
|
||||
|
||||
@@ -139,6 +139,27 @@ auth:
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# Data Storage Configuration
|
||||
# Persistent volume for /app/data directory
|
||||
# Used for: token databases, qdrant persistent storage, and any app data
|
||||
# When disabled, uses emptyDir (non-persistent, but still writable)
|
||||
dataStorage:
|
||||
# Enable persistent storage for /app/data
|
||||
# Set to true when using:
|
||||
# - Multi-user basic auth with offline access (stores tokens.db)
|
||||
# - Qdrant persistent mode (stores vector database)
|
||||
# - Any feature requiring persistent app data
|
||||
# Set to false for basic auth without persistence (uses emptyDir)
|
||||
enabled: false
|
||||
# Storage class (leave empty for default)
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
# Size for data storage (should accommodate tokens.db and/or qdrant data)
|
||||
# Recommended: 1Gi minimum, 5Gi for production with qdrant
|
||||
size: 1Gi
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# MCP server configuration
|
||||
mcp:
|
||||
# Transport mode (default: streamable-http for SSE)
|
||||
|
||||
+12
-16
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
|
||||
image: docker.io/library/mariadb:lts@sha256:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -19,11 +19,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
||||
image: docker.io/library/redis:alpine@sha256:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||
image: docker.io/library/nextcloud:32.0.5@sha256:11a3a4f63bad8813c7455b4a3c473ccd1c41e2c48f55decb51718f15691e7568
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
@@ -54,14 +54,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||
image: docker.io/library/nginx:alpine@sha256:4870c12cd2ca986de501a804b4f506ad3875a0b1874940ba0a2c7f763f1855b2
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:9945a842ba983afcf110053cbcc0df7e4bd09ba9f02aa213824ce3f986713635
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -88,8 +88,8 @@ services:
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
|
||||
# Vector sync configuration (ADR-007)
|
||||
#- VECTOR_SYNC_ENABLED=true
|
||||
# Semantic search configuration (ADR-007, ADR-021)
|
||||
#- ENABLE_SEMANTIC_SEARCH=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
@@ -140,14 +140,13 @@ services:
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
#- ENABLE_OFFLINE_ACCESS=true
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
|
||||
# Token storage (required for middleware initialization)
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
- ENABLE_SEMANTIC_SEARCH=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
@@ -180,7 +179,6 @@ services:
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1)
|
||||
#- ENABLE_OFFLINE_ACCESS=true
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
@@ -189,9 +187,8 @@ services:
|
||||
# Tokens must contain BOTH MCP and Nextcloud audiences
|
||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||
|
||||
# Vector sync configuration (ADR-007)
|
||||
# Semantic search configuration (ADR-007, ADR-021)
|
||||
- ENABLE_SEMANTIC_SEARCH=true
|
||||
#- VECTOR_SYNC_ENABLED=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
@@ -211,7 +208,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -259,7 +256,6 @@ services:
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||
#- ENABLE_OFFLINE_ACCESS=true
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
@@ -293,13 +289,13 @@ services:
|
||||
- 127.0.0.1:8081:8081
|
||||
environment:
|
||||
- SMITHERY_DEPLOYMENT=true
|
||||
- VECTOR_SYNC_ENABLED=false
|
||||
- ENABLE_SEMANTIC_SEARCH=false
|
||||
- PORT=8081
|
||||
profiles:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
# Authentication Flows by Deployment Mode
|
||||
|
||||
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
|
||||
|
||||
## Quick Reference Matrix
|
||||
|
||||
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|
||||
|------|-------------------|-----------------|-----------------|
|
||||
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
|
||||
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
|
||||
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
|
||||
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
|
||||
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
This document covers three distinct communication patterns:
|
||||
|
||||
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
|
||||
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
|
||||
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
|
||||
|
||||
---
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### 1. Single-User BasicAuth
|
||||
|
||||
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no auth required) │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ Authorization: Basic │
|
||||
│ │ (embedded credentials) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
|
||||
- Single shared `NextcloudClient` created at startup
|
||||
- No MCP-level authentication required (server trusts local clients)
|
||||
- All requests use the same Nextcloud user
|
||||
|
||||
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
|
||||
|
||||
**Implementation:** Background jobs use `get_settings()` to access credentials
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-User BasicAuth
|
||||
|
||||
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ Authorization: Basic │ │
|
||||
│ (user credentials) │ │
|
||||
│ │── BasicAuthMiddleware ────▶│
|
||||
│ │ Extracts credentials │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (pass-through) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
|
||||
- Credentials passed through to Nextcloud (not stored)
|
||||
- Client created per-request from extracted credentials
|
||||
- Stateless - no credential storage between requests
|
||||
|
||||
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
|
||||
|
||||
#### Background Sync (Optional)
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Store App Password ──────▶│ │
|
||||
│ (via management API) │ │
|
||||
│ │── Store in SQLite ────────▶│
|
||||
│ │ (encrypted) │
|
||||
│◀── Confirmation ────────────│ │
|
||||
│ │ │
|
||||
│ [Background Job] │ │
|
||||
│ │── Retrieve app password ──▶│
|
||||
│ │ (from encrypted storage) │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (stored app password) │
|
||||
│ │◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- `ENABLE_OFFLINE_ACCESS=true`
|
||||
- `TOKEN_ENCRYPTION_KEY` for credential encryption
|
||||
- `TOKEN_STORAGE_DB` for SQLite storage path
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
|
||||
│ (user initiates) │ │
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ (management API calls) │ │
|
||||
│ │── Validate via JWKS ──────▶│
|
||||
│ │ (or introspection) │
|
||||
│◀── API Response ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
|
||||
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
|
||||
- Authorization check: `token.sub == requested_resource_owner`
|
||||
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
|
||||
|
||||
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
|
||||
|
||||
---
|
||||
|
||||
### 3. OAuth Single-Audience (Default)
|
||||
|
||||
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
|
||||
|
||||
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: ["mcp-server", │ │
|
||||
│ "nextcloud"] │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ (UnifiedTokenVerifier) │
|
||||
│ │ │
|
||||
│ │── HTTP + Same Token ──────▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │ (multi-audience token) │
|
||||
│ │ │
|
||||
│ │ NC validates its own aud │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
|
||||
- MCP server validates only MCP audience (per RFC 7519)
|
||||
- Nextcloud independently validates its own audience
|
||||
- No token exchange needed - same token used throughout
|
||||
- Stateless operation for interactive requests
|
||||
|
||||
**Token validation flow:**
|
||||
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
|
||||
2. Token passed directly to Nextcloud via `get_client_from_context()`
|
||||
3. Nextcloud validates its own audience when receiving API calls
|
||||
|
||||
**Implementation:**
|
||||
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
|
||||
- `context.py:96-99` - Uses token directly in multi-audience mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[Background Job starts] │ │
|
||||
│── Get refresh token ──────▶│
|
||||
│ (from encrypted storage) │
|
||||
│ │
|
||||
│── Token refresh request ──▶│
|
||||
│ grant_type=refresh_token │
|
||||
│ scope=openid profile ... │
|
||||
│◀── New access + refresh ───│
|
||||
│ (rotation) │
|
||||
│ │
|
||||
│── Store rotated refresh ──▶│
|
||||
│ (encrypted) │
|
||||
│ │
|
||||
│── HTTP + Access Token ────▶│
|
||||
│ Authorization: Bearer │
|
||||
│◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
|
||||
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
|
||||
- `TokenBrokerService` handles token lifecycle
|
||||
- Per-user locking prevents race conditions during concurrent refresh
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
|
||||
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 4. OAuth Token Exchange (RFC 8693)
|
||||
|
||||
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
|
||||
|
||||
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: "mcp-server" │ │
|
||||
│ (MCP audience only) │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ │
|
||||
│ │── RFC 8693 Exchange ──────▶│
|
||||
│ │ grant_type= │
|
||||
│ │ urn:ietf:params:oauth: │
|
||||
│ │ grant-type:token-exchange
|
||||
│ │ subject_token=<mcp-token>│
|
||||
│ │ requested_audience= │
|
||||
│ │ "nextcloud" │
|
||||
│ │◀── Delegated Token ────────│
|
||||
│ │ aud: "nextcloud" │
|
||||
│ │ │
|
||||
│ │── HTTP + Delegated Token ─▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Strict audience separation: MCP token has `aud: "mcp-server"` only
|
||||
- Server exchanges for Nextcloud-audience token on each request
|
||||
- Ephemeral delegated tokens (not cached by default)
|
||||
- Strongest security boundary between MCP and Nextcloud access
|
||||
|
||||
**Token exchange details:**
|
||||
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
- Subject token: MCP access token
|
||||
- Requested audience: Nextcloud resource URI
|
||||
- Result: Short-lived token scoped for Nextcloud
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
|
||||
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
|
||||
- `context.py:88-94` - Routes to session client in exchange mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[User provisions access] │ │
|
||||
│── Flow 2 OAuth ───────────▶│
|
||||
│ client_id="mcp-server" │
|
||||
│ scope=offline_access ... │
|
||||
│◀── Refresh Token ──────────│
|
||||
│ (stored encrypted) │
|
||||
│ │
|
||||
[Background Job runs later] │ │
|
||||
│── Refresh for background ─▶│
|
||||
│ (same as single-audience)│
|
||||
```
|
||||
|
||||
**Key difference from interactive:**
|
||||
- Interactive: On-demand token exchange per request
|
||||
- Background: Uses pre-provisioned refresh tokens (Flow 2)
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 5. Smithery Stateless
|
||||
|
||||
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
|
||||
|
||||
Enabled by `SMITHERY_DEPLOYMENT=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── SSE Connect ─────────────▶│ │
|
||||
│ ?nextcloud_url=... │ │
|
||||
│ &username=... │ │
|
||||
│ &app_password=... │ │
|
||||
│ │── SmitheryConfigMiddleware │
|
||||
│ │ Extract URL params │
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no Authorization header) │ │
|
||||
│ │── Create per-request ─────▶│
|
||||
│ │ NextcloudClient │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (from session params) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Configuration passed via URL query parameters (Smithery `configSchema`)
|
||||
- No persistent state - client created fresh per request
|
||||
- No OAuth infrastructure
|
||||
- No background sync support (stateless)
|
||||
- No admin UI available
|
||||
|
||||
**Required session parameters:**
|
||||
- `nextcloud_url`: Nextcloud instance URL
|
||||
- `username`: Nextcloud username
|
||||
- `app_password`: Nextcloud app password
|
||||
|
||||
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Not supported. Smithery mode is fully stateless with no credential storage.
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable. Smithery deployments don't integrate with Astrolabe.
|
||||
|
||||
---
|
||||
|
||||
## Astrolabe Background Token Refresh
|
||||
|
||||
The Astrolabe Nextcloud app includes a background job that proactively refreshes OAuth tokens before expiration.
|
||||
|
||||
```
|
||||
Nextcloud Cron Astrolabe MCP Server IdP
|
||||
│ │ │
|
||||
│── Run RefreshUserTokens ───▶│ │
|
||||
│ (every 15 minutes) │ │
|
||||
│ │── Get all user tokens ────▶│
|
||||
│ │ (from preferences) │
|
||||
│ │ │
|
||||
│ [For each user] │ │
|
||||
│ │── Check expiry ───────────▶│
|
||||
│ │ refresh if <50% lifetime │
|
||||
│ │ │
|
||||
│ │── Acquire user lock ──────▶│
|
||||
│ │ (prevent race condition) │
|
||||
│ │ │
|
||||
│ │── Token refresh request ──▶│
|
||||
│ │ grant_type=refresh_token │
|
||||
│ │◀── New tokens ─────────────│
|
||||
│ │ │
|
||||
│ │── Store new tokens ───────▶│
|
||||
│ │ (with issued_at) │
|
||||
│◀── Job complete ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Runs every 15 minutes via Nextcloud cron
|
||||
- Refreshes when <50% of token lifetime remains
|
||||
- Uses locking to prevent race conditions with on-demand refresh
|
||||
- Stores `issued_at` timestamp for accurate lifetime calculation
|
||||
- Batch processing (100 users at a time) for memory efficiency
|
||||
|
||||
**Implementation:** `third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php`
|
||||
|
||||
---
|
||||
|
||||
## Configuration Quick Reference
|
||||
|
||||
### Single-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://localhost:8080
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
```
|
||||
|
||||
### Multi-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Single-Audience (Default)
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
# No username/password triggers OAuth mode
|
||||
|
||||
# Optional: Static client credentials (instead of DCR)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Token Exchange
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### Smithery Stateless
|
||||
```bash
|
||||
SMITHERY_DEPLOYMENT=true
|
||||
# All other config comes from session URL parameters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Authentication](authentication.md) - Configuration details and setup guides
|
||||
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
|
||||
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
|
||||
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
|
||||
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
|
||||
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
|
||||
@@ -223,6 +223,55 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
| Token Storage | None | Refresh tokens only | All tokens |
|
||||
| Deployment Complexity | Low | Medium | High |
|
||||
|
||||
### Astrolabe User Setup (Hybrid Mode)
|
||||
|
||||
When Astrolabe connects to an MCP server running in hybrid mode, users must complete a **two-step credential setup**:
|
||||
|
||||
#### Step 1: OAuth Authorization (Search Access)
|
||||
|
||||
**Purpose**: Allows Astrolabe to call MCP server APIs on the user's behalf.
|
||||
|
||||
**Flow**:
|
||||
1. User opens Astrolabe Personal Settings in Nextcloud
|
||||
2. Clicks "Authorize" button
|
||||
3. Redirected to Astrolabe's OAuth controller (`/apps/astrolabe/oauth/initiate`)
|
||||
4. OAuth controller discovers IdP from MCP server's `/api/v1/status` endpoint
|
||||
5. User authenticates with Identity Provider (Nextcloud OIDC or external IdP)
|
||||
6. Tokens stored in Nextcloud user config (`McpTokenStorage`)
|
||||
7. Astrolabe can now perform semantic searches via MCP API
|
||||
|
||||
**Technical Details**:
|
||||
- Token audience: MCP server
|
||||
- Token storage: Nextcloud app config (`oc_preferences`)
|
||||
- Used for: `/api/v1/search`, `/api/v1/status` (authenticated endpoints)
|
||||
|
||||
#### Step 2: App Password (Background Indexing)
|
||||
|
||||
**Purpose**: Allows MCP server to access Nextcloud content for background sync.
|
||||
|
||||
**Flow**:
|
||||
1. User generates app password in Nextcloud Security settings
|
||||
2. Enters app password in Astrolabe Personal Settings
|
||||
3. App password validated against Nextcloud and stored (encrypted)
|
||||
4. MCP server can now index user's content in the background
|
||||
|
||||
**Technical Details**:
|
||||
- Credential type: Nextcloud app password
|
||||
- Token storage: MCP server's refresh token database
|
||||
- Used for: Background indexing, content sync to vector database
|
||||
|
||||
#### Why Two Credentials?
|
||||
|
||||
| Direction | Auth Method | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| Astrolabe → MCP Server | OAuth Bearer Token | User searches, settings management |
|
||||
| MCP Server → Nextcloud | BasicAuth (App Password) | Background content indexing |
|
||||
|
||||
The separation ensures:
|
||||
- **Security**: Each credential has limited scope
|
||||
- **Audit Trail**: OAuth tokens identify users; app passwords enable background ops
|
||||
- **User Control**: Users explicitly grant each type of access
|
||||
|
||||
### See Also
|
||||
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
||||
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Introducing Astrolabe: Navigate Your Data Universe in Nextcloud
|
||||
|
||||
Your Nextcloud instance holds years of notes, projects, recipes, contacts, and documents. But when you need to find something, you're stuck typing exact keywords and hoping for the best. Search "car repair" and miss that note titled "Vehicle maintenance tips." Search "meeting agenda" and overlook the calendar event called "Team sync." Traditional keyword search demands that you remember exactly how you wrote things down.
|
||||
|
||||
What if your search could understand what you *mean*, not just what you type?
|
||||
|
||||
Meet **Astrolabe**—a Nextcloud app that brings AI-powered semantic search to your self-hosted cloud. Named after the ancient navigational instrument that helped travelers chart courses by the stars, Astrolabe helps you navigate your personal knowledge by mapping the semantic connections between your documents.
|
||||
|
||||
## The Astrolabe Metaphor
|
||||
|
||||
The astrolabe was one of humanity's most elegant scientific instruments—an analog computer for solving problems related to time and the position of celestial bodies. Its theoretical foundation traces back to **Hipparchus of Nicaea** (c. 190–120 BCE), who discovered the stereographic projection that allows a three-dimensional celestial sphere to be represented on a flat surface. Later Greek scholars like **Theon of Alexandria** and his daughter **Hypatia** refined it into a practical instrument, and during the Islamic Golden Age, astronomers in Baghdad, Damascus, and Cordoba perfected its design and applications.
|
||||
|
||||
For nearly two millennia, astrolabes served astronomers, navigators, scholars, and religious officials across the Greek, Byzantine, Islamic, and medieval European worlds. These instruments allowed users to determine time, find celestial positions, calculate daylight hours, identify constellations, and even determine the direction of Mecca for prayer—all without complex calculations. The astrolabe made the vast complexity of the heavens understandable and navigable.
|
||||
|
||||
**Astrolabe** (the app) does the same for your data. Every document, note, and calendar event becomes a point of light in your personal data universe. The app maps their semantic relationships—their meaning, not just their words—and suddenly the connections become visible. Documents cluster by topic, related ideas sit nearby, and you can navigate this landscape as naturally as medieval scholars once read the stars. Where the original astrolabe projected the celestial sphere onto brass, this one projects your knowledge into explorable semantic space.
|
||||
|
||||
## Semantic Search: Find Meaning, Not Just Keywords
|
||||
|
||||
The core feature of Astrolabe is semantic search. Instead of matching exact keywords, it understands the concepts in your query and finds related content.
|
||||
|
||||
**What this looks like in practice:**
|
||||
|
||||
| You Search For | Traditional Search Finds | Astrolabe Also Finds |
|
||||
|----------------|--------------------------|----------------------|
|
||||
| "car repair" | Documents containing "car repair" | Notes about "vehicle maintenance," "fixing the truck" |
|
||||
| "team planning" | Documents with "team planning" | Calendar events titled "Q2 kickoff," Deck cards about "project roadmap" |
|
||||
| "pasta recipes" | Documents with "pasta recipes" | Notes about "Italian cooking," "homemade noodles," "carbonara tips" |
|
||||
|
||||
This works across multiple Nextcloud apps: Notes, Files (including PDFs with OCR), Deck cards, Calendar events, Contacts, and News/RSS items. One search bar, all your content, understood by meaning.
|
||||
|
||||
### Hybrid Search: Best of Both Worlds
|
||||
|
||||
Sometimes you want exact matches ("PROJ-2024-001"), sometimes you want semantic understanding ("that project from last year about authentication"). Astrolabe's hybrid search combines both approaches:
|
||||
|
||||
- **Semantic search** uses embeddings to find conceptually related content
|
||||
- **BM25 keyword search** finds exact matches and important terms
|
||||
- **Reciprocal Rank Fusion (RRF)** intelligently merges the results
|
||||
|
||||
You can adjust the balance or switch modes entirely depending on your needs.
|
||||
|
||||

|
||||
*Astrolabe results appear alongside traditional search in Nextcloud's unified search bar*
|
||||
|
||||
## Visualize Your Data Universe
|
||||
|
||||
Beyond search, Astrolabe includes an interactive 3D visualization that shows your documents positioned in semantic space. Similar documents cluster together. Topics form constellations. You can rotate, zoom, and explore.
|
||||
|
||||
This isn't just eye candy—it's a practical tool for knowledge discovery:
|
||||
|
||||
- **Find forgotten connections**: Search for your current project and watch as related documents from months ago light up nearby
|
||||
- **Spot topic clusters**: See how your notes naturally group by subject
|
||||
- **Explore the unknown**: Click on points near your search results to discover content you didn't know was related
|
||||
|
||||
The visualization uses Principal Component Analysis (PCA) to project high-dimensional embeddings (768 dimensions) down to 3D space while preserving the relationships between documents. We implemented a lightweight, custom PCA specifically for this—no heavyweight ML libraries required.
|
||||
|
||||

|
||||
*Documents cluster by semantic similarity. The query point (red) shows your search, and related documents cluster nearby*
|
||||
|
||||
## Power Your AI Agents
|
||||
|
||||
Astrolabe isn't just for humans—it's for your AI assistants too.
|
||||
|
||||
The backend runs a **Model Context Protocol (MCP)** server, which means AI tools like Claude Desktop, Cursor, or custom agents can connect directly to your Nextcloud data. Your AI assistant can:
|
||||
|
||||
- Search your notes semantically ("Find everything related to the Kubernetes migration")
|
||||
- Retrieve document content for context
|
||||
- Get AI-generated answers with citations from your documents (RAG)
|
||||
|
||||
The critical point: **your data never leaves your infrastructure**. The MCP server runs on your hardware. Your AI assistant sends queries, the server returns results, and you maintain full control. No documents uploaded to third-party services.
|
||||
|
||||
### Retrieval-Augmented Generation (RAG)
|
||||
|
||||
Ask a question, and Astrolabe can retrieve relevant documents and have your AI synthesize an answer—complete with citations:
|
||||
|
||||
```
|
||||
You: "What were the main issues we had deploying to production last month?"
|
||||
|
||||
Astrolabe finds: 3 relevant notes, 2 Deck cards, 1 calendar event
|
||||
|
||||
AI generates: "Based on your documents, there were three main issues:
|
||||
1. Database migration timeout (see Note: 'Prod deploy 2024-01-15')
|
||||
2. SSL certificate renewal (see Deck card: 'Ops Tasks')
|
||||
3. Resource limits on the new pods (see Note: 'K8s troubleshooting')
|
||||
```
|
||||
|
||||
This uses MCP's sampling capability—the server doesn't run its own LLM. Instead, it asks your client's AI to generate the response. You choose the model, you control the costs.
|
||||
|
||||
## Under the Hood
|
||||
|
||||
For the technically curious, here's how Astrolabe works:
|
||||
|
||||
### Embedding Providers
|
||||
|
||||
Astrolabe supports multiple backends for generating semantic embeddings:
|
||||
|
||||
- **Amazon Bedrock**: Enterprise-grade, Titan embeddings
|
||||
- **OpenAI**: Direct OpenAI API or compatible endpoints (including GitHub Models)
|
||||
- **Ollama**: Self-hosted, privacy-focused, runs entirely on your hardware
|
||||
|
||||
The system auto-detects available providers based on environment variables and falls back gracefully. Deploy Ollama on your server for full privacy, or use Bedrock for enterprise scale—same codebase, zero code changes.
|
||||
|
||||
### Background Indexing
|
||||
|
||||
Documents are indexed automatically via webhooks. When you create or edit a note, Nextcloud fires an event, and the MCP server processes it in the background. No manual sync required.
|
||||
|
||||
The indexing pipeline:
|
||||
1. **Scanner** detects changes via ETags and modification timestamps
|
||||
2. **Queue** manages backpressure (up to 10k pending documents)
|
||||
3. **Worker pool** processes embeddings concurrently (configurable, default 3 workers)
|
||||
4. **Qdrant** stores vectors for fast similarity search
|
||||
|
||||
### Lightweight by Design
|
||||
|
||||
We deliberately avoided heavyweight dependencies:
|
||||
|
||||
- **Custom PCA**: No scikit-learn, just efficient eigendecomposition
|
||||
- **In-process async**: No separate message queues or worker processes—just anyio TaskGroups
|
||||
- **Plugin architecture**: New apps (Notes, Calendar, etc.) are simple scanner/processor implementations
|
||||
|
||||
This means Astrolabe runs comfortably alongside your Nextcloud on modest hardware.
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌─────────────┐ ┌─────────┐
|
||||
│ Nextcloud │────▶│ MCP Server │────▶│ Qdrant │
|
||||
│ (Astrolabe) │◀────│ (Python) │◀────│ (Vectors)│
|
||||
└──────────────┘ └─────────────┘ └─────────┘
|
||||
│ │
|
||||
│ OAuth/Token │ Embeddings
|
||||
▼ ▼
|
||||
┌────────┐ ┌──────────┐
|
||||
│ User │ │ Ollama/ │
|
||||
│Browser │ │ Bedrock │
|
||||
└────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
|
||||
- Nextcloud 31 or 32
|
||||
- MCP server instance (Docker recommended)
|
||||
- Vector database (Qdrant, included in Docker setup)
|
||||
- Embedding provider (Ollama for self-hosted, or cloud options)
|
||||
|
||||
### Quick Setup
|
||||
|
||||
1. **Install the Astrolabe app** from the Nextcloud App Store (or manually)
|
||||
|
||||
2. **Start the MCP server** (Docker Compose makes this easy):
|
||||
```bash
|
||||
docker compose up -d mcp qdrant ollama
|
||||
```
|
||||
|
||||
3. **Configure the connection** in your Nextcloud `config.php`:
|
||||
```php
|
||||
'astrolabe' => [
|
||||
'mcp_server_url' => 'http://localhost:8000',
|
||||
],
|
||||
```
|
||||
|
||||
4. **Authorize access** in Settings → Personal → Astrolabe
|
||||
|
||||
5. **Start searching** using Nextcloud's unified search bar
|
||||
|
||||
For detailed setup instructions, including OAuth configuration and embedding provider options, see the [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server).
|
||||
|
||||
## What Can You Index?
|
||||
|
||||
Astrolabe currently supports:
|
||||
|
||||
| App | What Gets Indexed |
|
||||
|-----|-------------------|
|
||||
| **Notes** | Full text and metadata |
|
||||
| **Files** | PDFs (with OCR), DOCX, text files |
|
||||
| **Deck** | Card titles and descriptions |
|
||||
| **Calendar** | Event titles, descriptions, and details |
|
||||
| **Contacts** | Names, notes, and contact information |
|
||||
| **News** | RSS/Atom feed articles |
|
||||
|
||||
Each result shows the document type, relevance score, and a direct link to the source. For large documents, it shows which chunk (section) matched.
|
||||
|
||||

|
||||
*Click a result to see the matching chunk in context*
|
||||
|
||||
## Who Is This For?
|
||||
|
||||
**Researchers and students**: Find all notes related to your thesis topic, even when you used different terminology across semesters. Discover connections between papers you read months apart.
|
||||
|
||||
**Teams and organizations**: Surface institutional knowledge that would otherwise stay buried. New team members can search for concepts instead of knowing exactly what to look for.
|
||||
|
||||
**Developers**: Connect your AI coding assistant to your Nextcloud. Give it access to project notes, meeting records, and documentation without copy-pasting context.
|
||||
|
||||
**Personal knowledge managers**: Discover forgotten documents related to your current work. Watch your knowledge base evolve over time through the visualization.
|
||||
|
||||
## Try It Out
|
||||
|
||||
Astrolabe is open source (AGPL) and ready to use. Your data universe has been waiting in the dark—it's time to turn on the lights.
|
||||
|
||||
- **Install**: [Nextcloud App Store](https://apps.nextcloud.com/apps/astrolabe)
|
||||
- **Source**: [GitHub](https://github.com/cbcoutinho/nextcloud-mcp-server)
|
||||
- **Documentation**: [Setup Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/tree/master/docs)
|
||||
- **Issues**: [Report bugs or request features](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
|
||||
|
||||
---
|
||||
|
||||
*Astrolabe is maintained by [Chris Coutinho](https://github.com/cbcoutinho). Contributions welcome.*
|
||||
@@ -531,6 +531,28 @@ docker-compose up
|
||||
|
||||
---
|
||||
|
||||
## Astrolabe Internal URL
|
||||
|
||||
The Astrolabe Nextcloud app may need to make internal HTTP requests to the local web server (e.g., for OAuth token refresh). By default, it uses `http://localhost` which works for standard Docker containers where PHP and Apache run together.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `astrolabe_internal_url` | Internal URL for server-to-server requests within container | `http://localhost` |
|
||||
|
||||
**When to configure:**
|
||||
- Custom container setups where the internal web server is not on `localhost:80`
|
||||
- Kubernetes deployments with service discovery
|
||||
- Multi-container setups with separate web server containers
|
||||
|
||||
**Example (Nextcloud config.php):**
|
||||
```php
|
||||
'astrolabe_internal_url' => 'http://web-server.internal:8080',
|
||||
```
|
||||
|
||||
**Note:** This is for internal PHP-to-Apache requests, NOT for external client URLs. The default (`http://localhost`) works for standard Docker containers where PHP and Apache run together.
|
||||
|
||||
---
|
||||
|
||||
## Loading Environment Variables
|
||||
|
||||
After creating your `.env` file, load the environment variables:
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# Webhook Management Guide
|
||||
|
||||
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
|
||||
|
||||
**Related ADRs:**
|
||||
- ADR-010: Webhook-Based Vector Sync
|
||||
- ADR-020: Deployment Modes and Configuration Validation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before enabling webhooks, ensure:
|
||||
|
||||
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
||||
2. **Astrolabe app** installed in Nextcloud (provides settings UI and credentials API)
|
||||
3. **MCP server** accessible from Nextcloud via HTTP(S)
|
||||
4. **Vector sync enabled** on the MCP server
|
||||
|
||||
## Webhook Architecture Overview
|
||||
|
||||
The webhook system has two components:
|
||||
|
||||
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
|
||||
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
|
||||
|
||||
Both must be configured for webhooks to function properly.
|
||||
|
||||
## Deployment Mode Specifics
|
||||
|
||||
### 1. Single-User BasicAuth
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://localhost:8080
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable Webhooks:**
|
||||
1. Register webhooks using occ commands (requires Nextcloud admin):
|
||||
```bash
|
||||
# Enable webhook_listeners app
|
||||
php occ app:enable webhook_listeners
|
||||
|
||||
# Register webhooks for vector sync
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8000/webhooks/nextcloud" \
|
||||
--method POST
|
||||
|
||||
# Repeat for other events (see Event Types below)
|
||||
```
|
||||
|
||||
2. Optionally reduce polling frequency:
|
||||
```bash
|
||||
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
```bash
|
||||
# List registered webhooks
|
||||
php occ webhook_listeners:list
|
||||
|
||||
# Remove specific webhook by ID
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Simplest mode - admin credentials used for all operations
|
||||
- No per-user provisioning required
|
||||
- Background sync runs as the configured admin user
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-User BasicAuth Pass-Through
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
# OAuth client for Astrolabe API access
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
```
|
||||
|
||||
**Credential Architecture:**
|
||||
This mode uses **two separate credential mechanisms**:
|
||||
|
||||
1. **OAuth Session** (for management API access, including webhooks):
|
||||
- Obtained via browser OAuth flow (`/oauth/login`)
|
||||
- Stores refresh token in MCP server's `tokens.db`
|
||||
- Used for webhook registration/management APIs
|
||||
|
||||
2. **App Password** (for background sync):
|
||||
- Generated in Nextcloud Security settings
|
||||
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
|
||||
- Used by background scanners to access Nextcloud APIs
|
||||
|
||||
**Enable Webhooks:**
|
||||
|
||||
#### Step 1: Complete OAuth Login (for Management API)
|
||||
Users must authorize the MCP server to access their Nextcloud:
|
||||
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
|
||||
2. Click **"Authorize via OAuth"** under "Option 1"
|
||||
3. Complete OAuth consent flow
|
||||
4. Verify the page shows "Background Sync Access: Active"
|
||||
|
||||
#### Step 2: Configure App Password (for Background Sync)
|
||||
Since OAuth refresh tokens have short expiry, users should also configure an app password:
|
||||
|
||||
1. Navigate to **Nextcloud Settings → Security**
|
||||
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
|
||||
3. Return to **Nextcloud Settings → Astrolabe**
|
||||
4. Under "Option 2: App Password", paste the app password
|
||||
5. Click **Save**
|
||||
|
||||
#### Step 3: Register Webhooks (Admin)
|
||||
Same as Single-User BasicAuth:
|
||||
```bash
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8003/webhooks/nextcloud" \
|
||||
--method POST
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
|
||||
*Per-User:*
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
|
||||
|
||||
*System-Wide:*
|
||||
```bash
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
If OAuth login fails with "Access forbidden - Your client is not authorized":
|
||||
1. Check if OAuth client is registered:
|
||||
```sql
|
||||
SELECT id, name, client_identifier FROM oc_oidc_clients
|
||||
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
|
||||
```
|
||||
2. Restart MCP server to trigger DCR re-registration
|
||||
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
|
||||
|
||||
If background sync fails with "User no longer provisioned":
|
||||
1. Verify app password is stored:
|
||||
```sql
|
||||
SELECT userid, configkey FROM oc_preferences
|
||||
WHERE appid = 'astrolabe' AND userid = 'username';
|
||||
```
|
||||
2. Ensure user completed **both** OAuth login AND app password setup
|
||||
|
||||
---
|
||||
|
||||
### 3. OAuth Single-Audience (Default OAuth Mode)
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
# No NEXTCLOUD_USERNAME/PASSWORD
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable Webhooks:**
|
||||
|
||||
#### Step 1: User Provisioning
|
||||
Users authorize via OAuth with `offline_access` scope:
|
||||
|
||||
1. MCP client initiates OAuth flow
|
||||
2. User consents to requested scopes including `offline_access`
|
||||
3. MCP server stores refresh token for background operations
|
||||
|
||||
Alternatively, via Astrolabe UI:
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||
2. Click **"Authorize via OAuth"**
|
||||
3. Complete consent flow
|
||||
|
||||
#### Step 2: Register Webhooks (Admin)
|
||||
```bash
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8001/webhooks/nextcloud" \
|
||||
--method POST
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
|
||||
*Per-User:*
|
||||
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
|
||||
- Via MCP tool: Use `revoke_nextcloud_access` if available
|
||||
|
||||
*System-Wide:*
|
||||
```bash
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. OAuth Token Exchange (RFC 8693)
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable/Disable Webhooks:**
|
||||
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
|
||||
|
||||
---
|
||||
|
||||
### 5. Smithery Stateless
|
||||
|
||||
**Configuration:**
|
||||
- Configuration from session URL params
|
||||
- `VECTOR_SYNC_ENABLED=false` (required)
|
||||
|
||||
**Webhooks:**
|
||||
**Not supported.** This mode is stateless with no persistent storage or background operations.
|
||||
|
||||
---
|
||||
|
||||
## Webhook Event Types
|
||||
|
||||
Register these webhook events for full vector sync coverage:
|
||||
|
||||
### File/Note Events
|
||||
```bash
|
||||
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
### Calendar Events
|
||||
```bash
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
### Tables Events
|
||||
```bash
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
## Webhook Presets (via Astrolabe UI)
|
||||
|
||||
The Astrolabe app provides preset webhook configurations that can be enabled/disabled via the Admin settings UI:
|
||||
|
||||
| Preset | Events Covered |
|
||||
|--------|----------------|
|
||||
| `notes_sync` | File create/update/delete for .md files |
|
||||
| `calendar_sync` | Calendar object events |
|
||||
| `tables_sync` | Tables row events |
|
||||
| `forms_sync` | Forms submission events |
|
||||
| `files_sync` | All file events (optional, high volume) |
|
||||
|
||||
**Enable Presets:**
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe** (Admin settings)
|
||||
2. Toggle desired presets in "Webhook Configuration"
|
||||
|
||||
**Note:** Presets require the MCP server's management API to be accessible. The API uses OAuth bearer tokens from the user's session.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Webhook Authentication
|
||||
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
|
||||
|
||||
```bash
|
||||
# MCP Server
|
||||
WEBHOOK_SECRET=<generate-random-secret>
|
||||
|
||||
# Nextcloud webhook registration
|
||||
php occ webhook_listeners:add \
|
||||
--event "..." \
|
||||
--uri "$MCP_URL/webhooks/nextcloud" \
|
||||
--header "Authorization: Bearer <secret>"
|
||||
```
|
||||
|
||||
### Token Storage
|
||||
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
|
||||
- Store the key securely (environment variable, secrets manager)
|
||||
- Different users have isolated credential storage
|
||||
|
||||
## Monitoring
|
||||
|
||||
### MCP Server Logs
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs mcp-multi-user-basic | grep -i webhook
|
||||
|
||||
# Key log messages
|
||||
# - "Queued document from webhook: ..." - Success
|
||||
# - "Webhook authentication failed" - Auth error
|
||||
# - "User X no longer provisioned" - Missing credentials
|
||||
```
|
||||
|
||||
### Nextcloud Logs
|
||||
```bash
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | \
|
||||
jq 'select(.message | contains("webhook"))' | tail
|
||||
```
|
||||
|
||||
### Database Checks
|
||||
```sql
|
||||
-- Check registered webhooks
|
||||
SELECT * FROM oc_webhook_listeners;
|
||||
|
||||
-- Check OAuth clients
|
||||
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
|
||||
|
||||
-- Check user credentials in Astrolabe
|
||||
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Access forbidden - Your client is not authorized to connect"
|
||||
**Cause:** OAuth client registration expired or not present in Nextcloud
|
||||
**Fix:** Restart MCP server to trigger DCR re-registration
|
||||
|
||||
### "User X no longer provisioned, stopping scanner"
|
||||
**Cause:** Background sync credentials missing or expired
|
||||
**Fix:** User must complete credential provisioning (see mode-specific steps)
|
||||
|
||||
### "Failed to fetch" in browser console during OAuth
|
||||
**Cause:** Network issue between browser and MCP server callback endpoint
|
||||
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
|
||||
|
||||
### Webhooks not firing
|
||||
**Causes:**
|
||||
1. `webhook_listeners` app not enabled
|
||||
2. Webhook not registered for the event type
|
||||
3. Background job workers not running
|
||||
**Fix:**
|
||||
```bash
|
||||
php occ app:enable webhook_listeners
|
||||
php occ background:cron # or configure systemd cron
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Add app_passwords table for multi-user BasicAuth mode
|
||||
|
||||
This migration adds support for storing app passwords that are provisioned
|
||||
via Astrolabe's personal settings. This enables background sync in
|
||||
multi-user BasicAuth mode without requiring OAuth.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-01-13 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "002"
|
||||
down_revision = "001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add app_passwords table for multi-user BasicAuth mode."""
|
||||
|
||||
# App passwords table for multi-user BasicAuth background sync
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app_passwords (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_password BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Index for efficient user lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
|
||||
ON app_passwords(updated_at)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop app_passwords table."""
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
|
||||
op.execute("DROP TABLE IF EXISTS app_passwords")
|
||||
@@ -3,4 +3,74 @@
|
||||
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
||||
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
||||
authentication via the UnifiedTokenVerifier.
|
||||
|
||||
This package is organized into modules by domain:
|
||||
- management.py: Server status, user sessions, shared helpers
|
||||
- passwords.py: App password provisioning for multi-user BasicAuth
|
||||
- webhooks.py: Webhook registration management
|
||||
- visualization.py: Search and PDF visualization endpoints
|
||||
"""
|
||||
|
||||
# Re-export all public functions for backward compatibility
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
__version__,
|
||||
_parse_float_param,
|
||||
_parse_int_param,
|
||||
_sanitize_error_for_client,
|
||||
_validate_query_string,
|
||||
extract_bearer_token,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
revoke_user_access,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
from nextcloud_mcp_server.api.passwords import (
|
||||
delete_app_password,
|
||||
get_app_password_status,
|
||||
provision_app_password,
|
||||
)
|
||||
from nextcloud_mcp_server.api.visualization import (
|
||||
get_chunk_context,
|
||||
get_pdf_preview,
|
||||
unified_search,
|
||||
vector_search,
|
||||
)
|
||||
from nextcloud_mcp_server.api.webhooks import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
get_installed_apps,
|
||||
list_webhooks,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Version
|
||||
"__version__",
|
||||
# Shared helpers (from management.py)
|
||||
"extract_bearer_token",
|
||||
"validate_token_and_get_user",
|
||||
"_sanitize_error_for_client",
|
||||
"_parse_int_param",
|
||||
"_parse_float_param",
|
||||
"_validate_query_string",
|
||||
# Status endpoints (from management.py)
|
||||
"get_server_status",
|
||||
"get_vector_sync_status",
|
||||
# Session endpoints (from management.py)
|
||||
"get_user_session",
|
||||
"revoke_user_access",
|
||||
# Password endpoints (from passwords.py)
|
||||
"provision_app_password",
|
||||
"get_app_password_status",
|
||||
"delete_app_password",
|
||||
# Webhook endpoints (from webhooks.py)
|
||||
"get_installed_apps",
|
||||
"list_webhooks",
|
||||
"create_webhook",
|
||||
"delete_webhook",
|
||||
# Visualization endpoints (from visualization.py)
|
||||
"unified_search",
|
||||
"vector_search",
|
||||
"get_chunk_context",
|
||||
"get_pdf_preview",
|
||||
]
|
||||
|
||||
@@ -4,10 +4,15 @@ ADR-018: Provides REST API endpoints for the Nextcloud PHP app to query:
|
||||
- Server status and version
|
||||
- User session information and background access status
|
||||
- Vector sync metrics
|
||||
- Vector search for visualization
|
||||
|
||||
All endpoints use OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
|
||||
|
||||
Shared helper functions for other API modules are also exported from here:
|
||||
- extract_bearer_token: Extract OAuth token from request
|
||||
- validate_token_and_get_user: Validate token and get user ID
|
||||
- _sanitize_error_for_client: Return safe error messages
|
||||
- _parse_int_param, _parse_float_param, _validate_query_string: Parameter validation
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -15,7 +20,6 @@ import time
|
||||
from importlib.metadata import version
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@@ -229,8 +233,13 @@ async def get_server_status(request: Request) -> JSONResponse:
|
||||
if mode == AuthMode.MULTI_USER_BASIC:
|
||||
response_data["supports_app_passwords"] = settings.enable_offline_access
|
||||
|
||||
# Include OIDC configuration if in OAuth mode
|
||||
if auth_mode == "oauth":
|
||||
# Include OIDC configuration if OAuth is available
|
||||
# This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access)
|
||||
# Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode
|
||||
oauth_provisioning_available = auth_mode == "oauth" or (
|
||||
mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access
|
||||
)
|
||||
if oauth_provisioning_available:
|
||||
# Provide IdP discovery information for NC PHP app
|
||||
oidc_config = {}
|
||||
|
||||
@@ -508,909 +517,3 @@ async def revoke_user_access(request: Request) -> JSONResponse:
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||
|
||||
Returns a list of installed app IDs for filtering webhook presets.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Get installed apps using OCS API
|
||||
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
|
||||
# We check which ones are installed and enabled
|
||||
ocs_url = "/ocs/v1.php/cloud/apps"
|
||||
params = {"filter": "enabled"}
|
||||
|
||||
response = await client.get(
|
||||
ocs_url,
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"OCS API returned status {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
|
||||
|
||||
return JSONResponse({"apps": apps})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting installed apps for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def list_webhooks(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/webhooks - List all registered webhooks.
|
||||
|
||||
Returns list of webhook registrations for the authenticated user.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to list webhooks
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhooks = await webhooks_client.list_webhooks()
|
||||
|
||||
return JSONResponse({"webhooks": webhooks})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing webhooks for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def create_webhook(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/webhooks - Create a new webhook registration.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"uri": "http://mcp:8000/webhooks/nextcloud",
|
||||
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
|
||||
}
|
||||
|
||||
Returns the created webhook data including the webhook ID.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
event = body.get("event")
|
||||
uri = body.get("uri")
|
||||
# Accept both camelCase (eventFilter) and snake_case (event_filter)
|
||||
event_filter = body.get("eventFilter") or body.get("event_filter")
|
||||
|
||||
if not event or not uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Bad request",
|
||||
"message": "Missing required fields: event, uri",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to create webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhook_data = await webhooks_client.create_webhook(
|
||||
event=event, uri=uri, event_filter=event_filter
|
||||
)
|
||||
|
||||
return JSONResponse({"webhook": webhook_data})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_webhook(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
|
||||
|
||||
Returns success/failure status.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get webhook_id from path parameter
|
||||
webhook_id = request.path_params.get("webhook_id")
|
||||
if not webhook_id:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Missing webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
webhook_id = int(webhook_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Invalid webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to delete webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
await webhooks_client.delete_webhook(webhook_id=webhook_id)
|
||||
|
||||
return JSONResponse({"success": True, "message": "Webhook deleted"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def unified_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
||||
|
||||
Optimized search endpoint for the Nextcloud Unified Search provider
|
||||
and other PHP app integrations. Returns results with metadata needed
|
||||
for navigation to source documents.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 20, // max: 100
|
||||
"offset": 0, // pagination offset
|
||||
"include_pca": false, // optional PCA coordinates
|
||||
"include_chunks": true // include text snippets
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"results": [{
|
||||
"id": "doc123",
|
||||
"doc_type": "note",
|
||||
"title": "Document Title",
|
||||
"excerpt": "Matching text snippet...",
|
||||
"score": 0.85,
|
||||
"path": "/path/to/file.txt", // for files
|
||||
"board_id": 1, // for deck cards
|
||||
"card_id": 42
|
||||
}],
|
||||
"total_found": 150,
|
||||
"algorithm_used": "hybrid"
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
|
||||
# Validate and parse parameters
|
||||
try:
|
||||
query = body.get("query", "")
|
||||
_validate_query_string(query, max_length=10000)
|
||||
|
||||
limit = _parse_int_param(
|
||||
str(body.get("limit")) if body.get("limit") is not None else None,
|
||||
20,
|
||||
1,
|
||||
100,
|
||||
"limit",
|
||||
)
|
||||
|
||||
offset = _parse_int_param(
|
||||
str(body.get("offset")) if body.get("offset") is not None else None,
|
||||
0,
|
||||
0,
|
||||
1000000,
|
||||
"offset",
|
||||
)
|
||||
|
||||
score_threshold = _parse_float_param(
|
||||
body.get("score_threshold"),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
"score_threshold",
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
include_pca = body.get("include_pca", False)
|
||||
include_chunks = body.get("include_chunks", True)
|
||||
doc_types = body.get("doc_types") # Optional filter
|
||||
|
||||
if not query:
|
||||
return JSONResponse({"results": [], "total_found": 0})
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Request extra results to handle offset
|
||||
search_limit = limit + offset
|
||||
|
||||
# Execute search
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
for doc_type in doc_types:
|
||||
if doc_type:
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
else:
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
)
|
||||
|
||||
# Sort results by score (no deduplication - show all chunks)
|
||||
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Calculate total and apply pagination
|
||||
total_found = len(sorted_results)
|
||||
paginated_results = sorted_results[offset : offset + limit]
|
||||
|
||||
# Format results for Unified Search
|
||||
formatted_results = []
|
||||
for result in paginated_results:
|
||||
# Get document ID (prefer note_id for notes)
|
||||
doc_id = result.id
|
||||
if result.metadata and "note_id" in result.metadata:
|
||||
doc_id = result.metadata["note_id"]
|
||||
|
||||
result_data: dict[str, Any] = {
|
||||
"id": doc_id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"score": result.score,
|
||||
}
|
||||
|
||||
# Include excerpt/chunk if requested (full content, no truncation)
|
||||
if include_chunks and result.excerpt:
|
||||
result_data["excerpt"] = result.excerpt
|
||||
|
||||
# Include navigation metadata from result.metadata
|
||||
if result.metadata:
|
||||
# File path and mimetype for files
|
||||
if "path" in result.metadata:
|
||||
result_data["path"] = result.metadata["path"]
|
||||
if "mime_type" in result.metadata:
|
||||
result_data["mime_type"] = result.metadata["mime_type"]
|
||||
|
||||
# Deck card navigation
|
||||
if "board_id" in result.metadata:
|
||||
result_data["board_id"] = result.metadata["board_id"]
|
||||
if "card_id" in result.metadata:
|
||||
result_data["card_id"] = result.metadata["card_id"]
|
||||
|
||||
# Calendar event metadata
|
||||
if "calendar_id" in result.metadata:
|
||||
result_data["calendar_id"] = result.metadata["calendar_id"]
|
||||
if "event_uid" in result.metadata:
|
||||
result_data["event_uid"] = result.metadata["event_uid"]
|
||||
|
||||
# Add PDF page metadata
|
||||
if result.page_number is not None:
|
||||
result_data["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
result_data["page_count"] = result.page_count
|
||||
|
||||
# Add chunk metadata (always present, defaults to 0 and 1)
|
||||
result_data["chunk_index"] = result.chunk_index
|
||||
result_data["total_chunks"] = result.total_chunks
|
||||
|
||||
# Add chunk offsets for modal navigation
|
||||
if result.chunk_start_offset is not None:
|
||||
result_data["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
result_data["chunk_end_offset"] = result.chunk_end_offset
|
||||
|
||||
formatted_results.append(result_data)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"total_found": total_found,
|
||||
"algorithm_used": algorithm,
|
||||
}
|
||||
|
||||
# Optional PCA coordinates
|
||||
if include_pca and len(paginated_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(
|
||||
paginated_results, query_embedding
|
||||
)
|
||||
response_data["pca_data"] = pca_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA for unified search: {e}")
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in unified search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def vector_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/vector-viz/search - Vector search for visualization.
|
||||
|
||||
Executes semantic search and returns results with optional PCA coordinates
|
||||
for 2D visualization.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 10, // max: 50
|
||||
"include_pca": true, // whether to include 2D coordinates
|
||||
"doc_types": ["note", "file"] // optional filter by document types
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "vector_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
score_threshold = body.get("score_threshold", 0.0)
|
||||
limit = min(body.get("limit", 10), 50) # Enforce max limit
|
||||
include_pca = body.get("include_pca", True)
|
||||
doc_types = body.get("doc_types") # Optional list of document types
|
||||
|
||||
if not query:
|
||||
return JSONResponse(
|
||||
{"error": "Missing required parameter: query"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
|
||||
# which combines dense semantic and sparse BM25 vectors
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Execute search for each doc_type if specified, otherwise search all
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
# Search each doc_type separately and merge results
|
||||
for doc_type in doc_types:
|
||||
if doc_type: # Skip empty strings
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
# Sort merged results by score and limit
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
all_results = all_results[:limit]
|
||||
else:
|
||||
# Search all document types
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Format results for PHP client
|
||||
formatted_results = []
|
||||
for result in all_results:
|
||||
formatted_result = {
|
||||
"id": result.id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"excerpt": result.excerpt[:200] if result.excerpt else "",
|
||||
"score": result.score,
|
||||
"metadata": result.metadata,
|
||||
# Chunk information for context display
|
||||
"chunk_index": result.chunk_index,
|
||||
"total_chunks": result.total_chunks,
|
||||
}
|
||||
# Include optional fields if present
|
||||
if result.chunk_start_offset is not None:
|
||||
formatted_result["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
formatted_result["chunk_end_offset"] = result.chunk_end_offset
|
||||
if result.page_number is not None:
|
||||
formatted_result["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
formatted_result["page_count"] = result.page_count
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"algorithm_used": algorithm,
|
||||
"total_documents": len(formatted_results),
|
||||
}
|
||||
|
||||
# Compute PCA coordinates for visualization using shared function
|
||||
if include_pca and len(all_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
# Get query embedding from search algorithm or generate it
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(all_results, query_embedding)
|
||||
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
|
||||
response_data["query_coords"] = pca_data["query_coords"]
|
||||
if "pca_variance" in pca_data:
|
||||
response_data["pca_variance"] = pca_data["pca_variance"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA coordinates: {e}")
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
elif include_pca:
|
||||
# Not enough results for PCA
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "vector_search")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/chunk-context - Fetch chunk text with context.
|
||||
|
||||
Retrieves the matched chunk along with surrounding text and metadata.
|
||||
Used by clients to display chunk context and highlighted PDFs.
|
||||
|
||||
Query parameters:
|
||||
doc_type: Document type (e.g., "note")
|
||||
doc_id: Document ID
|
||||
start: Chunk start offset (character position)
|
||||
end: Chunk end offset (character position)
|
||||
context: Characters of context before/after (default: 500)
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_chunk_context"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get query parameters
|
||||
doc_type = request.query_params.get("doc_type")
|
||||
doc_id = request.query_params.get("doc_id")
|
||||
start_str = request.query_params.get("start")
|
||||
end_str = request.query_params.get("end")
|
||||
|
||||
# Validate required parameters
|
||||
if not all([doc_type, doc_id, start_str, end_str]):
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing required parameters: doc_type, doc_id, start, end",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Type narrowing: we already checked these are not None above
|
||||
assert start_str is not None
|
||||
assert end_str is not None
|
||||
assert doc_id is not None
|
||||
assert doc_type is not None
|
||||
|
||||
# Parse and validate integer parameters with bounds checking
|
||||
try:
|
||||
context_chars = _parse_int_param(
|
||||
request.query_params.get("context"),
|
||||
500,
|
||||
0,
|
||||
10000,
|
||||
"context_chars",
|
||||
)
|
||||
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
|
||||
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
|
||||
if end <= start:
|
||||
raise ValueError("end must be greater than start")
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
# Convert doc_id to int if possible (most IDs are int)
|
||||
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
|
||||
|
||||
# Get bearer token for client initialization
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Initialize authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=user_id,
|
||||
doc_id=doc_id_val,
|
||||
doc_type=doc_type,
|
||||
chunk_start=start,
|
||||
chunk_end=end,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
if chunk_context is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant if available
|
||||
# This is useful for clients that want to show a pre-rendered image
|
||||
highlighted_page_image = None
|
||||
page_number = chunk_context.page_number
|
||||
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(
|
||||
key="doc_id", match=MatchValue(value=doc_id_val)
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=user_id)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
)
|
||||
|
||||
if points_response[0]:
|
||||
payload = points_response[0][0].payload
|
||||
if payload:
|
||||
highlighted_page_image = payload.get("highlighted_page_image")
|
||||
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
|
||||
if payload.get("page_number") is not None:
|
||||
page_number = payload.get("page_number")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
"success": True,
|
||||
"chunk_text": chunk_context.chunk_text,
|
||||
"before_context": chunk_context.before_context,
|
||||
"after_context": chunk_context.after_context,
|
||||
"has_more_before": chunk_context.has_before_truncation,
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
"page_number": page_number,
|
||||
"chunk_index": chunk_context.chunk_index,
|
||||
"total_chunks": chunk_context.total_chunks,
|
||||
}
|
||||
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""App password management API endpoints.
|
||||
|
||||
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
|
||||
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||
- Store app passwords for background sync operations
|
||||
- Check app password status
|
||||
- Delete stored app passwords
|
||||
|
||||
Authentication is via BasicAuth with the user's Nextcloud credentials.
|
||||
Passwords are validated against Nextcloud before being stored.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
APP_PASSWORD_PATTERN = re.compile(
|
||||
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
|
||||
)
|
||||
|
||||
# Timeout for Nextcloud API validation requests (seconds)
|
||||
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
|
||||
|
||||
# Rate limiting configuration for app password provisioning
|
||||
# Limits: 5 attempts per user per hour
|
||||
RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
|
||||
|
||||
# In-memory rate limiter storage
|
||||
# Structure: {user_id: [(timestamp, success), ...]}
|
||||
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
|
||||
"""Check if user is rate limited for app password operations.
|
||||
|
||||
Implements a sliding window rate limiter to prevent brute-force attacks
|
||||
on the app password provisioning endpoint.
|
||||
|
||||
Args:
|
||||
user_id: User identifier to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed, seconds_until_retry)
|
||||
- is_allowed: True if request should be allowed
|
||||
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
|
||||
"""
|
||||
current_time = time.time()
|
||||
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
|
||||
|
||||
# Clean up old attempts outside the window
|
||||
_rate_limit_attempts[user_id] = [
|
||||
(ts, success)
|
||||
for ts, success in _rate_limit_attempts[user_id]
|
||||
if ts > window_start
|
||||
]
|
||||
|
||||
# Count recent attempts (both successful and failed)
|
||||
recent_attempts = len(_rate_limit_attempts[user_id])
|
||||
|
||||
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||
# Find when the oldest attempt in the window will expire
|
||||
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
|
||||
seconds_until_retry = int(
|
||||
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
|
||||
)
|
||||
return False, max(1, seconds_until_retry)
|
||||
|
||||
return True, 0
|
||||
|
||||
|
||||
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
|
||||
"""Record an app password provisioning attempt for rate limiting.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
success: Whether the attempt was successful
|
||||
"""
|
||||
_rate_limit_attempts[user_id].append((time.time(), success))
|
||||
|
||||
|
||||
def _extract_basic_auth(
|
||||
request: Request, path_user_id: str
|
||||
) -> tuple[str, str, JSONResponse | None]:
|
||||
"""Extract and validate BasicAuth credentials from request.
|
||||
|
||||
Validates:
|
||||
1. Authorization header is present and valid BasicAuth format
|
||||
2. Username in credentials matches the path user_id
|
||||
|
||||
Args:
|
||||
request: Starlette request with Authorization header
|
||||
path_user_id: User ID from the URL path to verify against
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password, error_response)
|
||||
- If successful: (username, password, None)
|
||||
- If failed: ("", "", JSONResponse with error)
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header or not auth_header.startswith("Basic "):
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Missing BasicAuth credentials"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Decode BasicAuth
|
||||
encoded = auth_header.split(" ", 1)[1]
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
except Exception:
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Invalid BasicAuth format"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
# Verify username matches path user_id
|
||||
if username != path_user_id:
|
||||
logger.warning(
|
||||
f"Username mismatch in app password operation for path user {path_user_id}"
|
||||
)
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Username does not match path user_id"},
|
||||
status_code=403,
|
||||
),
|
||||
)
|
||||
|
||||
return username, password, None
|
||||
|
||||
|
||||
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||
|
||||
Checks app.state.storage first, then falls back to creating from environment.
|
||||
This helper avoids repeated storage initialization logic across endpoints.
|
||||
|
||||
Args:
|
||||
request: Starlette request with app state
|
||||
|
||||
Returns:
|
||||
Initialized RefreshTokenStorage instance
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = getattr(request.app.state, "storage", None)
|
||||
|
||||
if not storage:
|
||||
# Multi-user BasicAuth mode may not have oauth_context
|
||||
# Initialize storage from environment
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
return storage
|
||||
|
||||
|
||||
async def provision_app_password(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
|
||||
|
||||
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
|
||||
for multi-user BasicAuth mode background sync.
|
||||
|
||||
The request must include BasicAuth credentials where:
|
||||
- username: Nextcloud user ID (must match path user_id)
|
||||
- password: The app password being provisioned
|
||||
|
||||
The MCP server validates the app password against Nextcloud before storing it.
|
||||
This proves the user owns the password and has access to Nextcloud.
|
||||
|
||||
Security model:
|
||||
- User identity is verified via BasicAuth against Nextcloud
|
||||
- App password is encrypted before storage
|
||||
- Only the user who owns the password can provision it
|
||||
- Rate limited to prevent brute-force attacks
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check rate limit before processing
|
||||
is_allowed, retry_after = _check_rate_limit(path_user_id)
|
||||
if not is_allowed:
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for app password provisioning: {path_user_id}"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||
},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return error_response
|
||||
|
||||
# Validate app password format
|
||||
if not APP_PASSWORD_PATTERN.match(app_password):
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password format"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Nextcloud host from settings
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
if not nextcloud_host:
|
||||
logger.error("NEXTCLOUD_HOST not configured")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Server not configured"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Validate app password against Nextcloud
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
# Use OCS API to verify credentials
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, app_password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"App password validation failed for user: HTTP {response.status_code}"
|
||||
)
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Verify the user ID from response matches
|
||||
data = response.json()
|
||||
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
|
||||
if ocs_user_id != username:
|
||||
logger.warning("User ID mismatch in OCS response")
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "User ID mismatch"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate app password: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Store the validated app password
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
await storage.store_app_password(username, app_password)
|
||||
|
||||
_record_rate_limit_attempt(path_user_id, success=True)
|
||||
logger.info(f"Provisioned app password for user: {username}")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password stored for {username}",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "provision_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_app_password_status(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
|
||||
|
||||
Returns status of background sync access for multi-user BasicAuth mode.
|
||||
|
||||
Requires BasicAuth with the user's app password for authentication.
|
||||
"""
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
app_password = await storage.get_app_password(username)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": username,
|
||||
"has_app_password": app_password is not None,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_app_password(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
|
||||
|
||||
Removes the user's app password from MCP server storage.
|
||||
|
||||
Requires BasicAuth with the user's credentials.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
# Validate credentials against Nextcloud
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid credentials"},
|
||||
status_code=401,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate credentials: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
deleted = await storage.delete_app_password(username)
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted app password for user: {username}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password deleted for {username}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": "No app password found to delete",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "delete_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -0,0 +1,813 @@
|
||||
"""Visualization API endpoints for search and PDF preview.
|
||||
|
||||
ADR-018: Provides REST API endpoints for the Nextcloud PHP app (Astrolabe) to:
|
||||
- Execute unified search with semantic/BM25/hybrid algorithms
|
||||
- Execute vector search with PCA visualization coordinates
|
||||
- Fetch chunk context with surrounding text
|
||||
- Render PDF pages server-side (avoiding CSP/worker issues)
|
||||
|
||||
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pymupdf
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
_parse_float_param,
|
||||
_parse_int_param,
|
||||
_sanitize_error_for_client,
|
||||
_validate_query_string,
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def unified_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
||||
|
||||
Optimized search endpoint for the Nextcloud Unified Search provider
|
||||
and other PHP app integrations. Returns results with metadata needed
|
||||
for navigation to source documents.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 20, // max: 100
|
||||
"offset": 0, // pagination offset
|
||||
"include_pca": false, // optional PCA coordinates
|
||||
"include_chunks": true // include text snippets
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"results": [{
|
||||
"id": "doc123",
|
||||
"doc_type": "note",
|
||||
"title": "Document Title",
|
||||
"excerpt": "Matching text snippet...",
|
||||
"score": 0.85,
|
||||
"path": "/path/to/file.txt", // for files
|
||||
"board_id": 1, // for deck cards
|
||||
"card_id": 42
|
||||
}],
|
||||
"total_found": 150,
|
||||
"algorithm_used": "hybrid"
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
|
||||
# Validate and parse parameters
|
||||
try:
|
||||
query = body.get("query", "")
|
||||
_validate_query_string(query, max_length=10000)
|
||||
|
||||
limit = _parse_int_param(
|
||||
str(body.get("limit")) if body.get("limit") is not None else None,
|
||||
20,
|
||||
1,
|
||||
100,
|
||||
"limit",
|
||||
)
|
||||
|
||||
offset = _parse_int_param(
|
||||
str(body.get("offset")) if body.get("offset") is not None else None,
|
||||
0,
|
||||
0,
|
||||
1000000,
|
||||
"offset",
|
||||
)
|
||||
|
||||
score_threshold = _parse_float_param(
|
||||
body.get("score_threshold"),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
"score_threshold",
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
include_pca = body.get("include_pca", False)
|
||||
include_chunks = body.get("include_chunks", True)
|
||||
doc_types = body.get("doc_types") # Optional filter
|
||||
|
||||
if not query:
|
||||
return JSONResponse({"results": [], "total_found": 0})
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Request extra results to handle offset
|
||||
search_limit = limit + offset
|
||||
|
||||
# Execute search
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
for doc_type in doc_types:
|
||||
if doc_type:
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
else:
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
)
|
||||
|
||||
# Sort results by score (no deduplication - show all chunks)
|
||||
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Calculate total and apply pagination
|
||||
total_found = len(sorted_results)
|
||||
paginated_results = sorted_results[offset : offset + limit]
|
||||
|
||||
# Format results for Unified Search
|
||||
formatted_results = []
|
||||
for result in paginated_results:
|
||||
# Get document ID (prefer note_id for notes)
|
||||
doc_id = result.id
|
||||
if result.metadata and "note_id" in result.metadata:
|
||||
doc_id = result.metadata["note_id"]
|
||||
|
||||
result_data: dict[str, Any] = {
|
||||
"id": doc_id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"score": result.score,
|
||||
}
|
||||
|
||||
# Include excerpt/chunk if requested (full content, no truncation)
|
||||
if include_chunks and result.excerpt:
|
||||
result_data["excerpt"] = result.excerpt
|
||||
|
||||
# Include navigation metadata from result.metadata
|
||||
if result.metadata:
|
||||
# File path and mimetype for files
|
||||
if "path" in result.metadata:
|
||||
result_data["path"] = result.metadata["path"]
|
||||
if "mime_type" in result.metadata:
|
||||
result_data["mime_type"] = result.metadata["mime_type"]
|
||||
|
||||
# Deck card navigation
|
||||
if "board_id" in result.metadata:
|
||||
result_data["board_id"] = result.metadata["board_id"]
|
||||
if "card_id" in result.metadata:
|
||||
result_data["card_id"] = result.metadata["card_id"]
|
||||
|
||||
# Calendar event metadata
|
||||
if "calendar_id" in result.metadata:
|
||||
result_data["calendar_id"] = result.metadata["calendar_id"]
|
||||
if "event_uid" in result.metadata:
|
||||
result_data["event_uid"] = result.metadata["event_uid"]
|
||||
|
||||
# Add PDF page metadata
|
||||
if result.page_number is not None:
|
||||
result_data["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
result_data["page_count"] = result.page_count
|
||||
|
||||
# Add chunk metadata (always present, defaults to 0 and 1)
|
||||
result_data["chunk_index"] = result.chunk_index
|
||||
result_data["total_chunks"] = result.total_chunks
|
||||
|
||||
# Add chunk offsets for modal navigation
|
||||
if result.chunk_start_offset is not None:
|
||||
result_data["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
result_data["chunk_end_offset"] = result.chunk_end_offset
|
||||
|
||||
formatted_results.append(result_data)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"total_found": total_found,
|
||||
"algorithm_used": algorithm,
|
||||
}
|
||||
|
||||
# Optional PCA coordinates
|
||||
if include_pca and len(paginated_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(
|
||||
paginated_results, query_embedding
|
||||
)
|
||||
response_data["pca_data"] = pca_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA for unified search: {e}")
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in unified search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def vector_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/vector-viz/search - Vector search for visualization.
|
||||
|
||||
Executes semantic search and returns results with optional PCA coordinates
|
||||
for 2D visualization.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 10, // max: 50
|
||||
"include_pca": true, // whether to include 2D coordinates
|
||||
"doc_types": ["note", "file"] // optional filter by document types
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "vector_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
score_threshold = body.get("score_threshold", 0.0)
|
||||
limit = min(body.get("limit", 10), 50) # Enforce max limit
|
||||
include_pca = body.get("include_pca", True)
|
||||
doc_types = body.get("doc_types") # Optional list of document types
|
||||
|
||||
if not query:
|
||||
return JSONResponse(
|
||||
{"error": "Missing required parameter: query"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
|
||||
# which combines dense semantic and sparse BM25 vectors
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Execute search for each doc_type if specified, otherwise search all
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
# Search each doc_type separately and merge results
|
||||
for doc_type in doc_types:
|
||||
if doc_type: # Skip empty strings
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
# Sort merged results by score and limit
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
all_results = all_results[:limit]
|
||||
else:
|
||||
# Search all document types
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Format results for PHP client
|
||||
formatted_results = []
|
||||
for result in all_results:
|
||||
formatted_result = {
|
||||
"id": result.id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"excerpt": result.excerpt[:200] if result.excerpt else "",
|
||||
"score": result.score,
|
||||
"metadata": result.metadata,
|
||||
# Chunk information for context display
|
||||
"chunk_index": result.chunk_index,
|
||||
"total_chunks": result.total_chunks,
|
||||
}
|
||||
# Include optional fields if present
|
||||
if result.chunk_start_offset is not None:
|
||||
formatted_result["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
formatted_result["chunk_end_offset"] = result.chunk_end_offset
|
||||
if result.page_number is not None:
|
||||
formatted_result["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
formatted_result["page_count"] = result.page_count
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"algorithm_used": algorithm,
|
||||
"total_documents": len(formatted_results),
|
||||
}
|
||||
|
||||
# Compute PCA coordinates for visualization using shared function
|
||||
if include_pca and len(all_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
# Get query embedding from search algorithm or generate it
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(all_results, query_embedding)
|
||||
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
|
||||
response_data["query_coords"] = pca_data["query_coords"]
|
||||
if "pca_variance" in pca_data:
|
||||
response_data["pca_variance"] = pca_data["pca_variance"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA coordinates: {e}")
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
elif include_pca:
|
||||
# Not enough results for PCA
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "vector_search")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/chunk-context - Fetch chunk text with context.
|
||||
|
||||
Retrieves the matched chunk along with surrounding text and metadata.
|
||||
Used by clients to display chunk context and highlighted PDFs.
|
||||
|
||||
Query parameters:
|
||||
doc_type: Document type (e.g., "note")
|
||||
doc_id: Document ID
|
||||
start: Chunk start offset (character position)
|
||||
end: Chunk end offset (character position)
|
||||
context: Characters of context before/after (default: 500)
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_chunk_context"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get query parameters
|
||||
doc_type = request.query_params.get("doc_type")
|
||||
doc_id = request.query_params.get("doc_id")
|
||||
start_str = request.query_params.get("start")
|
||||
end_str = request.query_params.get("end")
|
||||
|
||||
# Validate required parameters
|
||||
if not all([doc_type, doc_id, start_str, end_str]):
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing required parameters: doc_type, doc_id, start, end",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Type narrowing: we already checked these are not None above
|
||||
assert start_str is not None
|
||||
assert end_str is not None
|
||||
assert doc_id is not None
|
||||
assert doc_type is not None
|
||||
|
||||
# Parse and validate integer parameters with bounds checking
|
||||
try:
|
||||
context_chars = _parse_int_param(
|
||||
request.query_params.get("context"),
|
||||
500,
|
||||
0,
|
||||
10000,
|
||||
"context_chars",
|
||||
)
|
||||
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
|
||||
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
|
||||
if end <= start:
|
||||
raise ValueError("end must be greater than start")
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
# Convert doc_id to int if possible (most IDs are int)
|
||||
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
|
||||
|
||||
# Get bearer token for client initialization
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Initialize authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=user_id,
|
||||
doc_id=doc_id_val,
|
||||
doc_type=doc_type,
|
||||
chunk_start=start,
|
||||
chunk_end=end,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
if chunk_context is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant if available
|
||||
# This is useful for clients that want to show a pre-rendered image
|
||||
highlighted_page_image = None
|
||||
page_number = chunk_context.page_number
|
||||
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(
|
||||
key="doc_id", match=MatchValue(value=doc_id_val)
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=user_id)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
)
|
||||
|
||||
if points_response[0]:
|
||||
payload = points_response[0][0].payload
|
||||
if payload:
|
||||
highlighted_page_image = payload.get("highlighted_page_image")
|
||||
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
|
||||
if payload.get("page_number") is not None:
|
||||
page_number = payload.get("page_number")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
"success": True,
|
||||
"chunk_text": chunk_context.chunk_text,
|
||||
"before_context": chunk_context.before_context,
|
||||
"after_context": chunk_context.after_context,
|
||||
"has_more_before": chunk_context.has_before_truncation,
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
"page_number": page_number,
|
||||
"chunk_index": chunk_context.chunk_index,
|
||||
"total_chunks": chunk_context.total_chunks,
|
||||
}
|
||||
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_pdf_preview(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/pdf-preview - Render PDF page to PNG image.
|
||||
|
||||
Server-side PDF rendering using PyMuPDF. This endpoint allows Astrolabe
|
||||
to display PDF pages without requiring client-side PDF.js, avoiding CSP
|
||||
worker restrictions and ES private field issues in Chromium.
|
||||
|
||||
Query parameters:
|
||||
file_path: WebDAV path to PDF file (e.g., "/Documents/report.pdf")
|
||||
page: Page number (1-indexed, default: 1)
|
||||
scale: Zoom factor for rendering (default: 2.0 = 144 DPI)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"image": "<base64-encoded-png>",
|
||||
"page_number": 1,
|
||||
"total_pages": 10
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
# Log incoming request
|
||||
file_path_param = request.query_params.get("file_path", "<not provided>")
|
||||
page_param = request.query_params.get("page", "1")
|
||||
logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}")
|
||||
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
logger.info(f"PDF preview authenticated for user: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_pdf_preview"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse and validate parameters
|
||||
file_path = request.query_params.get("file_path")
|
||||
if not file_path:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing required parameter: file_path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate no path traversal sequences
|
||||
if ".." in file_path:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid file path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
page_num = _parse_int_param(
|
||||
request.query_params.get("page"), 1, 1, 10000, "page"
|
||||
)
|
||||
scale = _parse_float_param(
|
||||
request.query_params.get("scale"), 2.0, 0.5, 5.0, "scale"
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
|
||||
# Get bearer token for WebDAV authentication
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Download PDF via WebDAV using user's token
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
pdf_bytes, _ = await nc_client.webdav.read_file(file_path)
|
||||
|
||||
# Check file size limit (50 MB)
|
||||
max_pdf_size = 50 * 1024 * 1024
|
||||
if len(pdf_bytes) > max_pdf_size:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"PDF file exceeds maximum size limit ({max_pdf_size // (1024 * 1024)} MB)",
|
||||
},
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
# Render page with PyMuPDF
|
||||
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
||||
try:
|
||||
total_pages = doc.page_count
|
||||
|
||||
# Validate page number
|
||||
if page_num > total_pages:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Page {page_num} does not exist (document has {total_pages} pages)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
page = doc[page_num - 1] # 0-indexed
|
||||
mat = pymupdf.Matrix(scale, scale)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
png_bytes = pix.tobytes("png")
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
# Encode as base64
|
||||
image_b64 = base64.b64encode(png_bytes).decode("ascii")
|
||||
|
||||
logger.info(
|
||||
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, "
|
||||
f"{len(png_bytes):,} bytes"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"image": image_b64,
|
||||
"page_number": page_num,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"PDF file not found: {file_path_param}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "PDF file not found"},
|
||||
status_code=404,
|
||||
)
|
||||
except (pymupdf.FileDataError, pymupdf.EmptyFileError):
|
||||
logger.warning(f"Invalid or corrupted PDF file: {file_path_param}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid or corrupted PDF file"},
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"PDF preview error: {e}", exc_info=True)
|
||||
error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Webhook management API endpoints.
|
||||
|
||||
Provides REST API endpoints for managing webhook registrations with Nextcloud.
|
||||
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||
- List installed Nextcloud apps
|
||||
- Create, list, and delete webhook registrations
|
||||
|
||||
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
_sanitize_error_for_client,
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||
|
||||
Returns a list of installed app IDs for filtering webhook presets.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Get installed apps using OCS API
|
||||
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
|
||||
# We check which ones are installed and enabled
|
||||
ocs_url = "/ocs/v1.php/cloud/apps"
|
||||
params = {"filter": "enabled"}
|
||||
|
||||
response = await client.get(
|
||||
ocs_url,
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"OCS API returned status {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
|
||||
|
||||
return JSONResponse({"apps": apps})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting installed apps for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def list_webhooks(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/webhooks - List all registered webhooks.
|
||||
|
||||
Returns list of webhook registrations for the authenticated user.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to list webhooks
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhooks = await webhooks_client.list_webhooks()
|
||||
|
||||
return JSONResponse({"webhooks": webhooks})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing webhooks for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def create_webhook(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/webhooks - Create a new webhook registration.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"uri": "http://mcp:8000/webhooks/nextcloud",
|
||||
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
|
||||
}
|
||||
|
||||
Returns the created webhook data including the webhook ID.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
event = body.get("event")
|
||||
uri = body.get("uri")
|
||||
# Accept both camelCase (eventFilter) and snake_case (event_filter)
|
||||
event_filter = body.get("eventFilter") or body.get("event_filter")
|
||||
|
||||
if not event or not uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Bad request",
|
||||
"message": "Missing required fields: event, uri",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to create webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhook_data = await webhooks_client.create_webhook(
|
||||
event=event, uri=uri, event_filter=event_filter
|
||||
)
|
||||
|
||||
return JSONResponse({"webhook": webhook_data})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_webhook(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
|
||||
|
||||
Returns success/failure status.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get webhook_id from path parameter
|
||||
webhook_id = request.path_params.get("webhook_id")
|
||||
if not webhook_id:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Missing webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
webhook_id = int(webhook_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Invalid webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to delete webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
await webhooks_client.delete_webhook(webhook_id=webhook_id)
|
||||
|
||||
return JSONResponse({"success": True, "message": "Webhook deleted"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -2012,7 +2012,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
checks["auth_mode"] = "multi_user_basic"
|
||||
checks["auth_configured"] = "ok"
|
||||
# Indicate if app passwords are supported (when offline_access enabled)
|
||||
checks["supports_app_passwords"] = settings.enable_offline_access
|
||||
checks["supports_app_passwords"] = get_settings().enable_offline_access
|
||||
elif mode == AuthMode.SINGLE_USER_BASIC:
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
@@ -2029,9 +2029,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
|
||||
# Check Qdrant status if using network mode (external Qdrant service)
|
||||
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||
settings = get_settings()
|
||||
vector_sync_enabled = settings.vector_sync_enabled
|
||||
qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode
|
||||
|
||||
if vector_sync_enabled and qdrant_url:
|
||||
@@ -2112,15 +2112,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||
)
|
||||
if enable_management_apis:
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
from nextcloud_mcp_server.api import (
|
||||
create_webhook,
|
||||
delete_app_password,
|
||||
delete_webhook,
|
||||
get_app_password_status,
|
||||
get_chunk_context,
|
||||
get_installed_apps,
|
||||
get_pdf_preview,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
list_webhooks,
|
||||
provision_app_password,
|
||||
revoke_user_access,
|
||||
unified_search,
|
||||
vector_search,
|
||||
@@ -2148,12 +2152,36 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
# App password endpoints for multi-user BasicAuth mode
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
|
||||
)
|
||||
routes.append(
|
||||
Route("/api/v1/chunk-context", get_chunk_context, methods=["GET"])
|
||||
)
|
||||
# PDF preview endpoint for Astrolabe (server-side rendering)
|
||||
routes.append(Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]))
|
||||
# ADR-018: Unified search endpoint for Nextcloud PHP app integration
|
||||
routes.append(Route("/api/v1/search", unified_search, methods=["POST"]))
|
||||
routes.append(Route("/api/v1/apps", get_installed_apps, methods=["GET"]))
|
||||
@@ -2166,8 +2194,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
logger.info(
|
||||
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
||||
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
||||
"/api/v1/users/{user_id}/app-password, "
|
||||
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||
"/api/v1/webhooks"
|
||||
"/api/v1/webhooks, /api/v1/pdf-preview"
|
||||
)
|
||||
|
||||
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||
|
||||
@@ -1240,6 +1240,180 @@ class RefreshTokenStorage:
|
||||
|
||||
return deleted
|
||||
|
||||
# ============================================================================
|
||||
# App Password Storage (multi-user BasicAuth mode)
|
||||
# ============================================================================
|
||||
|
||||
async def store_app_password(
|
||||
self,
|
||||
user_id: str,
|
||||
app_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Store encrypted app password for background sync (multi-user BasicAuth mode).
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
app_password: Nextcloud app password to store
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for app password storage."
|
||||
)
|
||||
|
||||
encrypted_password = self.cipher.encrypt(app_password.encode())
|
||||
now = int(time.time())
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO app_passwords
|
||||
(user_id, encrypted_password, created_at, updated_at)
|
||||
VALUES (
|
||||
?,
|
||||
?,
|
||||
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
|
||||
?
|
||||
)
|
||||
""",
|
||||
(user_id, encrypted_password, user_id, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(f"Stored app password for user {user_id}")
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "error")
|
||||
raise
|
||||
|
||||
# Audit log
|
||||
await self._audit_log(
|
||||
event="store_app_password",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
|
||||
async def get_app_password(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve and decrypt app password for a user.
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
|
||||
Returns:
|
||||
Decrypted app password, or None if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||
(user_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"No app password found for user {user_id}")
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
return None
|
||||
|
||||
encrypted_password = row[0]
|
||||
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
logger.debug(f"Retrieved app password for user {user_id}")
|
||||
|
||||
return decrypted_password
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_app_password(self, user_id: str) -> bool:
|
||||
"""
|
||||
Delete app password for a user.
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
|
||||
Returns:
|
||||
True if password was deleted, False if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM app_passwords WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted app password for user {user_id}")
|
||||
await self._audit_log(
|
||||
event="delete_app_password",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No app password to delete for user {user_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "error")
|
||||
raise
|
||||
|
||||
async def get_all_app_password_user_ids(self) -> list[str]:
|
||||
"""
|
||||
Get list of all user IDs with stored app passwords.
|
||||
|
||||
Returns:
|
||||
List of user IDs
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
user_ids = [row[0] for row in rows]
|
||||
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||
return user_ids
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,7 @@ from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,9 +107,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
"status": str, # "syncing" or "idle"
|
||||
}
|
||||
"""
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
if not vector_sync_enabled:
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -127,10 +128,8 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection
|
||||
@@ -634,7 +633,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled (needed for Welcome tab)
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||
settings = get_settings()
|
||||
vector_sync_enabled = settings.vector_sync_enabled
|
||||
|
||||
# Render template
|
||||
template = _jinja_env.get_template("user_info.html")
|
||||
|
||||
@@ -386,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> None:
|
||||
# Use the non-API route /cards/{cardId}/reorder which correctly reads
|
||||
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
|
||||
# has a parameter conflict where URL stackId overrides body stackId.
|
||||
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
json_data = {"order": order, "stackId": target_stack_id}
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
||||
f"/apps/deck/cards/{card_id}/reorder",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Labels
|
||||
|
||||
@@ -637,7 +637,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Remove Label from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
@@ -692,7 +694,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Unassign User from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Semantic search MCP tools using vector database."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import anyio
|
||||
from httpx import RequestError
|
||||
@@ -658,12 +657,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
after creating or updating content across all indexed apps.
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
if not vector_sync_enabled:
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return VectorSyncStatusResponse(
|
||||
indexed_count=0,
|
||||
pending_count=0,
|
||||
|
||||
@@ -8,8 +8,8 @@ Manages background vector sync for multi-user deployments:
|
||||
Authentication strategies are mutually exclusive by deployment mode:
|
||||
|
||||
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
|
||||
- Uses app passwords obtained via Astrolabe Management API
|
||||
- Users provision via Astrolabe personal settings
|
||||
- Uses app passwords stored locally in MCP server's database
|
||||
- Users provision via Astrolabe personal settings, which sends to MCP API
|
||||
- OAuth is NOT used
|
||||
|
||||
OAuth mode (with external IdP like Keycloak):
|
||||
@@ -33,7 +33,6 @@ from anyio.streams.memory import (
|
||||
)
|
||||
from httpx import BasicAuth
|
||||
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||
@@ -71,15 +70,18 @@ class UserSyncState:
|
||||
async def get_user_client_basic_auth(
|
||||
user_id: str,
|
||||
nextcloud_host: str,
|
||||
storage: "RefreshTokenStorage | None" = None,
|
||||
) -> NextcloudClient:
|
||||
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
|
||||
|
||||
For multi-user BasicAuth deployments where users provision app passwords
|
||||
via Astrolabe personal settings. OAuth is NOT used in this mode.
|
||||
via Astrolabe personal settings. The app password is stored locally in the
|
||||
MCP server's database after being provisioned through the management API.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
nextcloud_host: Nextcloud base URL
|
||||
storage: Optional RefreshTokenStorage instance (created from env if not provided)
|
||||
|
||||
Returns:
|
||||
Authenticated NextcloudClient with BasicAuth
|
||||
@@ -87,21 +89,15 @@ async def get_user_client_basic_auth(
|
||||
Raises:
|
||||
NotProvisionedError: If user has not provisioned an app password
|
||||
"""
|
||||
settings = get_settings()
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
raise NotProvisionedError(
|
||||
"Astrolabe client credentials not configured. "
|
||||
"Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval."
|
||||
)
|
||||
# Get or create storage instance
|
||||
if storage is None:
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
astrolabe = AstrolabeClient(
|
||||
nextcloud_host=nextcloud_host,
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
)
|
||||
|
||||
app_password = await astrolabe.get_user_app_password(user_id)
|
||||
# Retrieve app password from local storage
|
||||
app_password = await storage.get_app_password(user_id)
|
||||
|
||||
if not app_password:
|
||||
raise NotProvisionedError(
|
||||
@@ -419,8 +415,15 @@ async def user_manager_task(
|
||||
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Get current provisioned users
|
||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||
# Get current provisioned users based on mode
|
||||
if use_basic_auth:
|
||||
# BasicAuth mode: query app_passwords table
|
||||
provisioned_users = set(
|
||||
await refresh_token_storage.get_all_app_password_user_ids()
|
||||
)
|
||||
else:
|
||||
# OAuth mode: query refresh_tokens table
|
||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||
active_users = set(user_states.keys())
|
||||
|
||||
# Start scanners for new users
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.60.2"
|
||||
version = "0.63.1"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database query helper for development.
|
||||
|
||||
Wraps `docker compose exec db mariadb` to execute SQL statements against
|
||||
the Nextcloud MariaDB database.
|
||||
|
||||
Usage:
|
||||
./scripts/dbquery.py "SELECT * FROM oc_notes LIMIT 5"
|
||||
./scripts/dbquery.py -u root -p password "SHOW TABLES"
|
||||
./scripts/dbquery.py --json "SELECT * FROM oc_oidc_clients"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_compose_dir() -> Path:
|
||||
"""Find the directory containing docker-compose.yml."""
|
||||
current = Path(__file__).resolve().parent
|
||||
while current != current.parent:
|
||||
if (current / "docker-compose.yml").exists():
|
||||
return current
|
||||
if (current / "compose.yml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Default to script's parent directory
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run_query(
|
||||
sql: str,
|
||||
user: str = "root",
|
||||
password: str = "password",
|
||||
database: str = "nextcloud",
|
||||
vertical: bool = False,
|
||||
json_output: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""
|
||||
Execute SQL via docker compose exec.
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
compose_dir = find_compose_dir()
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T", # Disable pseudo-TTY allocation
|
||||
"db",
|
||||
"mariadb",
|
||||
f"-u{user}",
|
||||
f"-p{password}",
|
||||
database,
|
||||
"-e",
|
||||
sql,
|
||||
]
|
||||
|
||||
if vertical:
|
||||
cmd.insert(-2, "-E") # Vertical output format
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=compose_dir,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute SQL queries against the Nextcloud MariaDB database",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s "SELECT COUNT(*) FROM oc_notes"
|
||||
%(prog)s "SELECT id, name FROM oc_oidc_clients"
|
||||
%(prog)s -E "SELECT * FROM oc_users LIMIT 1"
|
||||
%(prog)s --user nextcloud --password nextcloud "SHOW TABLES"
|
||||
""",
|
||||
)
|
||||
parser.add_argument("sql", help="SQL statement to execute")
|
||||
parser.add_argument(
|
||||
"-u", "--user", default="root", help="Database user (default: root)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--password",
|
||||
default="password",
|
||||
help="Database password (default: password)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--database",
|
||||
default="nextcloud",
|
||||
help="Database name (default: nextcloud)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-E",
|
||||
"--vertical",
|
||||
action="store_true",
|
||||
help="Print output vertically (one column per line)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json_output",
|
||||
help="Request JSON output (if supported)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
returncode, stdout, stderr = run_query(
|
||||
sql=args.sql,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
vertical=args.vertical,
|
||||
json_output=args.json_output,
|
||||
)
|
||||
|
||||
if stdout:
|
||||
print(stdout, end="")
|
||||
if stderr:
|
||||
# Filter out the password warning
|
||||
filtered_stderr = "\n".join(
|
||||
line
|
||||
for line in stderr.splitlines()
|
||||
if "Using a password on the command line interface can be insecure"
|
||||
not in line
|
||||
)
|
||||
if filtered_stderr:
|
||||
print(filtered_stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite database query helper for MCP service development.
|
||||
|
||||
Wraps `docker compose exec <service> sqlite3` to execute SQL statements
|
||||
against the token storage database in any MCP service container.
|
||||
|
||||
Usage:
|
||||
./scripts/sqlitequery.py ".tables"
|
||||
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||
./scripts/sqlitequery.py -s keycloak --headers "SELECT * FROM oauth_clients"
|
||||
./scripts/sqlitequery.py --json "SELECT * FROM audit_logs LIMIT 5"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Service name aliases for convenience
|
||||
SERVICE_ALIASES = {
|
||||
"mcp": "mcp",
|
||||
"oauth": "mcp-oauth",
|
||||
"mcp-oauth": "mcp-oauth",
|
||||
"keycloak": "mcp-keycloak",
|
||||
"mcp-keycloak": "mcp-keycloak",
|
||||
"basic": "mcp-multi-user-basic",
|
||||
"multi-user-basic": "mcp-multi-user-basic",
|
||||
"mcp-multi-user-basic": "mcp-multi-user-basic",
|
||||
}
|
||||
|
||||
|
||||
def find_compose_dir() -> Path:
|
||||
"""Find the directory containing docker-compose.yml."""
|
||||
current = Path(__file__).resolve().parent
|
||||
while current != current.parent:
|
||||
if (current / "docker-compose.yml").exists():
|
||||
return current
|
||||
if (current / "compose.yml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Default to script's parent directory
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def resolve_service(service: str) -> str:
|
||||
"""Resolve service alias to container name."""
|
||||
resolved = SERVICE_ALIASES.get(service.lower())
|
||||
if resolved is None:
|
||||
# Not a known alias, use as-is (might be a custom service)
|
||||
return service
|
||||
return resolved
|
||||
|
||||
|
||||
def run_query(
|
||||
sql: str,
|
||||
service: str = "mcp",
|
||||
database: str = "/app/data/tokens.db",
|
||||
headers: bool = False,
|
||||
json_output: bool = False,
|
||||
column_mode: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""
|
||||
Execute SQL via docker compose exec.
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
compose_dir = find_compose_dir()
|
||||
container = resolve_service(service)
|
||||
|
||||
# Build sqlite3 command with options
|
||||
sqlite_args = []
|
||||
|
||||
# Set output mode
|
||||
if json_output:
|
||||
sqlite_args.extend(["-json"])
|
||||
elif column_mode:
|
||||
sqlite_args.extend(["-column"])
|
||||
|
||||
# Enable headers
|
||||
if headers or column_mode:
|
||||
sqlite_args.extend(["-header"])
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T", # Disable pseudo-TTY allocation
|
||||
container,
|
||||
"sqlite3",
|
||||
*sqlite_args,
|
||||
database,
|
||||
sql,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=compose_dir,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute SQL queries against SQLite databases in MCP service containers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Services:
|
||||
mcp Single-user BasicAuth mode (default)
|
||||
oauth Nextcloud OAuth mode (mcp-oauth)
|
||||
keycloak Keycloak OAuth mode (mcp-keycloak)
|
||||
basic Multi-user BasicAuth mode (mcp-multi-user-basic)
|
||||
|
||||
Examples:
|
||||
%(prog)s ".tables"
|
||||
%(prog)s -s oauth "SELECT user_id FROM refresh_tokens"
|
||||
%(prog)s -s keycloak ".schema oauth_clients"
|
||||
%(prog)s --headers "SELECT * FROM audit_logs LIMIT 5"
|
||||
%(prog)s --json "SELECT * FROM oauth_sessions"
|
||||
""",
|
||||
)
|
||||
parser.add_argument("sql", help="SQL statement or SQLite command to execute")
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--service",
|
||||
default="mcp",
|
||||
help="Target service (mcp, oauth, keycloak, basic) (default: mcp)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--database",
|
||||
default="/app/data/tokens.db",
|
||||
help="Database path inside container (default: /app/data/tokens.db)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headers",
|
||||
action="store_true",
|
||||
help="Show column headers",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json_output",
|
||||
help="Output in JSON format",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--column",
|
||||
action="store_true",
|
||||
dest="column_mode",
|
||||
help="Output in column format with headers",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
returncode, stdout, stderr = run_query(
|
||||
sql=args.sql,
|
||||
service=args.service,
|
||||
database=args.database,
|
||||
headers=args.headers,
|
||||
json_output=args.json_output,
|
||||
column_mode=args.column_mode,
|
||||
)
|
||||
|
||||
if stdout:
|
||||
print(stdout, end="")
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+31
-22
@@ -2351,32 +2351,41 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating editors group (may already exist): {e}")
|
||||
|
||||
# Create each test user
|
||||
# Create each test user (idempotent - check if exists first)
|
||||
for username, config in test_user_configs.items():
|
||||
# Check if user already exists
|
||||
user_exists = False
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=username,
|
||||
password=config["password"],
|
||||
display_name=config["display_name"],
|
||||
email=config["email"],
|
||||
)
|
||||
logger.info(f"Created test user: {username}")
|
||||
created_users.append(username)
|
||||
await nc_client.users.get_user_details(username)
|
||||
user_exists = True
|
||||
logger.info(f"Test user {username} already exists, skipping creation")
|
||||
except Exception:
|
||||
# User doesn't exist, proceed with creation
|
||||
pass
|
||||
|
||||
# Add user to groups if specified
|
||||
for group in config["groups"]:
|
||||
try:
|
||||
await nc_client.users.add_user_to_group(username, group)
|
||||
logger.info(f"Added {username} to group {group}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error adding {username} to group {group}: {e}")
|
||||
if not user_exists:
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=username,
|
||||
password=config["password"],
|
||||
display_name=config["display_name"],
|
||||
email=config["email"],
|
||||
)
|
||||
logger.info(f"Created test user: {username}")
|
||||
created_users.append(username) # Only track users WE created
|
||||
|
||||
except Exception as e:
|
||||
# User might already exist, that's okay
|
||||
logger.warning(
|
||||
f"Could not create user {username} (may already exist): {e}"
|
||||
)
|
||||
created_users.append(username) # Add to list anyway for cleanup
|
||||
# Add user to groups if specified
|
||||
for group in config["groups"]:
|
||||
try:
|
||||
await nc_client.users.add_user_to_group(username, group)
|
||||
logger.info(f"Added {username} to group {group}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error adding {username} to group {group}: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create user {username}: {e}")
|
||||
|
||||
logger.info(f"Test users setup complete: {created_users}")
|
||||
yield test_user_configs
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Integration tests for app password provisioning via Astrolabe.
|
||||
"""Integration tests for app password provisioning via management API.
|
||||
|
||||
Tests the complete flow for multi-user BasicAuth mode:
|
||||
1. User stores app password via Astrolabe API
|
||||
2. MCP server retrieves it via OAuth client credentials
|
||||
3. Background sync uses it to access Nextcloud (NOT OAuth refresh tokens)
|
||||
1. User stores app password via management API endpoint
|
||||
2. MCP server stores it locally (encrypted)
|
||||
3. Background sync uses locally stored password to access Nextcloud
|
||||
|
||||
These tests verify that BasicAuth and OAuth are completely separate concerns
|
||||
with no fallback between them.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
NotProvisionedError,
|
||||
get_user_client,
|
||||
@@ -21,140 +24,60 @@ from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_astrolabe_client_initialization():
|
||||
"""Test AstrolabeClient can be instantiated."""
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
)
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
assert client is not None
|
||||
assert client.nextcloud_host == "http://localhost:8080"
|
||||
assert client.client_id == "test-client"
|
||||
assert client.client_secret == "test-secret"
|
||||
assert client._token_cache is None
|
||||
|
||||
@pytest.fixture
|
||||
async def temp_storage(encryption_key):
|
||||
"""Create temporary storage instance with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_provisioning.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_astrolabe_client_get_access_token_requires_oidc():
|
||||
"""Test that getting access token requires OIDC discovery endpoint."""
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
)
|
||||
async def test_basic_auth_mode_uses_local_storage(temp_storage, mocker):
|
||||
"""Test that BasicAuth mode uses locally stored app passwords.
|
||||
|
||||
# This will fail without proper OIDC setup, which is expected
|
||||
# The test verifies the client follows the OAuth client credentials flow
|
||||
try:
|
||||
token = await client.get_access_token()
|
||||
# If we get here, OIDC is configured
|
||||
assert token is not None
|
||||
except Exception as e:
|
||||
# Expected if OIDC not fully configured for test client
|
||||
# 400/401/403/404 all indicate the flow is working but credentials are invalid
|
||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_app_password_returns_none_for_unconfigured_user():
|
||||
"""Test that get_user_app_password returns None for users without app passwords."""
|
||||
# This requires valid OAuth client credentials
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
pytest.skip("OAuth client credentials not configured")
|
||||
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
)
|
||||
|
||||
# Try to get app password for a user that hasn't provisioned one
|
||||
try:
|
||||
app_password = await client.get_user_app_password("nonexistent_user")
|
||||
# Should return None for unconfigured user (404 response)
|
||||
assert app_password is None
|
||||
except Exception as e:
|
||||
# May fail with auth error if OAuth not fully configured
|
||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_basic_auth_mode_uses_app_password_only(mocker):
|
||||
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
|
||||
|
||||
In multi-user BasicAuth mode, OAuth refresh tokens are NOT used.
|
||||
This is a complete separation of concerns.
|
||||
In multi-user BasicAuth mode, app passwords are stored locally
|
||||
in the MCP server's database after being provisioned via the API.
|
||||
"""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
# Store an app password in local storage
|
||||
await temp_storage.store_app_password("test_user", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||
|
||||
# Mock AstrolabeClient to return an app password
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call get_user_client in BasicAuth mode
|
||||
_client = await get_user_client(
|
||||
# Call get_user_client_basic_auth with local storage
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id="test_user",
|
||||
token_broker=None, # No token broker needed for BasicAuth mode
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
# Verify app password was requested
|
||||
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
||||
|
||||
# Verify client was created successfully with correct username
|
||||
assert _client is not None
|
||||
assert _client.username == "test_user"
|
||||
# Verify client was created with correct credentials
|
||||
assert client is not None
|
||||
assert client.username == "test_user"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
||||
async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
|
||||
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
|
||||
|
||||
There is NO fallback to OAuth - if no app password, user must provision one.
|
||||
"""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
# Don't store any app password
|
||||
|
||||
# Mock AstrolabeClient to return None (no app password)
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call get_user_client in BasicAuth mode - should raise NotProvisionedError
|
||||
# Call get_user_client_basic_auth - should raise NotProvisionedError
|
||||
with pytest.raises(NotProvisionedError) as exc_info:
|
||||
await get_user_client(
|
||||
await get_user_client_basic_auth(
|
||||
user_id="test_user",
|
||||
token_broker=None,
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
# Verify error message mentions app password provisioning
|
||||
@@ -162,6 +85,33 @@ async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
||||
assert "test_user" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_dispatches_to_basic_auth(temp_storage, mocker):
|
||||
"""Test that get_user_client dispatches to BasicAuth mode correctly."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("alice", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
# Mock RefreshTokenStorage.from_env at the source module
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||
return_value=temp_storage,
|
||||
)
|
||||
# Also mock initialize since from_env returns an uninitialized instance
|
||||
mocker.patch.object(temp_storage, "initialize", return_value=None)
|
||||
|
||||
# Call get_user_client in BasicAuth mode
|
||||
client = await get_user_client(
|
||||
user_id="alice",
|
||||
token_broker=None, # No token broker needed for BasicAuth mode
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
)
|
||||
|
||||
# Verify client was created successfully
|
||||
assert client is not None
|
||||
assert client.username == "alice"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
|
||||
@@ -183,7 +133,7 @@ async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||
use_basic_auth=False, # OAuth mode
|
||||
)
|
||||
|
||||
# Verify token broker was called (NOT Astrolabe)
|
||||
# Verify token broker was called
|
||||
mock_token_broker.get_background_token.assert_called_once()
|
||||
|
||||
|
||||
@@ -213,38 +163,6 @@ async def test_oauth_mode_raises_error_without_token(mocker):
|
||||
assert "test_user" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_basic_auth_function(mocker):
|
||||
"""Test the dedicated get_user_client_basic_auth function."""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
|
||||
# Mock AstrolabeClient
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call dedicated function
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id="alice",
|
||||
nextcloud_host="http://localhost:8080",
|
||||
)
|
||||
|
||||
assert client is not None
|
||||
assert client.username == "alice"
|
||||
mock_astrolabe.get_user_app_password.assert_called_once_with("alice")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_oauth_function(mocker):
|
||||
"""Test the dedicated get_user_client_oauth function."""
|
||||
@@ -276,3 +194,69 @@ async def test_oauth_mode_requires_token_broker():
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=False, # OAuth mode
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_multiple_users_basic_auth_mode(temp_storage, mocker):
|
||||
"""Test that multiple users can be provisioned independently."""
|
||||
# Store app passwords for multiple users
|
||||
users = {
|
||||
"alice": "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa",
|
||||
"bob": "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb",
|
||||
"charlie": "ccccc-ccccc-ccccc-ccccc-ccccc",
|
||||
}
|
||||
|
||||
for user_id, password in users.items():
|
||||
await temp_storage.store_app_password(user_id, password)
|
||||
|
||||
# Verify each user can get a client
|
||||
for user_id in users.keys():
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id=user_id,
|
||||
nextcloud_host="http://localhost:8080",
|
||||
storage=temp_storage,
|
||||
)
|
||||
assert client is not None
|
||||
assert client.username == user_id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_all_provisioned_users(temp_storage):
|
||||
"""Test that we can list all provisioned users for BasicAuth mode."""
|
||||
# Store app passwords for multiple users
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||
|
||||
# Get all provisioned users
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
|
||||
assert len(user_ids) == 2
|
||||
assert "alice" in user_ids
|
||||
assert "bob" in user_ids
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_revoke_app_password(temp_storage):
|
||||
"""Test that deleting app password revokes background access."""
|
||||
# Provision user
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
|
||||
# Verify user is provisioned
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert "alice" in user_ids
|
||||
|
||||
# Revoke access
|
||||
deleted = await temp_storage.delete_app_password("alice")
|
||||
assert deleted is True
|
||||
|
||||
# Verify user is no longer provisioned
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert "alice" not in user_ids
|
||||
|
||||
# Verify get_user_client now raises NotProvisionedError
|
||||
with pytest.raises(NotProvisionedError):
|
||||
await get_user_client_basic_auth(
|
||||
user_id="alice",
|
||||
nextcloud_host="http://localhost:8080",
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
@@ -43,8 +43,19 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
|
||||
# Submit form
|
||||
await page.click('button[type="submit"]')
|
||||
# Submit form - use force=True to bypass stability check (CSS transitions)
|
||||
submit_button = page.locator('button[type="submit"]')
|
||||
try:
|
||||
await submit_button.click(force=True, timeout=10000)
|
||||
except Exception:
|
||||
# Fallback: JavaScript click
|
||||
logger.info("Using JavaScript click for login button...")
|
||||
await page.evaluate(
|
||||
"""
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
"""
|
||||
)
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
# Verify logged in (should redirect away from login page)
|
||||
@@ -75,6 +86,289 @@ async def navigate_to_astrolabe_settings(page: Page):
|
||||
logger.info("✓ Successfully loaded Astrolabe settings page")
|
||||
|
||||
|
||||
async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
"""Complete Step 1: OAuth Authorization for Astrolabe.
|
||||
|
||||
Handles the OAuth flow:
|
||||
1. Check if already authorized (Step 1 shows "Complete")
|
||||
2. Click "Authorize" link
|
||||
3. Handle Nextcloud OIDC consent screen
|
||||
4. Wait for redirect back to Astrolabe settings
|
||||
5. Verify "Complete" badge appears on Step 1
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be on Astrolabe settings page)
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if authorization completed successfully
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Authorizing search access (Step 1) for {username}...")
|
||||
|
||||
# Check if already on Astrolabe settings page, if not navigate there
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Wait for page to fully render
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already authorized (either "Active" badge or Step 1 "Complete" badge)
|
||||
try:
|
||||
# Check for "Active" badge (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ Already fully authorized for {username} (Active badge)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
# Look for "Complete" text in the Step 1 section's parent
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 1 already complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find and click the "Authorize" button
|
||||
authorize_button = page.locator('a.button.primary:has-text("Authorize")')
|
||||
|
||||
try:
|
||||
await authorize_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"Found Authorize button for {username}")
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Could not find Authorize button for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"Authorize button not found for {username}")
|
||||
|
||||
# Click the Authorize button - this will redirect to OAuth provider
|
||||
# Use force=True to bypass stability check which can timeout due to CSS transitions
|
||||
await authorize_button.click(force=True)
|
||||
logger.info(f"Clicked Authorize button for {username}")
|
||||
|
||||
# Wait for OAuth redirect to complete
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info(f"After networkidle, current URL: {page.url}")
|
||||
|
||||
# Take screenshot to see current state
|
||||
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info(f"Screenshot saved: /tmp/astrolabe_after_authorize_{username}.png")
|
||||
|
||||
# Handle OIDC consent screen if present
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
if consent_handled:
|
||||
logger.info(f"✓ OAuth consent granted for {username}")
|
||||
else:
|
||||
logger.info(
|
||||
f"No consent screen required for {username} (may be previously authorized)"
|
||||
)
|
||||
|
||||
# Wait for redirect back to Astrolabe settings
|
||||
# The OAuth callback will redirect back to /settings/user/astrolabe
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000
|
||||
)
|
||||
logger.info(f"Redirected back to Astrolabe settings for {username}")
|
||||
except Exception:
|
||||
# Check if we're already on settings page
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
logger.warning(
|
||||
f"Not redirected to Astrolabe settings, current URL: {page.url}"
|
||||
)
|
||||
# Navigate manually
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
|
||||
# Wait for page to reload and render
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Verify authorization completed - check for various success indicators
|
||||
# When fully configured, shows "Active" badge; when only Step 1 done, shows "Complete"
|
||||
try:
|
||||
# First check if "Active" badge is shown (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ OAuth authorization complete for {username} (Active badge)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 1 "Complete" badge (partial configuration)
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Step 1 OAuth authorization complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neither badge found - authorization failed
|
||||
screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Authorization badge not visible for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"OAuth authorization did not complete for {username}")
|
||||
|
||||
|
||||
async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
"""Handle the OIDC consent screen during OAuth flow.
|
||||
|
||||
Reuses the proven pattern from tests/conftest.py.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if consent was handled, False if no consent screen was found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Checking for consent screen at URL: {page.url}")
|
||||
|
||||
# Check if consent screen is present - try multiple selectors
|
||||
# The consent screen may be #oidc-consent or use a different format
|
||||
consent_div = await page.query_selector("#oidc-consent")
|
||||
|
||||
if consent_div:
|
||||
logger.info(f"Consent screen detected via #oidc-consent for {username}")
|
||||
# Get consent screen data attributes for logging
|
||||
client_name = await consent_div.get_attribute("data-client-name")
|
||||
scopes_attr = await consent_div.get_attribute("data-scopes")
|
||||
logger.info(f" Client: {client_name}")
|
||||
logger.info(f" Requested scopes: {scopes_attr}")
|
||||
else:
|
||||
# Check for Allow button directly (different consent screen format)
|
||||
allow_button = page.locator('button:has-text("Allow")')
|
||||
if await allow_button.count() > 0:
|
||||
logger.info(f"Consent screen detected via Allow button for {username}")
|
||||
else:
|
||||
logger.info(f"No consent screen found for {username} at {page.url}")
|
||||
await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png")
|
||||
logger.info(f"Screenshot: /tmp/no_consent_screen_{username}.png")
|
||||
return False
|
||||
|
||||
# Wait for Vue.js to render the Allow button
|
||||
try:
|
||||
await page.wait_for_selector('button:has-text("Allow")', timeout=10000)
|
||||
logger.info(" Allow button rendered by Vue.js")
|
||||
except Exception as e:
|
||||
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f" Timeout waiting for Allow button: {e}")
|
||||
raise
|
||||
|
||||
# Check all scope checkboxes
|
||||
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
|
||||
if scope_checkboxes:
|
||||
logger.info(f" Found {len(scope_checkboxes)} scope checkboxes")
|
||||
for i, checkbox in enumerate(scope_checkboxes):
|
||||
is_checked = await checkbox.is_checked()
|
||||
is_disabled = await checkbox.is_disabled()
|
||||
if not is_checked and not is_disabled:
|
||||
await checkbox.check()
|
||||
logger.info(f" ✓ Checked scope checkbox {i + 1}")
|
||||
|
||||
# Click the Allow button using JavaScript (handles viewport issues)
|
||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
||||
|
||||
# Debug: take screenshot before clicking Allow
|
||||
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
|
||||
logger.info(
|
||||
f" Screenshot before Allow: /tmp/consent_before_allow_{username}.png"
|
||||
)
|
||||
|
||||
button_count = await allow_button_locator.count()
|
||||
logger.info(f" Found {button_count} Allow button(s)")
|
||||
|
||||
if button_count > 0:
|
||||
current_url = page.url
|
||||
logger.info(f" Current URL: {current_url}")
|
||||
logger.info(f" Clicking Allow button for {username}...")
|
||||
|
||||
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py)
|
||||
# This is more reliable than Playwright's click for Vue.js rendered buttons
|
||||
await page.evaluate(
|
||||
"""
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.textContent.trim() === 'Allow') {
|
||||
btn.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Wait for URL to change (Vue.js uses window.location.href after fetch)
|
||||
# networkidle doesn't detect fetch-based redirects
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
lambda url: url != current_url,
|
||||
timeout=30000,
|
||||
)
|
||||
logger.info(f" URL changed to: {page.url}")
|
||||
except Exception as wait_error:
|
||||
# If URL didn't change, check console for errors
|
||||
logger.warning(f" URL didn't change after click: {wait_error}")
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
|
||||
# Try alternative: manually POST consent and navigate
|
||||
logger.info(" Trying manual consent submission...")
|
||||
try:
|
||||
redirect_url = await page.evaluate(
|
||||
"""
|
||||
async () => {
|
||||
const selectedScopes = Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value).join(' ');
|
||||
|
||||
const response = await fetch('/index.php/apps/oidc/consent/grant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'requesttoken': OC.requestToken,
|
||||
},
|
||||
body: 'scopes=' + encodeURIComponent(selectedScopes),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
return response.url || '/index.php/apps/oidc/authorize';
|
||||
}
|
||||
"""
|
||||
)
|
||||
logger.info(f" Manual consent returned URL: {redirect_url}")
|
||||
await page.goto(redirect_url, wait_until="networkidle")
|
||||
except Exception as manual_error:
|
||||
logger.error(f" Manual consent also failed: {manual_error}")
|
||||
raise
|
||||
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
logger.info(f" Consent granted for {username}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f" Allow button not found for {username}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling consent screen for {username}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
|
||||
) -> str:
|
||||
@@ -105,16 +399,32 @@ async def generate_app_password(
|
||||
await anyio.sleep(1.0)
|
||||
logger.info("Waited for Vue.js to process input and enable button")
|
||||
|
||||
# Click the create button
|
||||
# Click the create button - use force=True to bypass stability check (CSS transitions)
|
||||
create_button = page.locator(
|
||||
'button[type="submit"]:has-text("Create new app password")'
|
||||
)
|
||||
await create_button.click()
|
||||
try:
|
||||
await create_button.click(force=True, timeout=10000)
|
||||
except Exception:
|
||||
# Fallback: JavaScript click
|
||||
logger.info("Using JavaScript click for create button...")
|
||||
await page.evaluate(
|
||||
"""
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
"""
|
||||
)
|
||||
logger.info("Clicked create app password button")
|
||||
|
||||
# Wait for app password to be generated and displayed in the dialog
|
||||
await anyio.sleep(3) # Give it more time to generate and display
|
||||
|
||||
# Debug screenshot after clicking create
|
||||
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
|
||||
logger.info(
|
||||
f"Screenshot after create: /tmp/app_password_after_create_{username}.png"
|
||||
)
|
||||
|
||||
# Find the Login input field which should have the username value
|
||||
# Then find the Password input field which is in the same form
|
||||
app_password = None
|
||||
@@ -172,11 +482,11 @@ async def generate_app_password(
|
||||
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
||||
)
|
||||
|
||||
# Close the dialog by clicking the Close button
|
||||
close_button = page.get_by_role("button", name="Close")
|
||||
await close_button.click()
|
||||
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
|
||||
logger.info("Closing app password dialog with Escape key...")
|
||||
await page.keyboard.press("Escape")
|
||||
await anyio.sleep(0.5) # Wait for dialog close animation
|
||||
logger.info("Closed app password dialog")
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
return app_password
|
||||
|
||||
@@ -226,9 +536,9 @@ async def enable_background_sync_via_app_password(
|
||||
# Wait for page to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already active (look for "Active" text in the Background Sync Access section)
|
||||
# Check if already complete (look for Step 2 "Complete" badge or overall "Active" state)
|
||||
try:
|
||||
# The "Active" badge appears as a <span> with text "Active"
|
||||
# First check for overall "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.info(f"✓ Background sync already active for {username}")
|
||||
@@ -236,6 +546,18 @@ async def enable_background_sync_via_app_password(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge (app password already set)
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 (app password) already complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find the app password input field using the placeholder text
|
||||
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||
@@ -319,21 +641,120 @@ async def enable_background_sync_via_app_password(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify "Active" text appears after reload
|
||||
# Verify Step 2 "Complete" badge or overall "Active" badge appears after reload
|
||||
try:
|
||||
# First try to find "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.count() > 0:
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Background sync enabled for {username} - Active badge visible"
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Step 2 (app password) enabled for {username} - Complete badge visible"
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If neither badge found, raise error
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Neither Active nor Complete badge appeared for {username}. "
|
||||
f"Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"Background sync setup did not complete for {username}")
|
||||
|
||||
|
||||
async def complete_astrolabe_authorization(
|
||||
page: Page, username: str, password: str
|
||||
) -> dict:
|
||||
"""Complete full Astrolabe two-step authorization.
|
||||
|
||||
Performs the complete authorization flow:
|
||||
1. Navigate to Astrolabe settings
|
||||
2. OAuth authorization (Step 1) if needed
|
||||
3. Generate app password in Security settings
|
||||
4. App password entry (Step 2) if needed
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be logged in)
|
||||
username: Nextcloud username
|
||||
password: Nextcloud password (for reference, not used directly)
|
||||
|
||||
Returns:
|
||||
Dict with {"step1": bool, "step2": bool, "app_password": str | None}
|
||||
"""
|
||||
logger.info(f"Starting full Astrolabe authorization for {username}...")
|
||||
|
||||
result = {"step1": False, "step2": False, "app_password": None}
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Step 1: OAuth authorization
|
||||
try:
|
||||
result["step1"] = await authorize_search_access(page, username)
|
||||
logger.info(f"✓ Step 1 complete for {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Step 1 failed for {username}: {e}")
|
||||
raise
|
||||
|
||||
# Navigate back to settings if needed (OAuth might have redirected elsewhere)
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Check if Step 2 is already complete
|
||||
try:
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 already complete for {username}")
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also check for overall "Active" badge
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Background sync enabled for {username} - Active badge visible")
|
||||
return True
|
||||
if await active_text.count() > 0 and await active_text.is_visible():
|
||||
logger.info(f"✓ Authorization already fully active for {username}")
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Active badge did not appear for {username}. Screenshot: {screenshot_path}"
|
||||
pass
|
||||
|
||||
# Step 2: Generate app password and enter it
|
||||
app_password = await generate_app_password(page, username)
|
||||
result["app_password"] = app_password
|
||||
|
||||
try:
|
||||
result["step2"] = await enable_background_sync_via_app_password(
|
||||
page, username, app_password
|
||||
)
|
||||
logger.info(f"✓ Step 2 complete for {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Step 2 failed for {username}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"✓ Full Astrolabe authorization complete for {username}")
|
||||
return result
|
||||
|
||||
|
||||
async def verify_app_password_created(username: str) -> bool:
|
||||
"""Verify that background sync app password was stored for the user.
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
|
||||
|
||||
This test verifies that:
|
||||
1. User can provision background sync access via app password
|
||||
2. Content created via MCP tools is indexed by vector sync
|
||||
3. Semantic search via Astrolabe UI returns results
|
||||
4. Plotly 3D visualization container renders correctly
|
||||
|
||||
Requires:
|
||||
- docker-compose up -d app db mcp-multi-user-basic
|
||||
- ENABLE_SEMANTIC_SEARCH=true on the mcp-multi-user-basic container
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from playwright.async_api import Page
|
||||
|
||||
# Import helper functions from existing test
|
||||
from tests.conftest import create_mcp_client_session
|
||||
from tests.integration.test_astrolabe_multi_user_background_sync import (
|
||||
complete_astrolabe_authorization,
|
||||
login_to_nextcloud,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def wait_for_vector_sync(
|
||||
mcp_client, initial_indexed_count: int, timeout_seconds: int = 60
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""Wait for vector sync to index new content.
|
||||
|
||||
Args:
|
||||
mcp_client: MCP client session
|
||||
initial_indexed_count: Initial indexed document count before creating content
|
||||
timeout_seconds: Maximum time to wait for sync
|
||||
|
||||
Returns:
|
||||
Tuple of (success, status_data)
|
||||
"""
|
||||
wait_interval = 2
|
||||
waited = 0
|
||||
status_data = None
|
||||
|
||||
while waited < timeout_seconds:
|
||||
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||
if sync_status.isError:
|
||||
logger.warning(f"Vector sync status error: {sync_status}")
|
||||
return False, None
|
||||
|
||||
status_data = json.loads(sync_status.content[0].text)
|
||||
indexed_count = status_data.get("indexed_count", 0)
|
||||
pending_count = status_data.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Sync status at {waited}s: indexed={indexed_count}, "
|
||||
f"pending={pending_count}, status={status_data.get('status')}"
|
||||
)
|
||||
|
||||
if indexed_count > initial_indexed_count and pending_count == 0:
|
||||
logger.info(
|
||||
f"✓ Sync complete: {indexed_count} documents indexed "
|
||||
f"(was {initial_indexed_count})"
|
||||
)
|
||||
return True, status_data
|
||||
|
||||
await anyio.sleep(wait_interval)
|
||||
waited += wait_interval
|
||||
|
||||
return False, status_data
|
||||
|
||||
|
||||
async def navigate_to_astrolabe_main(page: Page):
|
||||
"""Navigate to Astrolabe main app page (Semantic Search section).
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info("Navigating to Astrolabe main app...")
|
||||
await page.goto(f"{nextcloud_url}/apps/astrolabe", wait_until="networkidle")
|
||||
|
||||
# Wait for the app to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
logger.info("✓ Successfully loaded Astrolabe main app")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.timeout(
|
||||
300
|
||||
) # 5 minutes - this test involves OAuth, app password, and vector sync
|
||||
async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
browser,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test Plotly 3D visualization in Astrolabe with multi-user BasicAuth mode.
|
||||
|
||||
This test:
|
||||
1. Configures Astrolabe for the mcp-multi-user-basic service
|
||||
2. Provisions background sync access for alice via app password
|
||||
3. Creates a note with unique searchable content (as alice)
|
||||
4. Waits for vector sync to index the note
|
||||
5. Performs semantic search in Astrolabe UI
|
||||
6. Verifies the Plotly visualization renders and results are displayed
|
||||
"""
|
||||
# Phase 1: Configure Astrolabe for mcp-multi-user-basic
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
username = "alice"
|
||||
password = test_users_setup[username]["password"]
|
||||
note_id = None
|
||||
unique_term = None
|
||||
|
||||
# Create MCP client with alice's credentials for the multi-user BasicAuth server
|
||||
credentials = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8")
|
||||
auth_header = f"Basic {credentials}"
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Phase 2: Complete full Astrolabe authorization (OAuth + app password)
|
||||
await login_to_nextcloud(page, username, password)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
logger.info(f"Authorization result: {auth_result}")
|
||||
|
||||
# Create MCP client session as alice - all MCP operations inside this block
|
||||
async for alice_mcp_client in create_mcp_client_session(
|
||||
url="http://localhost:8003/mcp",
|
||||
headers={"Authorization": auth_header},
|
||||
client_name="Alice BasicAuth MCP",
|
||||
):
|
||||
# Phase 3: Get initial indexed count
|
||||
initial_sync = await alice_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", {}
|
||||
)
|
||||
|
||||
if initial_sync.isError:
|
||||
pytest.skip("Vector sync not enabled on mcp-multi-user-basic")
|
||||
|
||||
initial_data = json.loads(initial_sync.content[0].text)
|
||||
initial_count = initial_data.get("indexed_count", 0)
|
||||
logger.info(f"Initial indexed count: {initial_count}")
|
||||
|
||||
# Create note with unique searchable term
|
||||
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}"
|
||||
note_response = await alice_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{
|
||||
"title": f"Visualization Test Note {unique_term}",
|
||||
"content": f"""# Testing Plotly Visualization
|
||||
|
||||
This note contains the unique term: {unique_term}
|
||||
|
||||
It is used to test the 3D vector space visualization in the Astrolabe app.
|
||||
The visualization should show this document as a point in PCA-reduced space.
|
||||
|
||||
## Key Features
|
||||
- Semantic search with embeddings
|
||||
- PCA dimension reduction to 3D
|
||||
- Interactive Plotly scatter3d plot
|
||||
""",
|
||||
"category": "Test",
|
||||
},
|
||||
)
|
||||
|
||||
if note_response.isError:
|
||||
pytest.fail(f"Failed to create test note: {note_response}")
|
||||
|
||||
note_data = json.loads(note_response.content[0].text)
|
||||
note_id = note_data.get("id")
|
||||
logger.info(f"Created test note ID: {note_id}")
|
||||
|
||||
# Phase 4: Wait for vector indexing
|
||||
sync_complete, status = await wait_for_vector_sync(
|
||||
alice_mcp_client, initial_count, timeout_seconds=90
|
||||
)
|
||||
assert sync_complete, f"Vector sync did not complete in time: {status}"
|
||||
|
||||
# Phase 5: Navigate to Astrolabe and perform search
|
||||
await navigate_to_astrolabe_main(page)
|
||||
|
||||
# Fill search query - find the Astrolabe search input specifically
|
||||
# The NcTextField component wraps the input in a div with class mcp-search-input
|
||||
search_input = page.locator(".mcp-search-input input")
|
||||
await search_input.wait_for(timeout=10000, state="visible")
|
||||
await search_input.fill(unique_term)
|
||||
logger.info(f"Entered search query: {unique_term}")
|
||||
|
||||
# Trigger search by pressing Enter on the input field
|
||||
# This is wired to performSearch via @keyup.enter in the Vue component
|
||||
await search_input.press("Enter")
|
||||
logger.info("Pressed Enter to trigger search")
|
||||
|
||||
# Wait for loading to complete - watch for loading indicator to disappear
|
||||
loading_indicator = page.locator(".mcp-loading")
|
||||
try:
|
||||
# If loading indicator appears, wait for it to disappear
|
||||
if await loading_indicator.count() > 0:
|
||||
await loading_indicator.wait_for(state="hidden", timeout=30000)
|
||||
logger.info("Loading completed")
|
||||
except Exception:
|
||||
# Loading might be too fast to catch
|
||||
pass
|
||||
|
||||
# Brief wait for UI to settle
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Take diagnostic screenshot
|
||||
await page.screenshot(path="/tmp/astrolabe_search_after_click.png")
|
||||
logger.info(
|
||||
"Took diagnostic screenshot: /tmp/astrolabe_search_after_click.png"
|
||||
)
|
||||
|
||||
# Wait for search results using text-based detection
|
||||
# This is more reliable than class-based selectors
|
||||
# The UI shows "N results" when search completes successfully
|
||||
results_text_pattern = page.get_by_text(re.compile(r"\d+ results?"))
|
||||
no_results_text = page.get_by_text("No results found")
|
||||
error_note = page.locator(".mcp-error")
|
||||
|
||||
# Wait for one of: results count, no results message, or error
|
||||
try:
|
||||
# Poll for results or error states (don't rely on Nextcloud core CSS classes)
|
||||
found_state = False
|
||||
for attempt in range(60): # 60 attempts, 500ms each = 30s total
|
||||
if await error_note.count() > 0:
|
||||
error_text = await error_note.text_content()
|
||||
logger.error(f"Search error: {error_text}")
|
||||
pytest.fail(f"Search failed with error: {error_text}")
|
||||
|
||||
if await no_results_text.count() > 0:
|
||||
logger.warning(
|
||||
"No results found - vector sync may not have completed"
|
||||
)
|
||||
await page.screenshot(path="/tmp/astrolabe_no_results.png")
|
||||
pytest.fail(
|
||||
f"Search returned no results for '{unique_term}'. "
|
||||
"Check if vector sync completed for alice's content."
|
||||
)
|
||||
|
||||
if await results_text_pattern.count() > 0:
|
||||
results_text = await results_text_pattern.first.text_content()
|
||||
logger.info(f"Found results: {results_text}")
|
||||
found_state = True
|
||||
break
|
||||
|
||||
if attempt % 10 == 0:
|
||||
logger.info(
|
||||
f"Waiting for results... (attempt {attempt + 1}/60)"
|
||||
)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
if not found_state:
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
raise AssertionError("Search did not complete within timeout")
|
||||
|
||||
except AssertionError:
|
||||
raise # Re-raise AssertionError as-is
|
||||
except Exception as e:
|
||||
# Take another screenshot and get page content for debugging
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
raise AssertionError(f"Search did not complete: {e}")
|
||||
|
||||
logger.info("Results loaded")
|
||||
|
||||
# Phase 6: Verify visualization
|
||||
# Check Plotly container is visible
|
||||
viz_plot = page.locator("#viz-plot")
|
||||
await viz_plot.wait_for(timeout=15000, state="visible")
|
||||
logger.info("Plotly container is visible")
|
||||
|
||||
# Verify Plotly has rendered content (SVG/canvas elements inside)
|
||||
has_viz_content = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const plot = document.getElementById('viz-plot');
|
||||
if (!plot) return false;
|
||||
// Plotly creates .plotly class, canvas, or svg elements
|
||||
return plot.children.length > 0 ||
|
||||
plot.querySelector('.plotly, canvas, svg, .main-svg') !== null;
|
||||
}
|
||||
"""
|
||||
)
|
||||
assert has_viz_content, "Plotly visualization did not render any content"
|
||||
logger.info("✓ Plotly visualization rendered content")
|
||||
|
||||
# Verify results are displayed
|
||||
result_items = page.locator(".mcp-result-item")
|
||||
result_count = await result_items.count()
|
||||
assert result_count > 0, "No search results displayed"
|
||||
logger.info(f"✓ Found {result_count} search result(s)")
|
||||
|
||||
# Verify our note appears in results
|
||||
found_note = False
|
||||
for i in range(result_count):
|
||||
item = result_items.nth(i)
|
||||
title_elem = item.locator(".mcp-result-title")
|
||||
title_text = await title_elem.text_content()
|
||||
if title_text and unique_term in title_text:
|
||||
found_note = True
|
||||
logger.info(f"✓ Found test note in results: {title_text}")
|
||||
break
|
||||
|
||||
assert found_note, f"Created note with '{unique_term}' not found in results"
|
||||
|
||||
# Optional: Take screenshot for verification
|
||||
await page.screenshot(path="/tmp/astrolabe_plotly_test_success.png")
|
||||
logger.info("✓ All Plotly visualization assertions passed")
|
||||
|
||||
# Cleanup: delete the created note (inside the MCP client context)
|
||||
if note_id:
|
||||
try:
|
||||
delete_response = await alice_mcp_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id}")
|
||||
note_id = None # Mark as cleaned
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup note if not already cleaned (create new client for cleanup)
|
||||
if note_id:
|
||||
try:
|
||||
async for cleanup_client in create_mcp_client_session(
|
||||
url="http://localhost:8003/mcp",
|
||||
headers={"Authorization": auth_header},
|
||||
client_name="Cleanup MCP",
|
||||
):
|
||||
delete_response = await cleanup_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id} (finally)")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
|
||||
# Close browser context
|
||||
await context.close()
|
||||
@@ -0,0 +1,695 @@
|
||||
"""Integration tests for Astrolabe token refresh flow.
|
||||
|
||||
Tests the token refresh mechanism between Astrolabe (Nextcloud app)
|
||||
and the MCP server backend in a multi-user basic auth deployment.
|
||||
|
||||
This test verifies:
|
||||
1. User provisions access via Astrolabe personal settings
|
||||
2. Token is stored encrypted in Nextcloud database
|
||||
3. Token expires (simulated via database manipulation)
|
||||
4. MCP server requests new token via refresh
|
||||
5. Astrolabe refreshes token with IdP
|
||||
6. New token is stored and used successfully
|
||||
|
||||
Note: The mcp-multi-user-basic deployment uses "hybrid mode" which requires
|
||||
BOTH OAuth authorization AND app password for full configuration. These tests
|
||||
focus on the app password/credential storage aspects and verify database state
|
||||
directly rather than relying on UI elements that require both steps.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from playwright.async_api import Page
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
"""Helper function to login to Nextcloud via Playwright.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Nextcloud username
|
||||
password: Nextcloud password
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Logging in to Nextcloud as {username}...")
|
||||
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
|
||||
|
||||
# Fill in login form
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
|
||||
# Submit form
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
# Verify logged in (should redirect away from login page)
|
||||
current_url = page.url
|
||||
assert "/login" not in current_url, (
|
||||
f"Login failed for {username}, still on login page"
|
||||
)
|
||||
logger.info(f"✓ Successfully logged in as {username}")
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
page: Page, username: str, app_name: str = "Astrolabe Test"
|
||||
) -> str:
|
||||
"""Generate an app password in Nextcloud Security settings.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
username: Username (for logging)
|
||||
app_name: Name for the app password
|
||||
|
||||
Returns:
|
||||
The generated app password string
|
||||
"""
|
||||
logger.info(f"Generating app password for {username}...")
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
# Navigate to Security settings
|
||||
await page.goto(f"{nextcloud_url}/settings/user/security", wait_until="networkidle")
|
||||
logger.info("Navigated to Security settings")
|
||||
|
||||
# Fill the app password input field
|
||||
app_password_input = page.locator('input[placeholder="App name"]')
|
||||
await app_password_input.fill(app_name)
|
||||
logger.info(f"Entered app name: {app_name}")
|
||||
|
||||
# Wait for Vue.js to react and enable the button
|
||||
await anyio.sleep(1.0)
|
||||
|
||||
# Click the create button
|
||||
create_button = page.locator(
|
||||
'button[type="submit"]:has-text("Create new app password")'
|
||||
)
|
||||
await create_button.click()
|
||||
logger.info("Clicked create app password button")
|
||||
|
||||
# Wait for app password to be generated
|
||||
await anyio.sleep(3)
|
||||
|
||||
# Find the generated app password
|
||||
app_password = None
|
||||
try:
|
||||
await page.wait_for_selector('text="New app password"', timeout=10000)
|
||||
logger.info("App password dialog appeared")
|
||||
|
||||
all_inputs = await page.locator('input[type="text"]').all()
|
||||
for idx, input_elem in enumerate(all_inputs):
|
||||
try:
|
||||
value = await input_elem.input_value()
|
||||
if value and "-" in value and len(value) > 20:
|
||||
app_password = value.strip()
|
||||
logger.info(f"Found app password in input {idx}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find app password dialog: {e}")
|
||||
|
||||
if not app_password:
|
||||
screenshot_path = f"/tmp/app_password_generation_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"Could not find generated app password. Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
# Validate password format
|
||||
if not re.match(
|
||||
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
|
||||
app_password,
|
||||
):
|
||||
raise ValueError(f"App password format validation failed: {app_password}")
|
||||
|
||||
logger.info(f"✓ Generated app password for {username}")
|
||||
|
||||
# Close the dialog
|
||||
close_button = page.get_by_role("button", name="Close")
|
||||
await close_button.click()
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
return app_password
|
||||
|
||||
|
||||
async def save_app_password_in_astrolabe(
|
||||
page: Page, username: str, app_password: str
|
||||
) -> bool:
|
||||
"""Save app password in Astrolabe settings (Step 2 of hybrid mode).
|
||||
|
||||
This function only saves the app password - it does NOT verify the "Active"
|
||||
badge since that requires both OAuth and app password in hybrid mode.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username (for logging)
|
||||
app_password: App password to enter
|
||||
|
||||
Returns:
|
||||
True if the password was saved successfully (based on network response)
|
||||
"""
|
||||
logger.info(f"Saving app password in Astrolabe for {username}...")
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
# Track network responses
|
||||
credentials_response_status = None
|
||||
|
||||
def capture_response(resp):
|
||||
nonlocal credentials_response_status
|
||||
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
|
||||
credentials_response_status = resp.status
|
||||
logger.info(f"Credentials endpoint response: {resp.status} {resp.url}")
|
||||
|
||||
page.on("response", capture_response)
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if Step 2 already shows "Complete"
|
||||
try:
|
||||
complete_badge = page.locator('text="Complete"').first
|
||||
if await complete_badge.is_visible(timeout=2000):
|
||||
logger.info(f"✓ App password already configured for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find the app password input field
|
||||
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||
|
||||
try:
|
||||
await app_password_input.wait_for(timeout=5000, state="visible")
|
||||
logger.info("Found app password input field")
|
||||
except Exception:
|
||||
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"Could not find app password input field. Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
# Enter the app password
|
||||
await app_password_input.fill(app_password)
|
||||
logger.info(f"Entered app password for {username}")
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Click Save button
|
||||
save_button = page.get_by_role("button", name="Save")
|
||||
await save_button.click()
|
||||
logger.info("Clicked Save button")
|
||||
|
||||
# Wait for the request to complete and page to reload
|
||||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Verify the save was successful by checking network response
|
||||
if credentials_response_status == 200:
|
||||
logger.info(f"✓ App password saved successfully for {username}")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"App password save failed for {username}, status: {credentials_response_status}"
|
||||
)
|
||||
screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
return False
|
||||
|
||||
|
||||
def get_background_sync_credentials(username: str) -> dict | None:
|
||||
"""Get background sync credentials for a user from the database.
|
||||
|
||||
Args:
|
||||
username: Nextcloud username
|
||||
|
||||
Returns:
|
||||
Dict with credential details, or None if not found
|
||||
"""
|
||||
query = f"""
|
||||
SELECT configkey, configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at')
|
||||
ORDER BY configkey;
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
if "background_sync_type" in output:
|
||||
return {
|
||||
"has_password": "background_sync_password" in output,
|
||||
"has_type": "background_sync_type" in output,
|
||||
"has_timestamp": "background_sync_provisioned_at" in output,
|
||||
"is_app_password": "app_password" in output,
|
||||
}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting credentials for {username}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def delete_user_credentials(username: str) -> bool:
|
||||
"""Delete all stored credentials for a user (for cleanup).
|
||||
|
||||
Args:
|
||||
username: Nextcloud username
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
query = f"""
|
||||
DELETE FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey IN ('oauth_tokens', 'background_sync_password', 'background_sync_type', 'background_sync_provisioned_at');
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
logger.info(f"Deleted credentials for {username}")
|
||||
return result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting credentials for {username}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_app_password_storage_and_cleanup(
|
||||
browser,
|
||||
nc_client,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that app passwords are stored and cleaned up correctly.
|
||||
|
||||
This test verifies:
|
||||
1. User can save app password in Astrolabe settings
|
||||
2. Password is stored encrypted in the database
|
||||
3. Credentials can be revoked and are deleted from database
|
||||
|
||||
Note: In hybrid mode (mcp-multi-user-basic), this only tests Step 2
|
||||
(app password storage). The "Active" badge requires both OAuth and
|
||||
app password, which is tested separately.
|
||||
"""
|
||||
# Configure Astrolabe for mcp-multi-user-basic
|
||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
username = "alice"
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
|
||||
# Cleanup any existing credentials
|
||||
delete_user_credentials(username)
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Step 1: Login
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# Step 2: Verify no credentials exist initially
|
||||
initial_creds = get_background_sync_credentials(username)
|
||||
assert initial_creds is None, f"Expected no credentials, found: {initial_creds}"
|
||||
logger.info("✓ Verified no initial credentials")
|
||||
|
||||
# Step 3: Generate app password
|
||||
app_password = await generate_app_password(page, username)
|
||||
assert app_password, "Failed to generate app password"
|
||||
|
||||
# Step 4: Save app password in Astrolabe
|
||||
save_success = await save_app_password_in_astrolabe(
|
||||
page, username, app_password
|
||||
)
|
||||
assert save_success, "Failed to save app password"
|
||||
|
||||
# Step 5: Verify credentials are stored in database
|
||||
stored_creds = get_background_sync_credentials(username)
|
||||
assert stored_creds is not None, "Expected credentials to be stored"
|
||||
assert stored_creds["has_password"], "Expected password to be stored"
|
||||
assert stored_creds["has_type"], "Expected type to be stored"
|
||||
assert stored_creds["is_app_password"], "Expected type to be 'app_password'"
|
||||
logger.info("✓ Verified credentials stored in database")
|
||||
|
||||
# Step 6: Verify password is encrypted (not plaintext)
|
||||
query = f"""
|
||||
SELECT configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
encrypted_value = result.stdout.strip()
|
||||
assert app_password not in encrypted_value, "Password appears in plaintext!"
|
||||
assert len(encrypted_value) > len(app_password), (
|
||||
"Encrypted value should be longer"
|
||||
)
|
||||
logger.info("✓ Verified password is encrypted")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
# Cleanup
|
||||
delete_user_credentials(username)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_credential_isolation_between_users(
|
||||
browser,
|
||||
nc_client,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that credentials are properly isolated between users.
|
||||
|
||||
This test verifies:
|
||||
1. Multiple users can provision credentials independently
|
||||
2. Each user's encrypted credentials are unique
|
||||
3. Deleting one user's credentials doesn't affect others
|
||||
"""
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
test_users = ["alice", "bob"]
|
||||
user_passwords = {}
|
||||
|
||||
# Cleanup all users first
|
||||
for username in test_users:
|
||||
delete_user_credentials(username)
|
||||
|
||||
# Provision each user
|
||||
for username in test_users:
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await login_to_nextcloud(page, username, password)
|
||||
app_password = await generate_app_password(
|
||||
page, username, f"Test {username}"
|
||||
)
|
||||
save_success = await save_app_password_in_astrolabe(
|
||||
page, username, app_password
|
||||
)
|
||||
|
||||
assert save_success, f"Failed to save app password for {username}"
|
||||
user_passwords[username] = app_password
|
||||
|
||||
# Verify stored
|
||||
creds = get_background_sync_credentials(username)
|
||||
assert creds is not None, f"Credentials not stored for {username}"
|
||||
logger.info(f"✓ Credentials provisioned for {username}")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Verify isolation - get encrypted values
|
||||
encrypted_values = {}
|
||||
for username in test_users:
|
||||
query = f"""
|
||||
SELECT configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
encrypted_values[username] = result.stdout.strip()
|
||||
|
||||
# Different users should have different encrypted values
|
||||
assert encrypted_values["alice"] != encrypted_values["bob"], (
|
||||
"Different users should have different encrypted values"
|
||||
)
|
||||
logger.info("✓ Verified credentials are unique per user")
|
||||
|
||||
# Delete alice's credentials and verify bob's are unaffected
|
||||
delete_user_credentials("alice")
|
||||
|
||||
alice_creds = get_background_sync_credentials("alice")
|
||||
bob_creds = get_background_sync_credentials("bob")
|
||||
|
||||
assert alice_creds is None, "Alice's credentials should be deleted"
|
||||
assert bob_creds is not None, "Bob's credentials should still exist"
|
||||
logger.info("✓ Verified credential deletion is isolated")
|
||||
|
||||
# Cleanup
|
||||
for username in test_users:
|
||||
delete_user_credentials(username)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_credential_revoke_and_reprovision(
|
||||
browser,
|
||||
nc_client,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that credentials can be revoked and reprovisioned.
|
||||
|
||||
This test verifies:
|
||||
1. User provisions credentials
|
||||
2. User revokes credentials (deletes from database)
|
||||
3. User provisions again with new app password
|
||||
4. New credentials are stored correctly
|
||||
|
||||
Note: The UI prevents overwriting credentials directly - users must
|
||||
revoke first before provisioning new credentials.
|
||||
"""
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
username = "alice"
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
|
||||
delete_user_credentials(username)
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# First provisioning
|
||||
app_password_1 = await generate_app_password(page, username, "First Password")
|
||||
await save_app_password_in_astrolabe(page, username, app_password_1)
|
||||
|
||||
# Get first encrypted value
|
||||
query = f"""
|
||||
SELECT configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
result1 = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
first_encrypted = result1.stdout.strip()
|
||||
assert first_encrypted, "First credential should be stored"
|
||||
logger.info("✓ First credential stored")
|
||||
|
||||
# Revoke credentials (simulating user clicking "Revoke Access")
|
||||
delete_user_credentials(username)
|
||||
logger.info("✓ Credentials revoked")
|
||||
|
||||
# Verify credentials are gone
|
||||
creds_after_revoke = get_background_sync_credentials(username)
|
||||
assert creds_after_revoke is None, "Credentials should be deleted after revoke"
|
||||
|
||||
# Second provisioning with different password
|
||||
app_password_2 = await generate_app_password(page, username, "Second Password")
|
||||
await save_app_password_in_astrolabe(page, username, app_password_2)
|
||||
|
||||
result2 = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
second_encrypted = result2.stdout.strip()
|
||||
assert second_encrypted, "Second credential should be stored"
|
||||
logger.info("✓ Second credential stored")
|
||||
|
||||
# Verify the encrypted values are different (different passwords)
|
||||
assert first_encrypted != second_encrypted, (
|
||||
"Different passwords should produce different encrypted values"
|
||||
)
|
||||
|
||||
# Verify only one row exists
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
count_result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
count_query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
count = int(count_result.stdout.strip())
|
||||
assert count == 1, f"Expected 1 credential row, found {count}"
|
||||
logger.info("✓ Verified clean reprovision after revoke")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
delete_user_credentials(username)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Integration tests for Deck card reorder functionality.
|
||||
|
||||
Tests issue #469: Moving Deck card from one column (stack) to another not working.
|
||||
https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"""Create a temporary board with two stacks for testing card movement.
|
||||
|
||||
Yields:
|
||||
tuple: (board_data, source_stack_data, target_stack_data)
|
||||
"""
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
board_title = f"Reorder Test Board {unique_suffix}"
|
||||
board = None
|
||||
|
||||
logger.info(f"Creating board with two stacks: {board_title}")
|
||||
try:
|
||||
board = await nc_client.deck.create_board(board_title, "0000FF")
|
||||
board_id = board.id
|
||||
|
||||
# Create source stack (stack 1)
|
||||
source_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Source Stack {unique_suffix}", order=1
|
||||
)
|
||||
source_stack_data = {
|
||||
"id": source_stack.id,
|
||||
"title": source_stack.title,
|
||||
"order": source_stack.order,
|
||||
}
|
||||
logger.info(f"Created source stack with ID: {source_stack.id}")
|
||||
|
||||
# Create target stack (stack 2)
|
||||
target_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Target Stack {unique_suffix}", order=2
|
||||
)
|
||||
target_stack_data = {
|
||||
"id": target_stack.id,
|
||||
"title": target_stack.title,
|
||||
"order": target_stack.order,
|
||||
}
|
||||
logger.info(f"Created target stack with ID: {target_stack.id}")
|
||||
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
"title": board.title,
|
||||
"color": board.color,
|
||||
}
|
||||
|
||||
yield (board_data, source_stack_data, target_stack_data)
|
||||
|
||||
finally:
|
||||
if board:
|
||||
logger.info(f"Cleaning up board ID: {board.id}")
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up board: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_move_to_different_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test moving a card from one stack to another (issue #469).
|
||||
|
||||
This test reproduces the bug where the reorder_card API reports success
|
||||
but the card doesn't actually move to the target stack.
|
||||
"""
|
||||
board_data, source_stack_data, target_stack_data = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
target_stack_id = target_stack_data["id"]
|
||||
|
||||
# Create a card in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card_title = f"Test Card {unique_suffix}"
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, card_title, description="Card to be moved"
|
||||
)
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
|
||||
|
||||
try:
|
||||
# Verify card is in source stack
|
||||
card_before = await nc_client.deck.get_card(board_id, source_stack_id, card_id)
|
||||
assert card_before.stackId == source_stack_id, (
|
||||
f"Card should start in source stack {source_stack_id}, "
|
||||
f"but is in {card_before.stackId}"
|
||||
)
|
||||
logger.info(f"Verified card is in source stack: {source_stack_id}")
|
||||
|
||||
# Move card to target stack
|
||||
logger.info(
|
||||
f"Moving card {card_id} from stack {source_stack_id} "
|
||||
f"to stack {target_stack_id}"
|
||||
)
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card_id,
|
||||
order=0,
|
||||
target_stack_id=target_stack_id,
|
||||
)
|
||||
logger.info("reorder_card API call completed")
|
||||
|
||||
# Verify card moved to target stack
|
||||
# Note: After moving, the card should be accessible from the target stack
|
||||
card_after = await nc_client.deck.get_card(board_id, target_stack_id, card_id)
|
||||
assert card_after.stackId == target_stack_id, (
|
||||
f"Card should have moved to target stack {target_stack_id}, "
|
||||
f"but is in {card_after.stackId}"
|
||||
)
|
||||
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - try to delete from target stack first, then source
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, target_stack_id, card_id)
|
||||
except Exception:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up card: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_within_same_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test reordering a card within the same stack (should work)."""
|
||||
board_data, source_stack_data, _ = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
|
||||
# Create two cards in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card1 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 1 {unique_suffix}", order=0
|
||||
)
|
||||
card2 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
|
||||
)
|
||||
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
|
||||
|
||||
try:
|
||||
# Reorder card1 to position after card2
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card1.id,
|
||||
order=2, # Move to position 2
|
||||
target_stack_id=source_stack_id, # Same stack
|
||||
)
|
||||
logger.info(f"Reordered card {card1.id} to order 2")
|
||||
|
||||
# Verify card is still in the same stack
|
||||
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
|
||||
assert card_after.stackId == source_stack_id
|
||||
logger.info("Card reorder within same stack succeeded")
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up cards: {e}")
|
||||
@@ -89,8 +89,13 @@ async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
|
||||
"""Verify create operations are marked as non-idempotent."""
|
||||
tools = await nc_mcp_client.list_tools()
|
||||
|
||||
# Exceptions: operations that are actually idempotent
|
||||
# - calendar_create_meeting: creates or returns existing meeting
|
||||
# - nc_webdav_create_directory: MKCOL returns 405 if exists (same end state)
|
||||
idempotent_exceptions = {"calendar_create_meeting", "nc_webdav_create_directory"}
|
||||
|
||||
for tool in tools.tools:
|
||||
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
|
||||
if "create" in tool.name.lower() and tool.name not in idempotent_exceptions:
|
||||
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
|
||||
assert tool.annotations.idempotentHint is not True, (
|
||||
f"Create tool {tool.name} should not be idempotent (creates new resources)"
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Unit tests for App Password Storage functionality.
|
||||
|
||||
Tests the app password methods in RefreshTokenStorage for multi-user
|
||||
BasicAuth mode background sync.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temp_storage(encryption_key):
|
||||
"""Create temporary storage instance with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_app_passwords.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
async def test_store_app_password(temp_storage):
|
||||
"""Test storing an app password."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||
)
|
||||
|
||||
# Verify it can be retrieved
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved == "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||
|
||||
|
||||
async def test_store_app_password_replaces_existing(temp_storage):
|
||||
"""Test that storing a new app password replaces the existing one."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
)
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
|
||||
)
|
||||
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved == "fffff-ggggg-hhhhh-iiiii-jjjjj"
|
||||
|
||||
|
||||
async def test_get_app_password_nonexistent(temp_storage):
|
||||
"""Test retrieving app password for non-existent user."""
|
||||
retrieved = await temp_storage.get_app_password("nonexistent")
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
async def test_delete_app_password(temp_storage):
|
||||
"""Test deleting an app password."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||
)
|
||||
|
||||
deleted = await temp_storage.delete_app_password("testuser")
|
||||
assert deleted is True
|
||||
|
||||
# Verify it's gone
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
async def test_delete_app_password_nonexistent(temp_storage):
|
||||
"""Test deleting non-existent app password."""
|
||||
deleted = await temp_storage.delete_app_password("nonexistent")
|
||||
assert deleted is False
|
||||
|
||||
|
||||
async def test_get_all_app_password_user_ids(temp_storage):
|
||||
"""Test listing all users with app passwords."""
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||
await temp_storage.store_app_password("charlie", "ccccc-ccccc-ccccc-ccccc-ccccc")
|
||||
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert len(user_ids) == 3
|
||||
assert "alice" in user_ids
|
||||
assert "bob" in user_ids
|
||||
assert "charlie" in user_ids
|
||||
|
||||
|
||||
async def test_get_all_app_password_user_ids_empty(temp_storage):
|
||||
"""Test listing users when none have app passwords."""
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert len(user_ids) == 0
|
||||
|
||||
|
||||
async def test_app_password_encryption(encryption_key):
|
||||
"""Test that app passwords are encrypted at rest."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_encryption.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
|
||||
# Store a password
|
||||
test_password = "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||
await storage.store_app_password("testuser", test_password)
|
||||
|
||||
# Read directly from database to verify encryption
|
||||
import aiosqlite
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
async with db.execute(
|
||||
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||
("testuser",),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
# The stored value should be encrypted (not plain text)
|
||||
encrypted_bytes = row[0]
|
||||
assert encrypted_bytes != test_password.encode()
|
||||
# Encrypted data should be longer due to Fernet overhead
|
||||
assert len(encrypted_bytes) > len(test_password)
|
||||
|
||||
|
||||
async def test_app_password_requires_encryption_key():
|
||||
"""Test that app password operations require encryption key."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_no_key.db"
|
||||
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
|
||||
await storage.initialize()
|
||||
|
||||
# Storing should fail without encryption key
|
||||
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||
await storage.store_app_password(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
|
||||
# Getting should also fail without encryption key
|
||||
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||
await storage.get_app_password("testuser")
|
||||
|
||||
|
||||
async def test_multiple_users_independence(temp_storage):
|
||||
"""Test that different users maintain independent app passwords."""
|
||||
users = ["alice", "bob", "charlie", "diana"]
|
||||
|
||||
# Store unique passwords for each user
|
||||
for i, user in enumerate(users):
|
||||
password = (
|
||||
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||
)
|
||||
await temp_storage.store_app_password(user, password)
|
||||
|
||||
# Verify each user has their correct password
|
||||
for user in users:
|
||||
expected = (
|
||||
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||
)
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved == expected
|
||||
|
||||
# Delete one user's password
|
||||
await temp_storage.delete_app_password("bob")
|
||||
|
||||
# Verify other users unchanged
|
||||
for user in ["alice", "charlie", "diana"]:
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved is not None
|
||||
|
||||
# Verify bob's password is gone
|
||||
assert await temp_storage.get_app_password("bob") is None
|
||||
|
||||
|
||||
async def test_app_password_with_special_characters(temp_storage):
|
||||
"""Test storing passwords with various alphanumeric patterns."""
|
||||
# Nextcloud app passwords use alphanumeric characters
|
||||
passwords = [
|
||||
"AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", # uppercase
|
||||
"aaaaa-bbbbb-ccccc-ddddd-eeeee", # lowercase
|
||||
"12345-67890-12345-67890-12345", # numbers
|
||||
"aB1cD-eF2gH-iJ3kL-mN4oP-qR5sT", # mixed
|
||||
]
|
||||
|
||||
for i, password in enumerate(passwords):
|
||||
user = f"user{i}"
|
||||
await temp_storage.store_app_password(user, password)
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved == password
|
||||
|
||||
|
||||
async def test_decryption_with_wrong_key(encryption_key):
|
||||
"""Test that decryption fails with wrong key."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_wrong_key.db"
|
||||
|
||||
# Store with original key
|
||||
storage1 = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage1.initialize()
|
||||
await storage1.store_app_password("testuser", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||
|
||||
# Try to read with different key
|
||||
wrong_key = Fernet.generate_key().decode()
|
||||
storage2 = RefreshTokenStorage(db_path=str(db_path), encryption_key=wrong_key)
|
||||
await storage2.initialize()
|
||||
|
||||
# Decryption should fail and return None (graceful handling)
|
||||
retrieved = await storage2.get_app_password("testuser")
|
||||
assert retrieved is None
|
||||
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
Unit tests for Management API app password endpoints.
|
||||
|
||||
Tests the REST API endpoints for multi-user BasicAuth mode app password management:
|
||||
- POST /api/v1/users/{user_id}/app-password - Provision app password
|
||||
- GET /api/v1/users/{user_id}/app-password - Check status
|
||||
- DELETE /api/v1/users/{user_id}/app-password - Delete app password
|
||||
"""
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api import passwords
|
||||
from nextcloud_mcp_server.api.passwords import (
|
||||
delete_app_password,
|
||||
get_app_password_status,
|
||||
provision_app_password,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
"""Clear rate limit state before each test."""
|
||||
passwords._rate_limit_attempts.clear()
|
||||
yield
|
||||
passwords._rate_limit_attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temp_storage(encryption_key):
|
||||
"""Create temporary storage instance with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_management.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
def create_basic_auth_header(username: str, password: str) -> str:
|
||||
"""Create BasicAuth header value."""
|
||||
credentials = f"{username}:{password}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
return f"Basic {encoded}"
|
||||
|
||||
|
||||
def create_test_app(storage):
|
||||
"""Create a test Starlette app with the management endpoints."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
app.state.storage = storage
|
||||
return app
|
||||
|
||||
|
||||
async def test_provision_app_password_missing_auth():
|
||||
"""Test that missing auth returns 401."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/api/v1/users/testuser/app-password")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing BasicAuth" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_invalid_auth_format():
|
||||
"""Test that invalid auth format returns 401."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={"Authorization": "Basic invalid-not-base64!!!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid BasicAuth" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_username_mismatch():
|
||||
"""Test that username mismatch returns 403."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
# Try to provision for "testuser" but auth as "otheruser"
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "does not match" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_invalid_format():
|
||||
"""Test that invalid app password format returns 400."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
# Use invalid password format (not xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header("testuser", "invalid-password")
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid app password format" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_success(temp_storage, mocker):
|
||||
"""Test successful app password provisioning."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"ocs": {"data": {"id": "testuser"}}}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
# Create app with storage
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "stored" in data["message"].lower()
|
||||
|
||||
# Verify password was stored
|
||||
stored_password = await temp_storage.get_app_password("testuser")
|
||||
assert stored_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
|
||||
|
||||
async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
"""Test that failed Nextcloud validation returns 401."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid app password" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_get_app_password_status_provisioned(temp_storage, mocker):
|
||||
"""Test checking status when app password is provisioned."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["user_id"] == "testuser"
|
||||
assert data["has_app_password"] is True
|
||||
|
||||
|
||||
async def test_get_app_password_status_not_provisioned(temp_storage, mocker):
|
||||
"""Test checking status when app password is not provisioned."""
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["user_id"] == "testuser"
|
||||
assert data["has_app_password"] is False
|
||||
|
||||
|
||||
async def test_get_app_password_status_username_mismatch():
|
||||
"""Test that username mismatch returns 403 for status check."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_delete_app_password_success(temp_storage, mocker):
|
||||
"""Test successful app password deletion."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "deleted" in data["message"].lower()
|
||||
|
||||
# Verify password was removed
|
||||
stored_password = await temp_storage.get_app_password("testuser")
|
||||
assert stored_password is None
|
||||
|
||||
|
||||
async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
"""Test deleting non-existent app password."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "no app password found" in data["message"].lower()
|
||||
|
||||
|
||||
async def test_delete_app_password_invalid_credentials(mocker):
|
||||
"""Test that invalid credentials returns 401 for deletion."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "wrong-password-xxxxx"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_delete_app_password_username_mismatch():
|
||||
"""Test that username mismatch returns 403 for deletion."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_provision_app_password_rate_limiting(mocker):
|
||||
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401 (failed validation)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Make 5 failed attempts (should all return 401)
|
||||
for i in range(5):
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401, f"Attempt {i + 1} should return 401"
|
||||
|
||||
# 6th attempt should be rate limited (429)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
assert "Rate limit exceeded" in response.json()["error"]
|
||||
assert "Retry-After" in response.headers
|
||||
|
||||
|
||||
async def test_rate_limiting_is_per_user(mocker):
|
||||
"""Test that rate limiting is applied per user, not globally."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Make 5 failed attempts for user1 (hits rate limit)
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/api/v1/users/user1/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# user1 should be rate limited
|
||||
response = client.post(
|
||||
"/api/v1/users/user1/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
# user2 should NOT be rate limited (different user)
|
||||
response = client.post(
|
||||
"/api/v1/users/user2/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user2", "bbbbb-ccccc-ddddd-eeeee-fffff"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401 # Fails validation, but not rate limited
|
||||
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
Unit tests for Management API PDF preview endpoint.
|
||||
|
||||
Tests the /api/v1/pdf-preview endpoint focusing on:
|
||||
- Parameter validation (file_path, page, scale)
|
||||
- OAuth token validation
|
||||
- PDF rendering with PyMuPDF
|
||||
- Error handling (file not found, invalid page, etc.)
|
||||
"""
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api.visualization import get_pdf_preview
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def create_test_app():
|
||||
"""Create a test Starlette app with the PDF preview endpoint."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]),
|
||||
]
|
||||
)
|
||||
# Set up OAuth context (required by endpoint)
|
||||
app.state.oauth_context = {"config": {"nextcloud_host": "http://localhost:8080"}}
|
||||
return app
|
||||
|
||||
|
||||
def create_mock_pdf_bytes():
|
||||
"""Create a minimal valid PDF for testing."""
|
||||
# Minimal PDF structure that PyMuPDF can parse
|
||||
# This is a 1-page PDF with a blank page
|
||||
pdf_content = b"""%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer
|
||||
<< /Size 4 /Root 1 0 R >>
|
||||
startxref
|
||||
196
|
||||
%%EOF"""
|
||||
return pdf_content
|
||||
|
||||
|
||||
class TestPdfPreviewParameterValidation:
|
||||
"""Tests for parameter validation in PDF preview endpoint."""
|
||||
|
||||
def test_missing_file_path_returns_400(self):
|
||||
"""Test that missing file_path parameter returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "file_path" in data["error"].lower()
|
||||
|
||||
def test_invalid_page_number_returns_400(self):
|
||||
"""Test that invalid page number (0 or negative) returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test page=0
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "page" in data["error"].lower()
|
||||
|
||||
# Test negative page
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=-1",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_scale_returns_400(self):
|
||||
"""Test that scale outside valid range returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test scale too small
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.1",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "scale" in data["error"].lower()
|
||||
|
||||
# Test scale too large
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=10.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_non_numeric_page_returns_400(self):
|
||||
"""Test that non-numeric page parameter returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=abc",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestPdfPreviewAuthentication:
|
||||
"""Tests for authentication in PDF preview endpoint."""
|
||||
|
||||
def test_unauthorized_without_token_returns_401(self):
|
||||
"""Test that request without token returns 401."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Invalid token"),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/pdf-preview?file_path=/test.pdf")
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_unauthorized_with_invalid_token_returns_401(self):
|
||||
"""Test that request with invalid token returns 401."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Token expired"),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestPdfPreviewRendering:
|
||||
"""Tests for PDF rendering functionality."""
|
||||
|
||||
def test_successful_pdf_render(self):
|
||||
"""Test successful PDF page rendering."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
# Mock the WebDAV client
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=1&scale=1.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "image" in data
|
||||
assert data["page_number"] == 1
|
||||
assert data["total_pages"] == 1
|
||||
|
||||
# Verify image is valid base64
|
||||
try:
|
||||
decoded = base64.b64decode(data["image"])
|
||||
# PNG magic bytes
|
||||
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
except Exception as e:
|
||||
pytest.fail(f"Image is not valid base64-encoded PNG: {e}")
|
||||
|
||||
def test_page_out_of_range_returns_400(self):
|
||||
"""Test that requesting page beyond total pages returns 400."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=999",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "page" in data["error"].lower()
|
||||
assert "999" in data["error"]
|
||||
|
||||
def test_file_not_found_returns_404(self):
|
||||
"""Test that non-existent file returns 404."""
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
side_effect=FileNotFoundError("File not found")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/nonexistent.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "not found" in data["error"].lower()
|
||||
|
||||
def test_default_parameters(self):
|
||||
"""Test that default parameters (page=1, scale=2.0) are used."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
# Only file_path, no page or scale
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["page_number"] == 1 # Default page
|
||||
|
||||
|
||||
class TestPdfPreviewEdgeCases:
|
||||
"""Tests for edge cases in PDF preview endpoint."""
|
||||
|
||||
def test_url_encoded_file_path(self):
|
||||
"""Test that URL-encoded file paths are handled correctly."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
# URL-encoded path with spaces
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/Documents/My%20File.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Verify the path was passed correctly to WebDAV
|
||||
mock_webdav.read_file.assert_called_once()
|
||||
call_args = mock_webdav.read_file.call_args[0]
|
||||
assert "My File.pdf" in call_args[0]
|
||||
|
||||
def test_missing_nextcloud_host_config(self):
|
||||
"""Test handling when Nextcloud host is not configured."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
# Override with empty config
|
||||
app.state.oauth_context = {"config": {"nextcloud_host": ""}}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_corrupted_pdf_returns_400(self):
|
||||
"""Test that corrupted PDF data returns 400 with specific error."""
|
||||
mock_webdav = AsyncMock()
|
||||
# Return invalid PDF bytes
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(b"not a valid pdf", "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert (
|
||||
"corrupted" in data["error"].lower()
|
||||
or "invalid" in data["error"].lower()
|
||||
)
|
||||
|
||||
def test_boundary_scale_values(self):
|
||||
"""Test boundary scale values (min and max)."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test minimum valid scale (0.5)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.5",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test maximum valid scale (5.0)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=5.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestPdfPreviewSecurityValidation:
|
||||
"""Tests for security validations in PDF preview endpoint."""
|
||||
|
||||
def test_path_traversal_returns_400(self):
|
||||
"""Test that path traversal attempts are blocked with 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test various path traversal patterns
|
||||
traversal_paths = [
|
||||
"/Documents/../../../etc/passwd",
|
||||
"/../secret.pdf",
|
||||
"/folder/..%2F..%2Fetc/passwd", # URL-encoded
|
||||
"/test/../secret.pdf",
|
||||
]
|
||||
|
||||
for path in traversal_paths:
|
||||
response = client.get(
|
||||
f"/api/v1/pdf-preview?file_path={path}",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400, (
|
||||
f"Path traversal not blocked: {path}"
|
||||
)
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "invalid file path" in data["error"].lower()
|
||||
|
||||
def test_file_size_limit_exceeded_returns_413(self):
|
||||
"""Test that files exceeding 50MB limit return 413."""
|
||||
# Create bytes larger than 50MB limit
|
||||
large_pdf_bytes = b"x" * (51 * 1024 * 1024) # 51 MB
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(large_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/large.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 413
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "size limit" in data["error"].lower()
|
||||
|
||||
def test_corrupted_pdf_returns_400(self):
|
||||
"""Test that corrupted PDF returns 400 with specific error message."""
|
||||
# Invalid PDF content that PyMuPDF cannot parse
|
||||
corrupted_pdf_bytes = b"not a valid PDF file content"
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(corrupted_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert (
|
||||
"corrupted" in data["error"].lower()
|
||||
or "invalid" in data["error"].lower()
|
||||
)
|
||||
|
||||
def test_empty_pdf_returns_400(self):
|
||||
"""Test that empty PDF file returns 400."""
|
||||
empty_pdf_bytes = b""
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(empty_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/empty.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Unit tests for Management API status endpoint.
|
||||
|
||||
Tests the /api/v1/status endpoint focusing on:
|
||||
- OIDC config availability in different auth modes
|
||||
- Hybrid mode (multi_user_basic + enable_offline_access) returning OIDC config
|
||||
- OAuth mode returning OIDC config
|
||||
- Non-OAuth modes NOT returning OIDC config
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api.management import get_server_status
|
||||
from nextcloud_mcp_server.config_validators import AuthMode
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def create_test_app():
|
||||
"""Create a test Starlette app with the status endpoint."""
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/api/v1/status", get_server_status, methods=["GET"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_mock_settings(
|
||||
enable_multi_user_basic: bool = False,
|
||||
enable_offline_access: bool = False,
|
||||
oidc_discovery_url: str | None = None,
|
||||
oidc_issuer: str | None = None,
|
||||
vector_sync_enabled: bool = False,
|
||||
nextcloud_url: str = "http://localhost",
|
||||
enable_token_exchange: bool = False,
|
||||
mcp_client_id: str | None = None,
|
||||
mcp_client_secret: str | None = None,
|
||||
):
|
||||
"""Create mock settings with specified auth configuration."""
|
||||
settings = MagicMock()
|
||||
settings.enable_multi_user_basic_auth = enable_multi_user_basic
|
||||
settings.enable_offline_access = enable_offline_access
|
||||
settings.oidc_discovery_url = oidc_discovery_url
|
||||
settings.oidc_issuer = oidc_issuer
|
||||
settings.vector_sync_enabled = vector_sync_enabled
|
||||
settings.nextcloud_url = nextcloud_url
|
||||
settings.enable_token_exchange = enable_token_exchange
|
||||
settings.mcp_client_id = mcp_client_id
|
||||
settings.mcp_client_secret = mcp_client_secret
|
||||
return settings
|
||||
|
||||
|
||||
class TestStatusEndpointOidcConfig:
|
||||
"""Tests for OIDC configuration in status endpoint."""
|
||||
|
||||
def test_hybrid_mode_returns_oidc_config(self):
|
||||
"""Test that hybrid mode (multi_user_basic + offline_access) returns OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
# get_settings and detect_auth_mode are imported inside the function
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "multi_user_basic"
|
||||
assert data["supports_app_passwords"] is True
|
||||
|
||||
# Verify OIDC config is present (key feature for hybrid mode)
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "http://keycloak/.well-known/openid-configuration"
|
||||
)
|
||||
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
|
||||
|
||||
def test_hybrid_mode_without_oidc_settings_no_oidc_key(self):
|
||||
"""Test that hybrid mode without OIDC settings doesn't include empty oidc key."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url=None,
|
||||
oidc_issuer=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# OIDC key should NOT be present if no OIDC settings configured
|
||||
assert "oidc" not in data
|
||||
|
||||
def test_multi_user_basic_without_offline_access_no_oidc(self):
|
||||
"""Test that multi_user_basic WITHOUT offline_access doesn't return OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=False, # Key difference: no offline access
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "multi_user_basic"
|
||||
assert data["supports_app_passwords"] is False
|
||||
|
||||
# OIDC config should NOT be present (not hybrid mode)
|
||||
assert "oidc" not in data
|
||||
|
||||
def test_oauth_mode_returns_oidc_config(self):
|
||||
"""Test that OAuth mode returns OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=False,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url="http://nextcloud/.well-known/openid-configuration",
|
||||
oidc_issuer="http://nextcloud",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "oauth"
|
||||
|
||||
# Verify OIDC config is present
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "http://nextcloud/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
def test_single_user_basic_no_oidc(self):
|
||||
"""Test that single-user BasicAuth mode doesn't return OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=False,
|
||||
enable_offline_access=False,
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "basic"
|
||||
|
||||
# OIDC config should NOT be present
|
||||
assert "oidc" not in data
|
||||
# supports_app_passwords should NOT be present (only for multi_user_basic)
|
||||
assert "supports_app_passwords" not in data
|
||||
|
||||
def test_oidc_partial_config_only_discovery_url(self):
|
||||
"""Test OIDC config with only discovery URL set."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer=None, # Only discovery URL
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "http://keycloak/.well-known/openid-configuration"
|
||||
)
|
||||
assert "issuer" not in data["oidc"]
|
||||
|
||||
def test_oidc_partial_config_only_issuer(self):
|
||||
"""Test OIDC config with only issuer set."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url=None, # Only issuer
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" in data
|
||||
assert "discovery_url" not in data["oidc"]
|
||||
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
|
||||
|
||||
|
||||
class TestStatusEndpointBasicResponse:
|
||||
"""Tests for basic status endpoint response fields."""
|
||||
|
||||
def test_status_includes_version(self):
|
||||
"""Test that status endpoint includes version."""
|
||||
mock_settings = create_mock_settings()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "version" in data
|
||||
assert "uptime_seconds" in data
|
||||
assert "management_api_version" in data
|
||||
assert data["management_api_version"] == "1.0"
|
||||
|
||||
def test_status_includes_vector_sync_enabled(self):
|
||||
"""Test that status endpoint includes vector_sync_enabled."""
|
||||
mock_settings = create_mock_settings(vector_sync_enabled=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["vector_sync_enabled"] is True
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: composer
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/cs-fixer"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/openapi-extractor"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/phpunit"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/psalm"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
@@ -1,36 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Block unconventional commits
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: block-unconventional-commits-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
block-unconventional-commits:
|
||||
name: Block unconventional commits
|
||||
|
||||
runs-on: ubuntu-latest-low
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: webiny/action-conventional-commits@8bc41ff4e7d423d56fa4905f6ff79209a78776c7 # v1.3.0
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,36 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Block fixup and squash commits
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: fixup-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
commit-message-check:
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
name: Block fixup and squash commits
|
||||
|
||||
runs-on: ubuntu-latest-low
|
||||
|
||||
steps:
|
||||
- name: Run check
|
||||
uses: skjnldsv/block-fixup-merge-action@c138ea99e45e186567b64cf065ce90f7158c236a # v2
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,100 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint eslint
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-eslint-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest-low
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
outputs:
|
||||
src: ${{ steps.changes.outputs.src}}
|
||||
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- '.github/workflows/**'
|
||||
- 'src/**'
|
||||
- 'appinfo/info.xml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- '.eslintrc.*'
|
||||
- '.eslintignore'
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.vue'
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: changes
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
name: NPM lint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: [changes, lint]
|
||||
|
||||
if: always()
|
||||
|
||||
# This is the summary, we just avoid to rename it so that branch protection rules still match
|
||||
name: eslint
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi
|
||||
@@ -1,38 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint info.xml
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-info-xml-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
xml-linters:
|
||||
runs-on: ubuntu-latest-low
|
||||
|
||||
name: info.xml lint
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download schema
|
||||
run: wget https://raw.githubusercontent.com/nextcloud/appstore/master/nextcloudappstore/api/v1/release/info.xsd
|
||||
|
||||
- name: Lint info.xml
|
||||
uses: ChristophWurst/xmllint-action@36f2a302f84f8c83fceea0b9c59e1eb4a616d3c1 # v1.2
|
||||
with:
|
||||
xml-file: ./appinfo/info.xml
|
||||
xml-schema-file: ./info.xsd
|
||||
@@ -1,52 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint php-cs
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-php-cs-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: php-cs
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev
|
||||
composer i
|
||||
|
||||
- name: Lint
|
||||
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
|
||||
@@ -1,75 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint php
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-php-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
runs-on: ubuntu-latest-low
|
||||
outputs:
|
||||
php-versions: ${{ steps.versions.outputs.php-versions }}
|
||||
steps:
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version matrix
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
|
||||
|
||||
php-lint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: matrix
|
||||
strategy:
|
||||
matrix:
|
||||
php-versions: ${{fromJson(needs.matrix.outputs.php-versions)}}
|
||||
|
||||
name: php-lint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up php ${{ matrix.php-versions }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Lint
|
||||
run: composer run lint
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: php-lint
|
||||
|
||||
if: always()
|
||||
|
||||
name: php-lint-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi
|
||||
@@ -1,53 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint stylelint
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-stylelint-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: stylelint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run stylelint
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Node
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: node-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest-low
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
outputs:
|
||||
src: ${{ steps.changes.outputs.src}}
|
||||
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- '.github/workflows/**'
|
||||
- 'src/**'
|
||||
- 'appinfo/info.xml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.vue'
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: changes
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
name: NPM build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies & build
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Check webpack build changes
|
||||
run: |
|
||||
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets, see the section \"Show changes on failure\" for details' && exit 1)"
|
||||
|
||||
- name: Show changes on failure
|
||||
if: failure()
|
||||
run: |
|
||||
git status
|
||||
git --no-pager diff
|
||||
exit 1 # make it red to grab attention
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: [changes, build]
|
||||
|
||||
if: always()
|
||||
|
||||
# This is the summary, we just avoid to rename it so that branch protection rules still match
|
||||
name: node
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && needs.build.result != 'success' }}; then exit 1; fi
|
||||
@@ -1,81 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Npm audit fix and compile
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# At 2:30 on Sundays
|
||||
- cron: '30 2 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branches: ['main', 'master', 'stable31', 'stable30']
|
||||
|
||||
name: npm-audit-fix-${{ matrix.branches }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ matrix.branches }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Fix npm audit
|
||||
id: npm-audit
|
||||
uses: nextcloud-libraries/npm-audit-action@1b1728b2b4a7a78d69de65608efcf4db0e3e42d0 # v0.2.0
|
||||
|
||||
- name: Run npm ci and npm run build
|
||||
if: steps.checkout.outcome == 'success'
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.checkout.outcome == 'success'
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'fix(deps): Fix npm audit'
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
|
||||
signoff: true
|
||||
branch: automated/noid/${{ matrix.branches }}-fix-npm-audit
|
||||
title: '[${{ matrix.branches }}] Fix npm audit'
|
||||
body: ${{ steps.npm-audit.outputs.markdown }}
|
||||
labels: |
|
||||
dependencies
|
||||
3. to review
|
||||
@@ -1,96 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-FileCopyrightText: 2024 Arthur Schiwon <blizzz@arthur-schiwon.de>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: OpenAPI
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: openapi-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
openapi:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: ${{ github.repository_owner != 'nextcloud-gmbh' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get php version
|
||||
id: php_versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Set up php
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.php_versions.outputs.php-available }}
|
||||
extensions: xml
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check Typescript OpenApi types
|
||||
id: check_typescript_openapi
|
||||
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
|
||||
with:
|
||||
files: "src/types/openapi/openapi*.ts"
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
if: steps.check_typescript_openapi.outputs.files_exists == 'true'
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: node_versions
|
||||
# Continue if no package.json
|
||||
continue-on-error: true
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
if: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.node_versions.outputs.npmVersion }}
|
||||
if: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Set up dependencies
|
||||
run: composer i
|
||||
|
||||
- name: Regenerate OpenAPI
|
||||
run: composer run openapi
|
||||
|
||||
- name: Check openapi*.json and typescript changes
|
||||
run: |
|
||||
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and (if applicable) src/types/openapi/openapi*.ts, see the section \"Show changes on failure\" for details' && exit 1)"
|
||||
|
||||
- name: Show changes on failure
|
||||
if: failure()
|
||||
run: |
|
||||
git status
|
||||
git --no-pager diff
|
||||
exit 1 # make it red to grab attention
|
||||
@@ -1,87 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Static analysis
|
||||
|
||||
on: pull_request
|
||||
|
||||
concurrency:
|
||||
group: psalm-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
runs-on: ubuntu-latest-low
|
||||
outputs:
|
||||
ocp-matrix: ${{ steps.versions.outputs.ocp-matrix }}
|
||||
steps:
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version matrix
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml
|
||||
run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml
|
||||
|
||||
static-analysis:
|
||||
runs-on: ubuntu-latest
|
||||
needs: matrix
|
||||
strategy:
|
||||
# do not stop on another job's failure
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.ocp-matrix) }}
|
||||
|
||||
name: static-psalm-analysis ${{ matrix.ocp-version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up php${{ matrix.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
# Temporary workaround for missing pcntl_* in PHP 8.3
|
||||
ini-values: disable_functions=
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev
|
||||
composer i
|
||||
|
||||
|
||||
- name: Install dependencies # zizmor: ignore[template-injection]
|
||||
run: composer require --dev 'nextcloud/ocp:${{ matrix.ocp-version }}' --ignore-platform-reqs --with-dependencies
|
||||
|
||||
- name: Run coding standards check
|
||||
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
|
||||
|
||||
summary:
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: static-analysis
|
||||
|
||||
if: always()
|
||||
|
||||
name: static-psalm-analysis-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.static-analysis.result != 'success' }}; then exit 1; fi
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Auto approve nextcloud/ocp
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- stable*
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-nextcloud-ocp-approve-merge-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
auto-approve-merge:
|
||||
if: github.actor == 'nextcloud-command'
|
||||
runs-on: ubuntu-latest-low
|
||||
permissions:
|
||||
# for hmarr/auto-approve-action to approve PRs
|
||||
pull-requests: write
|
||||
# for alexwilson/enable-github-automerge-action to approve PRs
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Disabled on forks
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
run: |
|
||||
echo 'Can not approve PRs from forks'
|
||||
exit 1
|
||||
|
||||
- uses: mdecoleman/pr-branch-name@55795d86b4566d300d237883103f052125cc7508 # v3.0.0
|
||||
id: branchname
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# GitHub actions bot approve
|
||||
- uses: hmarr/auto-approve-action@b40d6c9ed2fa10c9a2749eca7eb004418a705501 # v2
|
||||
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Enable GitHub auto merge
|
||||
- name: Auto merge
|
||||
uses: alexwilson/enable-github-automerge-action@56e3117d1ae1540309dc8f7a9f2825bc3c5f06ff # v2.0.0
|
||||
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,101 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Update nextcloud/ocp
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '5 2 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-nextcloud-ocp:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branches: ['master']
|
||||
target: ['stable30']
|
||||
|
||||
name: update-nextcloud-ocp-${{ matrix.branches }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ matrix.branches }}
|
||||
submodules: true
|
||||
|
||||
- name: Set up php8.2
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: 8.2
|
||||
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Read codeowners
|
||||
id: codeowners
|
||||
run: |
|
||||
grep '/appinfo/info.xml' .github/CODEOWNERS | cut -f 2- -d ' ' | xargs | awk '{ print "codeowners="$0 }' >> $GITHUB_OUTPUT
|
||||
continue-on-error: true
|
||||
|
||||
- name: Composer install
|
||||
run: composer install
|
||||
|
||||
- name: Composer update nextcloud/ocp
|
||||
id: update_branch
|
||||
run: composer require --dev nextcloud/ocp:dev-${{ matrix.target }}
|
||||
|
||||
- name: Raise on issue on failure
|
||||
uses: dacbd/create-issue-action@cdb57ab6ff8862aa09fee2be6ba77a59581921c2 # v2.0.0
|
||||
if: ${{ failure() && steps.update_branch.conclusion == 'failure' }}
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
title: 'Failed to update nextcloud/ocp package'
|
||||
body: 'Please check the output of the GitHub action and manually resolve the issues<br>${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}<br>${{ steps.codeowners.outputs.codeowners }}'
|
||||
|
||||
- name: Reset checkout 3rdparty
|
||||
run: |
|
||||
git clean -f 3rdparty
|
||||
git checkout 3rdparty
|
||||
continue-on-error: true
|
||||
|
||||
- name: Reset checkout vendor
|
||||
run: |
|
||||
git clean -f vendor
|
||||
git checkout vendor
|
||||
continue-on-error: true
|
||||
|
||||
- name: Reset checkout vendor-bin
|
||||
run: |
|
||||
git clean -f vendor-bin
|
||||
git checkout vendor-bin
|
||||
continue-on-error: true
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'chore(dev-deps): Bump nextcloud/ocp package'
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
|
||||
signoff: true
|
||||
branch: 'automated/noid/${{ matrix.branches }}-update-nextcloud-ocp'
|
||||
title: '[${{ matrix.branches }}] Update nextcloud/ocp dependency'
|
||||
body: |
|
||||
Auto-generated update of [nextcloud/ocp](https://github.com/nextcloud-deps/ocp/) dependency
|
||||
labels: |
|
||||
dependencies
|
||||
3. to review
|
||||
@@ -12,3 +12,4 @@ build/
|
||||
node_modules/
|
||||
js/
|
||||
css/
|
||||
.phpunit.cache/
|
||||
|
||||
Vendored
+98
@@ -25,6 +25,104 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Requires external MCP server deployment
|
||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
|
||||
## astrolabe-v0.10.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## astrolabe-v0.9.0 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## astrolabe-v0.8.3 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## astrolabe-v0.8.2 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
||||
|
||||
## astrolabe-v0.8.1 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||
- **ci**: bump helm chart version when MCP appVersion changes
|
||||
|
||||
## astrolabe-v0.8.0 (2026-01-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## astrolabe-v0.7.2 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
|
||||
## astrolabe-v0.7.1 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||
- **mcp**: Move all imports to the top of modules
|
||||
|
||||
## astrolabe-v0.7.0 (2025-12-26)
|
||||
|
||||
### Feat
|
||||
|
||||
+5
-2
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
||||
|
||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||
]]></description>
|
||||
<version>0.7.0</version>
|
||||
<version>0.10.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||
<namespace>Astrolabe</namespace>
|
||||
@@ -40,7 +40,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
|
||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1</screenshot>
|
||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1</screenshot>
|
||||
<dependencies>
|
||||
<nextcloud min-version="30" max-version="32"/>
|
||||
<nextcloud min-version="31" max-version="32"/>
|
||||
</dependencies>
|
||||
<settings>
|
||||
<personal>OCA\Astrolabe\Settings\Personal</personal>
|
||||
@@ -57,4 +57,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
|
||||
<type>link</type>
|
||||
</navigation>
|
||||
</navigations>
|
||||
<background-jobs>
|
||||
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
||||
</background-jobs>
|
||||
</info>
|
||||
|
||||
+5
@@ -72,6 +72,11 @@ return [
|
||||
'url' => '/api/chunk-context',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#pdfPreview',
|
||||
'url' => '/api/pdf-preview',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
|
||||
// Admin settings routes
|
||||
[
|
||||
|
||||
Vendored
+8
-1
@@ -14,6 +14,11 @@
|
||||
"OCA\\Astrolabe\\": "lib/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"OCP\\": "vendor/nextcloud/ocp/OCP/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": [
|
||||
"@composer bin all install --ansi"
|
||||
@@ -25,7 +30,7 @@
|
||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"psalm": "psalm --threads=1 --no-cache",
|
||||
"test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky",
|
||||
"test:unit": "./vendor/bin/phpunit -c tests/unit/phpunit.xml --colors=always",
|
||||
"openapi": "generate-spec",
|
||||
"rector": "rector && composer cs:fix"
|
||||
},
|
||||
@@ -34,7 +39,9 @@
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^3.8",
|
||||
"nextcloud/ocp": "dev-stable30",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"config": {
|
||||
|
||||
+1973
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\Lock\LockedException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Background job to proactively refresh OAuth tokens before expiration.
|
||||
*
|
||||
* Runs every 15 minutes and refreshes tokens based on their actual expiration
|
||||
* time. Works with any IdP (Nextcloud OIDC, Keycloak, etc.) since it uses
|
||||
* the real token expiration rather than IdP configuration.
|
||||
*
|
||||
* Refresh strategy: Refresh when less than 50% of token lifetime remains,
|
||||
* ensuring tokens are refreshed well before expiration regardless of the
|
||||
* IdP's configured token lifetime.
|
||||
*
|
||||
* @psalm-suppress UnusedClass - Background jobs are loaded dynamically by Nextcloud
|
||||
*/
|
||||
class RefreshUserTokens extends TimedJob {
|
||||
/** Job runs every 15 minutes */
|
||||
private const JOB_INTERVAL_SECONDS = 900;
|
||||
|
||||
/** Refresh when this percentage of token lifetime remains */
|
||||
private const REFRESH_AT_REMAINING_PERCENT = 0.5;
|
||||
|
||||
/** Minimum threshold to avoid constant refresh (5 minutes) */
|
||||
private const MIN_THRESHOLD_SECONDS = 300;
|
||||
|
||||
/** Default assumed token lifetime if we can't determine it (1 hour) */
|
||||
private const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
|
||||
|
||||
/** Batch size for processing users (prevents memory issues on large installations) */
|
||||
private const BATCH_SIZE = 100;
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
$this->setInterval(self::JOB_INTERVAL_SECONDS);
|
||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||
}
|
||||
|
||||
protected function run(mixed $argument): void {
|
||||
$this->logger->info('RefreshUserTokens: Starting background token refresh');
|
||||
|
||||
$refreshed = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$offset = 0;
|
||||
$totalUsers = 0;
|
||||
|
||||
// Process users in batches to prevent memory issues on large installations
|
||||
do {
|
||||
$userIds = $this->tokenStorage->getAllUsersWithTokens(self::BATCH_SIZE, $offset);
|
||||
$batchCount = count($userIds);
|
||||
$totalUsers += $batchCount;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$result = $this->refreshUserTokenIfNeeded($userId);
|
||||
match ($result) {
|
||||
'refreshed' => $refreshed++,
|
||||
'failed' => $failed++,
|
||||
'skipped' => $skipped++,
|
||||
};
|
||||
}
|
||||
|
||||
$offset += self::BATCH_SIZE;
|
||||
} while ($batchCount === self::BATCH_SIZE);
|
||||
|
||||
$this->logger->info("RefreshUserTokens: Complete - total=$totalUsers, refreshed=$refreshed, failed=$failed, skipped=$skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user's token if it's nearing expiration.
|
||||
*
|
||||
* Calculates the refresh threshold based on the token's actual lifetime,
|
||||
* refreshing when less than 50% of the lifetime remains.
|
||||
*
|
||||
* Uses locking to prevent race conditions with on-demand refresh in
|
||||
* getAccessToken(). If lock cannot be acquired, skips this user since
|
||||
* on-demand refresh is already handling it.
|
||||
*
|
||||
* @return string 'refreshed', 'failed', or 'skipped'
|
||||
*/
|
||||
private function refreshUserTokenIfNeeded(string $userId): string {
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($token === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
$expiresAt = (int)($token['expires_at'] ?? 0);
|
||||
$issuedAt = isset($token['issued_at']) ? (int)$token['issued_at'] : null;
|
||||
$timeRemaining = $expiresAt - time();
|
||||
|
||||
// Calculate token lifetime from stored data or use default
|
||||
if ($issuedAt !== null) {
|
||||
$tokenLifetime = $expiresAt - $issuedAt;
|
||||
} else {
|
||||
// Fallback: use default lifetime assumption
|
||||
$tokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
// Calculate threshold: refresh when 50% of lifetime remains
|
||||
$threshold = max(
|
||||
(int)($tokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($timeRemaining > $threshold) {
|
||||
// Token still has plenty of time, skip
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Token is expiring soon, attempt refresh with lock
|
||||
try {
|
||||
return $this->tokenStorage->withTokenLock($userId, function () use ($userId) {
|
||||
// Re-check token after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for lock
|
||||
$currentToken = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($currentToken === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Recalculate threshold with current token data
|
||||
$currentExpiresAt = (int)($currentToken['expires_at'] ?? 0);
|
||||
$currentIssuedAt = isset($currentToken['issued_at']) ? (int)$currentToken['issued_at'] : null;
|
||||
$currentTimeRemaining = $currentExpiresAt - time();
|
||||
|
||||
if ($currentIssuedAt !== null) {
|
||||
$currentTokenLifetime = $currentExpiresAt - $currentIssuedAt;
|
||||
} else {
|
||||
$currentTokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
$currentThreshold = max(
|
||||
(int)($currentTokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($currentTimeRemaining > $currentThreshold) {
|
||||
// Token was refreshed by another process while we waited
|
||||
$this->logger->debug("RefreshUserTokens: Token already refreshed for user $userId while waiting for lock");
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Still needs refresh, proceed
|
||||
if (!isset($currentToken['refresh_token'])) {
|
||||
$this->logger->warning("RefreshUserTokens: User $userId has no refresh token");
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Refreshing token for user $userId (remaining={$currentTimeRemaining}s, threshold={$currentThreshold}s)");
|
||||
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
$this->logger->warning("RefreshUserTokens: Refresh returned null for user $userId");
|
||||
// Don't delete token here - let on-demand refresh handle cleanup
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// Calculate new expiration and store issued_at for future calculations
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? self::DEFAULT_TOKEN_LIFETIME_SECONDS);
|
||||
$now = time();
|
||||
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
|
||||
$this->tokenStorage->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at
|
||||
);
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Successfully refreshed token for user $userId");
|
||||
return 'refreshed';
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Lock held by on-demand refresh - expected, not an error
|
||||
$this->logger->debug("RefreshUserTokens: Lock held for user $userId, skipping");
|
||||
return 'skipped';
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("RefreshUserTokens: Failed to refresh for user $userId: " . $e->getMessage());
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-22
@@ -26,13 +26,13 @@ use Psr\Log\LoggerInterface;
|
||||
* Handles form submissions and AJAX requests from settings panels.
|
||||
*/
|
||||
class ApiController extends Controller {
|
||||
private $client;
|
||||
private $userSession;
|
||||
private $urlGenerator;
|
||||
private $logger;
|
||||
private $tokenStorage;
|
||||
private $config;
|
||||
private $tokenRefresher;
|
||||
private McpServerClient $client;
|
||||
private IUserSession $userSession;
|
||||
private IURLGenerator $urlGenerator;
|
||||
private LoggerInterface $logger;
|
||||
private McpTokenStorage $tokenStorage;
|
||||
private IConfig $config;
|
||||
private IdpTokenRefresher $tokenRefresher;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
@@ -152,10 +152,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback that calls IdP directly
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -168,7 +169,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required. Please authorize the app first.'
|
||||
@@ -417,10 +418,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -433,7 +435,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
@@ -529,10 +531,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -545,7 +548,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
@@ -628,10 +631,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -644,7 +648,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
@@ -757,10 +761,11 @@ class ApiController extends Controller {
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -773,7 +778,7 @@ class ApiController extends Controller {
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if (!$accessToken) {
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required.'
|
||||
@@ -788,4 +793,62 @@ class ApiController extends Controller {
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF page preview (server-side rendered).
|
||||
*
|
||||
* AJAX endpoint for PDF viewer in semantic search UI.
|
||||
* Uses server-side PyMuPDF rendering to avoid CSP/worker issues.
|
||||
*
|
||||
* @param string $file_path WebDAV path to PDF file
|
||||
* @param int $page Page number (1-indexed, default: 1)
|
||||
* @param float $scale Zoom factor (default: 2.0)
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function pdfPreview(
|
||||
string $file_path,
|
||||
int $page = 1,
|
||||
float $scale = 2.0,
|
||||
): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['success' => false, 'error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$result = $this->client->getPdfPreview($file_path, $page, $scale, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
}
|
||||
|
||||
+82
-16
@@ -23,13 +23,13 @@ use Psr\Log\LoggerInterface;
|
||||
* Handles storing and validating app passwords for multi-user BasicAuth mode.
|
||||
*/
|
||||
class CredentialsController extends Controller {
|
||||
private $tokenStorage;
|
||||
private $userSession;
|
||||
private $logger;
|
||||
private $config;
|
||||
private $client;
|
||||
private $httpClientService;
|
||||
private $urlGenerator;
|
||||
private McpTokenStorage $tokenStorage;
|
||||
private IUserSession $userSession;
|
||||
private LoggerInterface $logger;
|
||||
private IConfig $config;
|
||||
private McpServerClient $client;
|
||||
private IClientService $httpClientService;
|
||||
private IURLGenerator $urlGenerator;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
@@ -94,24 +94,90 @@ class CredentialsController extends Controller {
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Store encrypted app password
|
||||
// Store encrypted app password locally in Nextcloud
|
||||
try {
|
||||
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||
$this->logger->info("Successfully stored app password for user: $userId");
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => 'App password saved successfully'
|
||||
], Http::STATUS_OK);
|
||||
$this->logger->info("Stored app password locally for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store app password for user $userId", [
|
||||
$this->logger->error("Failed to store app password locally for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to save app password'
|
||||
'error' => 'Failed to save app password locally'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Send app password to MCP server for background sync
|
||||
// Get MCP server URL from system config (set in config.php)
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
$this->logger->warning('MCP server URL not configured, app password stored locally only');
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server not configured)'
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
try {
|
||||
$httpClient = $this->httpClientService->newClient();
|
||||
|
||||
// Send to MCP server with BasicAuth (user proves ownership of password)
|
||||
$mcpEndpoint = rtrim($mcpServerUrl, '/') . '/api/v1/users/' . urlencode($userId) . '/app-password';
|
||||
|
||||
$this->logger->debug("Sending app password to MCP server: $mcpEndpoint");
|
||||
|
||||
$response = $httpClient->post($mcpEndpoint, [
|
||||
'auth' => [$userId, $appPassword],
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$body = json_decode($response->getBody(), true);
|
||||
|
||||
if ($statusCode === 200 && ($body['success'] ?? false)) {
|
||||
$this->logger->info("Successfully provisioned app password to MCP server for user: $userId");
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => false,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => true,
|
||||
'message' => 'App password saved successfully'
|
||||
], Http::STATUS_OK);
|
||||
} else {
|
||||
$error = $body['error'] ?? 'Unknown error';
|
||||
$this->logger->error("MCP server rejected app password for user $userId: $error");
|
||||
// Return partial success since it was stored locally but MCP sync failed
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server sync failed)',
|
||||
'mcp_error' => $error
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to send app password to MCP server for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Return partial success since it was stored locally but MCP was unreachable
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server unreachable)',
|
||||
'mcp_error' => $e->getMessage()
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-10
@@ -12,6 +12,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
@@ -31,16 +32,16 @@ use Psr\Log\LoggerInterface;
|
||||
* - Public clients: PKCE only
|
||||
* - Confidential clients: PKCE + client_secret (defense in depth)
|
||||
*/
|
||||
class OAuthController extends Controller {
|
||||
private $config;
|
||||
private $session;
|
||||
private $userSession;
|
||||
private $urlGenerator;
|
||||
private $tokenStorage;
|
||||
private $logger;
|
||||
private $l;
|
||||
private $httpClient;
|
||||
private $client;
|
||||
class OauthController extends Controller {
|
||||
private IConfig $config;
|
||||
private ISession $session;
|
||||
private IUserSession $userSession;
|
||||
private IURLGenerator $urlGenerator;
|
||||
private McpTokenStorage $tokenStorage;
|
||||
private LoggerInterface $logger;
|
||||
private IL10N $l;
|
||||
private IClient $httpClient;
|
||||
private McpServerClient $client;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
+23
-3
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Search;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||
@@ -35,6 +36,7 @@ class SemanticSearchProvider implements IProvider {
|
||||
public function __construct(
|
||||
private McpServerClient $client,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private IConfig $config,
|
||||
private IL10N $l10n,
|
||||
private IURLGenerator $urlGenerator,
|
||||
@@ -85,12 +87,30 @@ class SemanticSearchProvider implements IProvider {
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Get OAuth token for user
|
||||
$accessToken = $this->tokenStorage->getAccessToken($user->getUID());
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback matching ApiController pattern
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get OAuth token for user with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
// User hasn't authorized the app yet - return empty results
|
||||
$this->logger->debug('No OAuth token for user in semantic search', [
|
||||
'user_id' => $user->getUID(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
+73
-23
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -18,10 +19,10 @@ use Psr\Log\LoggerInterface;
|
||||
* Public clients without client_secret cannot refresh tokens.
|
||||
*/
|
||||
class IdpTokenRefresher {
|
||||
private $config;
|
||||
private $httpClient;
|
||||
private $logger;
|
||||
private $mcpServerClient;
|
||||
private IConfig $config;
|
||||
private IClient $httpClient;
|
||||
private LoggerInterface $logger;
|
||||
private McpServerClient $mcpServerClient;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
@@ -38,25 +39,47 @@ class IdpTokenRefresher {
|
||||
/**
|
||||
* Get Nextcloud base URL for constructing internal OIDC endpoint URLs.
|
||||
*
|
||||
* @return string Base URL (e.g., "https://nextcloud.example.com")
|
||||
* IMPORTANT: This is for INTERNAL server-to-server requests (PHP to local Apache),
|
||||
* NOT for external client URLs. We must use the internal container URL, not the
|
||||
* external URL that browsers see.
|
||||
*
|
||||
* Configuration priority:
|
||||
* 1. astrolabe_internal_url - Explicit internal URL (for custom container setups)
|
||||
* 2. http://localhost - Default for Docker containers (web server on port 80)
|
||||
*
|
||||
* NOTE: We intentionally DO NOT use overwrite.cli.url here because:
|
||||
* - overwrite.cli.url is the EXTERNAL URL (e.g., http://localhost:8080)
|
||||
* - External URLs are not accessible from inside the container
|
||||
* - This method is for internal HTTP requests to the local web server
|
||||
*
|
||||
* @return string Base URL for internal requests (e.g., "http://localhost")
|
||||
*/
|
||||
private function getNextcloudBaseUrl(): string {
|
||||
// Prefer explicit CLI URL override
|
||||
$baseUrl = $this->config->getSystemValue('overwrite.cli.url', '');
|
||||
|
||||
if (!empty($baseUrl)) {
|
||||
return rtrim($baseUrl, '/');
|
||||
// Check for explicit internal URL config (for custom container setups)
|
||||
$internalUrl = $this->config->getSystemValue('astrolabe_internal_url', '');
|
||||
if (!is_string($internalUrl)) {
|
||||
$internalUrl = '';
|
||||
}
|
||||
if (!empty($internalUrl)) {
|
||||
// Validate URL format
|
||||
if (!filter_var($internalUrl, FILTER_VALIDATE_URL)) {
|
||||
$this->logger->warning('Invalid astrolabe_internal_url format, using default', [
|
||||
'configured_url' => $internalUrl,
|
||||
]);
|
||||
return 'http://localhost';
|
||||
}
|
||||
// Warn if it looks like an external URL (common misconfiguration)
|
||||
if (preg_match('/:\d{4,5}$/', $internalUrl)) {
|
||||
$this->logger->warning('astrolabe_internal_url appears to use external port mapping', [
|
||||
'configured_url' => $internalUrl,
|
||||
'hint' => 'Internal URLs should use port 80, not mapped ports like :8080',
|
||||
]);
|
||||
}
|
||||
return rtrim($internalUrl, '/');
|
||||
}
|
||||
|
||||
// Fallback to first trusted domain with protocol
|
||||
$trustedDomains = $this->config->getSystemValue('trusted_domains', []);
|
||||
if (!empty($trustedDomains)) {
|
||||
$protocol = $this->config->getSystemValue('overwriteprotocol', 'https');
|
||||
return $protocol . '://' . $trustedDomains[0];
|
||||
}
|
||||
|
||||
// Last resort: localhost (log warning)
|
||||
$this->logger->warning('IdpTokenRefresher: No Nextcloud URL configured, using localhost fallback');
|
||||
// Default: container environment with web server on localhost:80
|
||||
// This works because PHP runs inside the same container as Apache
|
||||
return 'http://localhost';
|
||||
}
|
||||
|
||||
@@ -99,7 +122,7 @@ class IdpTokenRefresher {
|
||||
// External IdP configured - use OIDC discovery
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Using external IdP', [
|
||||
$this->logger->debug('IdpTokenRefresher: Using external IdP', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
|
||||
@@ -115,7 +138,7 @@ class IdpTokenRefresher {
|
||||
// Nextcloud's OIDC app - use internal URL
|
||||
$tokenEndpoint = $this->getNextcloudBaseUrl() . '/apps/oidc/token';
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Using Nextcloud OIDC app', [
|
||||
$this->logger->debug('IdpTokenRefresher: Using Nextcloud OIDC app', [
|
||||
'token_endpoint' => $tokenEndpoint,
|
||||
]);
|
||||
}
|
||||
@@ -160,11 +183,38 @@ class IdpTokenRefresher {
|
||||
|
||||
return $tokenData;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
|
||||
} catch (\OCP\Http\Client\LocalServerException $e) {
|
||||
// Network/connection error - may be transient
|
||||
$this->logger->warning('IdpTokenRefresher: Network error during refresh', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
$statusCode = null;
|
||||
if (method_exists($e, 'getCode')) {
|
||||
$statusCode = $e->getCode();
|
||||
}
|
||||
|
||||
// Log with appropriate level based on error type
|
||||
if ($statusCode === 401 || $statusCode === 403) {
|
||||
// Auth error - token is invalid, should be deleted
|
||||
$this->logger->error('IdpTokenRefresher: Auth error - token invalid', [
|
||||
'status_code' => $statusCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} elseif ($statusCode >= 500) {
|
||||
// Server error - may be transient
|
||||
$this->logger->warning('IdpTokenRefresher: Server error during refresh', [
|
||||
'status_code' => $statusCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} else {
|
||||
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
|
||||
'status_code' => $statusCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+65
-5
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -16,10 +17,10 @@ use Psr\Log\LoggerInterface;
|
||||
* for all management operations.
|
||||
*/
|
||||
class McpServerClient {
|
||||
private $httpClient;
|
||||
private $config;
|
||||
private $logger;
|
||||
private $baseUrl;
|
||||
private IClient $httpClient;
|
||||
private IConfig $config;
|
||||
private LoggerInterface $logger;
|
||||
private string $baseUrl;
|
||||
|
||||
public function __construct(
|
||||
IClientService $clientService,
|
||||
@@ -31,7 +32,8 @@ class McpServerClient {
|
||||
$this->logger = $logger;
|
||||
|
||||
// Get MCP server configuration from Nextcloud config
|
||||
$this->baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
|
||||
$baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
|
||||
$this->baseUrl = is_string($baseUrl) ? $baseUrl : 'http://localhost:8000';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -603,4 +605,62 @@ class McpServerClient {
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF page preview (server-side rendered).
|
||||
*
|
||||
* Renders a PDF page to PNG using PyMuPDF on the server.
|
||||
* This avoids client-side PDF.js issues with CSP and ES private fields.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $filePath WebDAV path to PDF file
|
||||
* @param int $page Page number (1-indexed)
|
||||
* @param float $scale Zoom factor (default: 2.0)
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* success?: bool,
|
||||
* image?: string,
|
||||
* page_number?: int,
|
||||
* total_pages?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getPdfPreview(
|
||||
string $filePath,
|
||||
int $page,
|
||||
float $scale,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/pdf-preview',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
],
|
||||
'query' => [
|
||||
'file_path' => $filePath,
|
||||
'page' => $page,
|
||||
'scale' => $scale,
|
||||
]
|
||||
]
|
||||
);
|
||||
/** @var array{success?: bool, image?: string, page_number?: int, total_pages?: int, error?: string} $data */
|
||||
$data = json_decode((string)$response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get PDF preview', [
|
||||
'error' => $e->getMessage(),
|
||||
'file_path' => $filePath,
|
||||
'page' => $page,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+176
-34
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ICrypto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -15,18 +18,27 @@ use Psr\Log\LoggerInterface;
|
||||
* Handles token expiration checking and refresh logic.
|
||||
*/
|
||||
class McpTokenStorage {
|
||||
/** Buffer time in seconds before actual expiry to trigger refresh */
|
||||
private const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
|
||||
|
||||
private $config;
|
||||
private $crypto;
|
||||
private $db;
|
||||
private $logger;
|
||||
private ILockingProvider $lockingProvider;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
ICrypto $crypto,
|
||||
IDBConnection $db,
|
||||
LoggerInterface $logger,
|
||||
ILockingProvider $lockingProvider,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->crypto = $crypto;
|
||||
$this->db = $db;
|
||||
$this->logger = $logger;
|
||||
$this->lockingProvider = $lockingProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,18 +50,21 @@ class McpTokenStorage {
|
||||
* @param string $accessToken OAuth access token
|
||||
* @param string $refreshToken OAuth refresh token
|
||||
* @param int $expiresAt Unix timestamp when token expires
|
||||
* @param int|null $issuedAt Unix timestamp when token was issued (for lifetime calculation)
|
||||
*/
|
||||
public function storeUserToken(
|
||||
string $userId,
|
||||
string $accessToken,
|
||||
string $refreshToken,
|
||||
int $expiresAt,
|
||||
?int $issuedAt = null,
|
||||
): void {
|
||||
try {
|
||||
$tokenData = [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt,
|
||||
'issued_at' => $issuedAt ?? time(),
|
||||
];
|
||||
|
||||
// Encrypt token data before storage
|
||||
@@ -112,7 +127,7 @@ class McpTokenStorage {
|
||||
/**
|
||||
* Check if a token is expired or about to expire.
|
||||
*
|
||||
* Uses a 60-second buffer to refresh tokens before they actually expire.
|
||||
* Uses TOKEN_EXPIRY_BUFFER_SECONDS buffer to refresh tokens before they actually expire.
|
||||
*
|
||||
* @param array $token Token data array
|
||||
* @return bool True if expired or about to expire
|
||||
@@ -122,8 +137,44 @@ class McpTokenStorage {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expire 60 seconds early to avoid race conditions
|
||||
return time() >= ($token['expires_at'] - 60);
|
||||
// Expire early to avoid race conditions
|
||||
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lock path for a user's token refresh operation.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string Lock path
|
||||
*/
|
||||
private function getTokenRefreshLockPath(string $userId): string {
|
||||
return 'astrolabe/oauth/tokens/' . $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback while holding exclusive lock on user's token.
|
||||
*
|
||||
* Prevents race conditions between background job and on-demand token refresh.
|
||||
*
|
||||
* Note: Lock TTL is configured at the Nextcloud server level (default: 3600s).
|
||||
* If a process crashes while holding the lock, it will auto-expire after the TTL.
|
||||
* The ILockingProvider interface does not support per-call timeouts.
|
||||
*
|
||||
* @template T
|
||||
* @param string $userId User ID
|
||||
* @param callable(): T $callback
|
||||
* @return T
|
||||
* @throws LockedException If lock cannot be acquired
|
||||
*/
|
||||
public function withTokenLock(string $userId, callable $callback): mixed {
|
||||
$lockPath = $this->getTokenRefreshLockPath($userId);
|
||||
|
||||
$this->lockingProvider->acquireLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->lockingProvider->releaseLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,57 +201,148 @@ class McpTokenStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs that have OAuth tokens stored.
|
||||
*
|
||||
* Queries oc_preferences directly since IConfig doesn't support
|
||||
* listing all users with a specific key set.
|
||||
*
|
||||
* @param int $limit Maximum users to return (0 = no limit, for backward compatibility)
|
||||
* @param int $offset Starting offset for pagination
|
||||
* @return list<string> Array of user IDs
|
||||
*/
|
||||
public function getAllUsersWithTokens(int $limit = 0, int $offset = 0): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('userid')
|
||||
->from('preferences')
|
||||
->where($qb->expr()->eq('appid', $qb->createNamedParameter('astrolabe')))
|
||||
->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('oauth_tokens')));
|
||||
|
||||
if ($limit > 0) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset > 0) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
/** @var list<string> $userIds */
|
||||
$userIds = [];
|
||||
/** @psalm-suppress MixedAssignment - IResult::fetch() returns mixed */
|
||||
while (($row = $result->fetch()) !== false) {
|
||||
if (is_array($row) && isset($row['userid']) && is_string($row['userid'])) {
|
||||
$userIds[] = $row['userid'];
|
||||
}
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token for a user, handling expiration and refresh.
|
||||
*
|
||||
* This is a convenience method that combines token retrieval,
|
||||
* expiration checking, and automatic refresh if needed.
|
||||
*
|
||||
* Uses double-check locking pattern to prevent race conditions between
|
||||
* background job and on-demand refresh while minimizing lock contention.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param callable|null $refreshCallback Callback to refresh token if expired
|
||||
* Should accept (refreshToken) and return new token data
|
||||
* @return string|null Access token, or null if not available
|
||||
*/
|
||||
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
||||
// Quick check without lock (optimization)
|
||||
$token = $this->getUserToken($userId);
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if ($this->isExpired($token)) {
|
||||
// Try to refresh if callback provided
|
||||
if ($refreshCallback && isset($token['refresh_token'])) {
|
||||
try {
|
||||
$newTokenData = $refreshCallback($token['refresh_token']);
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$newTokenData['access_token'],
|
||||
$newTokenData['refresh_token'] ?? $token['refresh_token'],
|
||||
time() + ($newTokenData['expires_in'] ?? 3600)
|
||||
);
|
||||
|
||||
return $newTokenData['access_token'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Fall through to return null
|
||||
}
|
||||
}
|
||||
|
||||
// Token expired and no refresh available
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
// If not expired, return immediately without lock
|
||||
if (!$this->isExpired($token)) {
|
||||
return $token['access_token'];
|
||||
}
|
||||
|
||||
return $token['access_token'];
|
||||
// Token expired - acquire lock for refresh
|
||||
try {
|
||||
/**
|
||||
* @return string|null
|
||||
* @psalm-suppress MixedInferredReturnType
|
||||
*/
|
||||
return $this->withTokenLock($userId, function () use ($userId, $refreshCallback): ?string {
|
||||
// Re-check after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for the lock
|
||||
$currentToken = $this->getUserToken($userId);
|
||||
|
||||
if ($currentToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if another process already refreshed the token
|
||||
if (!$this->isExpired($currentToken)) {
|
||||
$this->logger->debug("Token already refreshed for user $userId while waiting for lock");
|
||||
/** @var string */
|
||||
return $currentToken['access_token'];
|
||||
}
|
||||
|
||||
// Still expired, perform refresh
|
||||
if ($refreshCallback && isset($currentToken['refresh_token'])) {
|
||||
try {
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $refreshCallback($refreshToken);
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$now = time();
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? 3600);
|
||||
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at for accurate lifetime calculation
|
||||
);
|
||||
|
||||
return $accessToken;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Delete stale token to prevent repeated refresh attempts
|
||||
$this->deleteUserToken($userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh callback returned null or invalid data - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expired and no refresh callback available - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Could not acquire lock - another process is refreshing
|
||||
// Return stale token rather than failing - caller can retry if needed
|
||||
$this->logger->warning("Could not acquire token lock for user $userId, returning stale token");
|
||||
/** @var string|null $staleToken */
|
||||
$staleToken = $token['access_token'] ?? null;
|
||||
return $staleToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+49
-53
@@ -79,60 +79,48 @@ class Personal implements ISettings {
|
||||
// Check if user has MCP OAuth token
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
// For multi_user_basic mode with app password support, check if user has app password
|
||||
// For multi_user_basic mode with app password support (hybrid mode)
|
||||
// User needs BOTH:
|
||||
// 1. OAuth token for Astrolabe→MCP API calls (stored in McpTokenStorage)
|
||||
// 2. App password for MCP→Nextcloud background sync
|
||||
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
|
||||
// Check if user has already provided an app password
|
||||
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
// Check both credentials
|
||||
$hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token));
|
||||
// In hybrid mode, check specifically for app password (not general background access)
|
||||
// because MCP server needs the app password for background sync
|
||||
$hasAppPassword = ($this->tokenStorage->getBackgroundSyncPassword($userId) !== null);
|
||||
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
if (!$hasBackgroundAccess) {
|
||||
// No app password yet - show app password entry form
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/personal',
|
||||
[
|
||||
'serverUrl' => $this->client->getPublicServerUrl(), // Changed from server_url to serverUrl
|
||||
'serverStatus' => $serverStatus,
|
||||
'auth_mode' => $authMode,
|
||||
'authMode' => $authMode, // Add camelCase version for template
|
||||
'supports_app_passwords' => $supportsAppPasswords,
|
||||
'supportsAppPasswords' => $supportsAppPasswords, // Add camelCase version
|
||||
'session' => null, // No session yet
|
||||
'hasBackgroundAccess' => false, // FIXED: Add missing parameter
|
||||
'backgroundAccessGranted' => false, // FIXED: Add missing parameter
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
'hasToken' => false, // No OAuth token in multi_user_basic mode
|
||||
'requesttoken' => \OCP\Util::callRegister(),
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
} else {
|
||||
// User has app password - show active status
|
||||
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
// OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth)
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
$parameters = [
|
||||
'userId' => $userId,
|
||||
'serverStatus' => $serverStatus,
|
||||
'session' => null, // No user session for app passwords
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
'backgroundAccessGranted' => true, // App password grants background access
|
||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||
'hasToken' => false, // No OAuth token
|
||||
'hasBackgroundAccess' => true,
|
||||
'backgroundSyncType' => $backgroundSyncType,
|
||||
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||
'authMode' => $authMode,
|
||||
'supportsAppPasswords' => $supportsAppPasswords,
|
||||
'requesttoken' => \OCP\Util::callRegister(),
|
||||
];
|
||||
// Consolidated template parameters (camelCase convention)
|
||||
$parameters = [
|
||||
'userId' => $userId,
|
||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||
'serverStatus' => $serverStatus,
|
||||
'authMode' => $authMode,
|
||||
'supportsAppPasswords' => $supportsAppPasswords,
|
||||
'session' => null, // No session in hybrid mode
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
// OAuth token status (for Astrolabe→MCP API calls)
|
||||
'hasOAuthToken' => $hasOAuthToken,
|
||||
'oauthUrl' => $oauthUrl,
|
||||
// App password status (for MCP→Nextcloud background sync)
|
||||
'hasBackgroundAccess' => $hasAppPassword,
|
||||
'backgroundAccessGranted' => $hasAppPassword, // Legacy alias
|
||||
'backgroundSyncType' => $backgroundSyncType,
|
||||
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||
'requesttoken' => \OCP\Util::callRegister(),
|
||||
];
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/personal',
|
||||
$parameters,
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/personal',
|
||||
$parameters,
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
|
||||
elseif (!$token || $this->tokenStorage->isExpired($token)) {
|
||||
@@ -198,6 +186,9 @@ class Personal implements ISettings {
|
||||
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
// OAuth URL for standard OAuth mode (in case user needs to re-authorize)
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
// Provide initial state for Vue.js frontend (if needed)
|
||||
$this->initialState->provideInitialState('user-data', [
|
||||
'userId' => $userId,
|
||||
@@ -205,17 +196,22 @@ class Personal implements ISettings {
|
||||
'session' => $userSession,
|
||||
]);
|
||||
|
||||
// Consolidated template parameters (camelCase convention)
|
||||
$parameters = [
|
||||
'userId' => $userId,
|
||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||
'serverStatus' => $serverStatus,
|
||||
'session' => $userSession,
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
|
||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||
'hasToken' => true,
|
||||
// OAuth status
|
||||
'hasOAuthToken' => true,
|
||||
'oauthUrl' => $oauthUrl,
|
||||
// Background sync status
|
||||
'hasBackgroundAccess' => $hasBackgroundAccess,
|
||||
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false, // Legacy
|
||||
'backgroundSyncType' => $backgroundSyncType,
|
||||
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||
'requesttoken' => \OCP\Util::callRegister(),
|
||||
];
|
||||
|
||||
return new TemplateResponse(
|
||||
|
||||
+81
-265
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.6.0",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astrolabe",
|
||||
"version": "0.6.0",
|
||||
"version": "0.10.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@nextcloud/axios": "^2.5.1",
|
||||
@@ -14,10 +14,9 @@
|
||||
"@nextcloud/initial-state": "^3.0.0",
|
||||
"@nextcloud/l10n": "^3.1.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.0.0",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"plotly.js-dist-min": "^2.35.3",
|
||||
"plotly.js-dist-min": "^3.0.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
@@ -1191,185 +1190,6 @@
|
||||
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.84",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.84",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.84",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.84",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.84",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.84"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.84.tgz",
|
||||
"integrity": "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.84.tgz",
|
||||
"integrity": "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.84.tgz",
|
||||
"integrity": "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.84.tgz",
|
||||
"integrity": "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.84.tgz",
|
||||
"integrity": "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.84.tgz",
|
||||
"integrity": "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.84.tgz",
|
||||
"integrity": "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.84",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.84.tgz",
|
||||
"integrity": "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -1657,9 +1477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nextcloud/vue": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.1.tgz",
|
||||
"integrity": "sha512-zNit83SI7IPT5iT9QsYPCYNwBYvKEqzLvWKTeJemqg9MZ8JGIC3/jjENeXzDolrTN/PixHns5lOYVCejATE1ag==",
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.4.0.tgz",
|
||||
"integrity": "sha512-MoEbaFqFeZfTB+8d/BtgObAfzJMQ+vdidzMP/zKzx9J4cW+vgY5bciDUueY+t3f0uwSJXO3xsqXXWj9x2KihzQ==",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ckpack/vue-color": "^1.6.0",
|
||||
@@ -1671,7 +1491,7 @@
|
||||
"@nextcloud/event-bus": "^3.3.3",
|
||||
"@nextcloud/initial-state": "^3.0.0",
|
||||
"@nextcloud/l10n": "^3.4.1",
|
||||
"@nextcloud/logger": "^3.0.2",
|
||||
"@nextcloud/logger": "^3.0.3",
|
||||
"@nextcloud/router": "^3.1.0",
|
||||
"@nextcloud/sharing": "^0.3.0",
|
||||
"@vuepic/vue-datepicker": "^11.0.3",
|
||||
@@ -1684,9 +1504,9 @@
|
||||
"emoji-mart-vue-fast": "^15.0.5",
|
||||
"escape-html": "^1.0.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"focus-trap": "^7.6.6",
|
||||
"focus-trap": "^7.8.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"p-queue": "^9.0.1",
|
||||
"p-queue": "^9.1.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-react": "^8.0.0",
|
||||
@@ -1696,14 +1516,14 @@
|
||||
"remark-unlink-protocols": "^1.0.0",
|
||||
"splitpanes": "^4.0.4",
|
||||
"striptags": "^3.2.0",
|
||||
"tabbable": "^6.3.0",
|
||||
"tabbable": "^6.4.0",
|
||||
"tributejs": "^5.1.3",
|
||||
"ts-md5": "^2.0.1",
|
||||
"unified": "^11.0.5",
|
||||
"unist-builder": "^4.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.6.3",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-select": "^4.0.0-beta.6"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3003,22 +2823,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
|
||||
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
|
||||
"integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/shared": "3.5.27",
|
||||
"entities": "^7.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core/node_modules/entities": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
|
||||
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -3028,26 +2848,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
|
||||
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
||||
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz",
|
||||
"integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-ssr": "3.5.26",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -3055,13 +2875,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
|
||||
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz",
|
||||
"integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
@@ -3095,53 +2915,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
||||
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz",
|
||||
"integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
|
||||
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz",
|
||||
"integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
|
||||
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.26",
|
||||
"@vue/runtime-core": "3.5.26",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/runtime-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
|
||||
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz",
|
||||
"integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.26"
|
||||
"vue": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
|
||||
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz",
|
||||
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vuepic/vue-datepicker": {
|
||||
@@ -5352,10 +5172,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.6.6",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tabbable": "^6.3.0"
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
@@ -7751,9 +7573,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/p-queue": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz",
|
||||
"integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==",
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
|
||||
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
@@ -7879,16 +7701,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "4.10.38",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.65"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"license": "ISC"
|
||||
@@ -7905,7 +7717,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/plotly.js-dist-min": {
|
||||
"version": "2.35.3",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-3.3.1.tgz",
|
||||
"integrity": "sha512-ZxKM9DlEoEF3wBzGRPGHt6gWTJrm5N81J9AgX9UBX/Qjc9L4lRxtPBPq+RmBJWoA71j1X5Z1ouuguLkdoo88tg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
@@ -9693,7 +9507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.3.0",
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/table": {
|
||||
@@ -10295,16 +10111,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
||||
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
"@vue/runtime-dom": "3.5.26",
|
||||
"@vue/server-renderer": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-sfc": "3.5.27",
|
||||
"@vue/runtime-dom": "3.5.27",
|
||||
"@vue/server-renderer": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
||||
Vendored
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.7.0",
|
||||
"version": "0.10.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
@@ -23,10 +23,9 @@
|
||||
"@nextcloud/initial-state": "^3.0.0",
|
||||
"@nextcloud/l10n": "^3.1.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.0.0",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"plotly.js-dist-min": "^2.35.3",
|
||||
"plotly.js-dist-min": "^3.0.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<files psalm-version="5.26.1@d747f6500b38ac4f7dfc5edbcae6e4b637d7add0">
|
||||
<file src="lib/Controller/ApiController.php">
|
||||
<DeprecatedMethod>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
</DeprecatedMethod>
|
||||
<InvalidArrayOffset>
|
||||
<code><![CDATA[$result['coordinates_3d']]]></code>
|
||||
<code><![CDATA[$result['pca_variance']]]></code>
|
||||
<code><![CDATA[$result['query_coords']]]></code>
|
||||
<code><![CDATA[$webhook['eventFilter']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[!empty($eventConfig['filter']) ? $eventConfig['filter'] : null]]></code>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
<code><![CDATA[$algorithm]]></code>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
<code><![CDATA[$fusion]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$data['algorithm']]]></code>
|
||||
<code><![CDATA[$data['fusion']]]></code>
|
||||
<code><![CDATA[$data['limit']]]></code>
|
||||
<code><![CDATA[$data['scoreThreshold']]]></code>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
<code><![CDATA[$eventConfig['filter']]]></code>
|
||||
<code><![CDATA[$presetEvent['event']]]></code>
|
||||
<code><![CDATA[$presetEvent['event']]]></code>
|
||||
<code><![CDATA[$presetEvent['filter']]]></code>
|
||||
<code><![CDATA[$presetEvent['filter']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
<code><![CDATA[$algorithm]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$eventConfig]]></code>
|
||||
<code><![CDATA[$fusion]]></code>
|
||||
<code><![CDATA[$presetEvent]]></code>
|
||||
<code><![CDATA[$presetEvent]]></code>
|
||||
<code><![CDATA[$presetFilter]]></code>
|
||||
<code><![CDATA[$presetFilter]]></code>
|
||||
<code><![CDATA[$response['coordinates_3d']]]></code>
|
||||
<code><![CDATA[$response['pca_variance']]]></code>
|
||||
<code><![CDATA[$response['query_coords']]]></code>
|
||||
<code><![CDATA[$webhookFilter]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyUndefinedArrayOffset>
|
||||
<code><![CDATA[$webhook['event']]]></code>
|
||||
<code><![CDATA[$webhook['event']]]></code>
|
||||
<code><![CDATA[$webhook['event']]]></code>
|
||||
<code><![CDATA[$webhook['id']]]></code>
|
||||
</PossiblyUndefinedArrayOffset>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[!$token]]></code>
|
||||
<code><![CDATA[empty($webhook['eventFilter'])]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<TypeDoesNotContainType>
|
||||
<code><![CDATA[is_array($status)]]></code>
|
||||
<code><![CDATA[is_array($status)]]></code>
|
||||
</TypeDoesNotContainType>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[ApiController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Controller/CredentialsController.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$body['error']]]></code>
|
||||
<code><![CDATA[$body['success']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$body]]></code>
|
||||
<code><![CDATA[$error]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[$body['success'] ?? false]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[CredentialsController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Controller/OauthController.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$authEndpoint]]></code>
|
||||
<code><![CDATA[$codeVerifier]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$internalBaseUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$tokenData['access_token']]]></code>
|
||||
<code><![CDATA[$tokenData['refresh_token'] ?? '']]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
<code><![CDATA[$userId]]></code>
|
||||
<code><![CDATA[time() + ($tokenData['expires_in'] ?? 3600)]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$discovery['authorization_endpoint']]]></code>
|
||||
<code><![CDATA[$discovery['token_endpoint']]]></code>
|
||||
<code><![CDATA[$discovery['token_endpoint']]]></code>
|
||||
<code><![CDATA[$statusData['auth_mode']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$authEndpoint]]></code>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$codeVerifier]]></code>
|
||||
<code><![CDATA[$discovery]]></code>
|
||||
<code><![CDATA[$discovery]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$mcpServerPublicUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$postData['client_secret']]]></code>
|
||||
<code><![CDATA[$statusData]]></code>
|
||||
<code><![CDATA[$statusData]]></code>
|
||||
<code><![CDATA[$storedState]]></code>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
<code><![CDATA[$userId]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[array]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$authEndpoint]]></code>
|
||||
<code><![CDATA[$tokenData['expires_in'] ?? 3600]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$responseBody]]></code>
|
||||
<code><![CDATA[$responseBody]]></code>
|
||||
<code><![CDATA[$statusResponse->getBody()]]></code>
|
||||
<code><![CDATA[$statusResponse->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[$error]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[OauthController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Listener/AstrolabeAdminSettingsListener.php">
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$value]]></code>
|
||||
<code><![CDATA[$value]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<RedundantCondition>
|
||||
<code><![CDATA[$event instanceof DeclarativeSettingsSetValueEvent]]></code>
|
||||
</RedundantCondition>
|
||||
</file>
|
||||
<file src="lib/Search/SemanticSearchProvider.php">
|
||||
<DeprecatedMethod>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
</DeprecatedMethod>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$chunkNum]]></code>
|
||||
<code><![CDATA[$docType]]></code>
|
||||
<code><![CDATA[$mimeType]]></code>
|
||||
<code><![CDATA[$result['page_count']]]></code>
|
||||
<code><![CDATA[$result['page_number']]]></code>
|
||||
<code><![CDATA[$result['total_chunks']]]></code>
|
||||
<code><![CDATA[$title]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$chunkEnd]]></code>
|
||||
<code><![CDATA[$chunkNum]]></code>
|
||||
<code><![CDATA[$chunkStart]]></code>
|
||||
<code><![CDATA[$docType]]></code>
|
||||
<code><![CDATA[$docType]]></code>
|
||||
<code><![CDATA[$id]]></code>
|
||||
<code><![CDATA[$mimeType]]></code>
|
||||
<code><![CDATA[$params['board_id']]]></code>
|
||||
<code><![CDATA[$params['page_number']]]></code>
|
||||
<code><![CDATA[$params['path']]]></code>
|
||||
<code><![CDATA[$params['title']]]></code>
|
||||
<code><![CDATA[$score]]></code>
|
||||
<code><![CDATA[$title]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$result['chunk_index']]]></code>
|
||||
<code><![CDATA[$score]]></code>
|
||||
</MixedOperand>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[$cursor]]></code>
|
||||
<code><![CDATA[empty($results['error'])]]></code>
|
||||
<code><![CDATA[empty($status['error'])]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
</file>
|
||||
<file src="lib/Service/IdpTokenRefresher.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$discovery['token_endpoint']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$discovery]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$internalUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$statusData]]></code>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[array|null]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$discoveryResponse->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$statusResponse->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Service/McpServerClient.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$clientId]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$baseUrl]]></code>
|
||||
<code><![CDATA[$clientId]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[array]]></code>
|
||||
<code><![CDATA[array{
|
||||
* apps?: array<string>,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* results?: array,
|
||||
* pca_coordinates?: array,
|
||||
* algorithm_used?: string,
|
||||
* total_documents?: int,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* results?: array<array{
|
||||
* id?: string|int,
|
||||
* title?: string,
|
||||
* doc_type?: string,
|
||||
* excerpt?: string,
|
||||
* score?: float,
|
||||
* path?: string,
|
||||
* board_id?: int,
|
||||
* card_id?: int
|
||||
* }>,
|
||||
* total_found?: int,
|
||||
* algorithm_used?: string,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* session_id?: string,
|
||||
* background_access_granted?: bool,
|
||||
* background_access_details?: array,
|
||||
* idp_profile?: array,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* status?: string,
|
||||
* indexed_documents?: int,
|
||||
* pending_documents?: int,
|
||||
* last_sync_time?: string,
|
||||
* documents_per_second?: float,
|
||||
* errors_24h?: int,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* version?: string,
|
||||
* auth_mode?: string,
|
||||
* vector_sync_enabled?: bool,
|
||||
* uptime_seconds?: int,
|
||||
* management_api_version?: string,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* webhooks?: array<array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool
|
||||
* }>,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{success?: bool, error?: string}]]></code>
|
||||
<code><![CDATA[array{success?: bool, message?: string, error?: string}]]></code>
|
||||
<code><![CDATA[string]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$this->config->getSystemValue('mcp_server_public_url', $this->baseUrl)]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
<code><![CDATA[isServerReachable]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Service/McpTokenStorage.php">
|
||||
<InvalidReturnStatement>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
</InvalidReturnStatement>
|
||||
<InvalidReturnType>
|
||||
<code><![CDATA[array|null]]></code>
|
||||
</InvalidReturnType>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$newTokenData]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[string|null]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$token['expires_at']]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$token['access_token']]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[!$token]]></code>
|
||||
<code><![CDATA[$refreshCallback]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
</file>
|
||||
<file src="lib/Service/WebhookPresets.php">
|
||||
<MissingClosureParamType>
|
||||
<code><![CDATA[$eventConfig]]></code>
|
||||
</MissingClosureParamType>
|
||||
<MissingClosureReturnType>
|
||||
<code><![CDATA[fn ($eventConfig) => $eventConfig['event']]]></code>
|
||||
</MissingClosureReturnType>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$preset['events']]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedReturnTypeCoercion>
|
||||
<code><![CDATA[array<string>]]></code>
|
||||
<code><![CDATA[array_map(
|
||||
fn ($eventConfig) => $eventConfig['event'],
|
||||
$preset['events']
|
||||
)]]></code>
|
||||
</MixedReturnTypeCoercion>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[getPresetEvents]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Settings/Admin.php">
|
||||
<DeprecatedMethod>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
</DeprecatedMethod>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$clientId]]></code>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$serverUrl]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<UnusedProperty>
|
||||
<code><![CDATA[$client]]></code>
|
||||
</UnusedProperty>
|
||||
</file>
|
||||
<file src="lib/Settings/AdminSection.php">
|
||||
<UnusedClass>
|
||||
<code><![CDATA[AdminSection]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Settings/AstrolabeAdminSettings.php">
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Settings/Personal.php">
|
||||
<InvalidArrayOffset>
|
||||
<code><![CDATA[$serverStatus['supports_app_passwords']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
<code><![CDATA[$supportsAppPasswords]]></code>
|
||||
</MixedAssignment>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[!$token]]></code>
|
||||
<code><![CDATA[$supportsAppPasswords]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[Personal]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Settings/PersonalSection.php">
|
||||
<UnusedClass>
|
||||
<code><![CDATA[PersonalSection]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
</files>
|
||||
Vendored
+1
@@ -8,6 +8,7 @@
|
||||
findUnusedBaselineEntry="true"
|
||||
findUnusedCode="true"
|
||||
phpVersion="8.1"
|
||||
errorBaseline="psalm-baseline.xml"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="lib" />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 736 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 218 KiB |
Vendored
+42
-36
@@ -48,22 +48,21 @@
|
||||
<div class="mcp-search-card">
|
||||
<div class="mcp-search-row">
|
||||
<NcTextField
|
||||
:value="query"
|
||||
v-model="query"
|
||||
:label="t('astrolabe', 'Search query')"
|
||||
:placeholder="t('astrolabe', 'Enter your search query...')"
|
||||
class="mcp-search-input"
|
||||
@update:value="query = $event"
|
||||
@keyup.enter="performSearch" />
|
||||
|
||||
<NcSelect
|
||||
v-model="selectedAlgorithmOption"
|
||||
:model-value="selectedAlgorithmOption"
|
||||
:options="algorithmOptions"
|
||||
:placeholder="t('astrolabe', 'Algorithm')"
|
||||
class="mcp-algorithm-select"
|
||||
@input="algorithm = $event ? $event.id : 'hybrid'" />
|
||||
@update:model-value="algorithm = $event ? $event.id : 'hybrid'" />
|
||||
|
||||
<NcButton
|
||||
type="primary"
|
||||
variant="primary"
|
||||
:disabled="!query.trim() || loading"
|
||||
@click="performSearch">
|
||||
<template #icon>
|
||||
@@ -75,7 +74,7 @@
|
||||
|
||||
<!-- Advanced Options Toggle -->
|
||||
<NcButton
|
||||
type="tertiary"
|
||||
variant="tertiary"
|
||||
class="mcp-advanced-toggle"
|
||||
@click="showAdvanced = !showAdvanced">
|
||||
<template #icon>
|
||||
@@ -94,9 +93,9 @@
|
||||
<NcCheckboxRadioSwitch
|
||||
v-for="docType in docTypeOptions"
|
||||
:key="docType.id"
|
||||
:checked="selectedDocTypes.includes(docType.id)"
|
||||
:model-value="selectedDocTypes.includes(docType.id)"
|
||||
type="checkbox"
|
||||
@update:checked="toggleDocType(docType.id, $event)">
|
||||
@update:model-value="toggleDocType(docType.id, $event)">
|
||||
{{ docType.label }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
@@ -105,11 +104,10 @@
|
||||
<div class="mcp-option-group">
|
||||
<label>{{ t('astrolabe', 'Result Limit') }}</label>
|
||||
<NcTextField
|
||||
:value="limit"
|
||||
v-model="limit"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="100"
|
||||
@update:value="limit = Number($event)" />
|
||||
:max="100" />
|
||||
</div>
|
||||
|
||||
<div class="mcp-option-group">
|
||||
@@ -154,9 +152,9 @@
|
||||
<div class="mcp-viz-header">
|
||||
<h3>{{ t('astrolabe', 'Vector Space Visualization') }}</h3>
|
||||
<NcCheckboxRadioSwitch
|
||||
:checked="showQueryPoint"
|
||||
:model-value="showQueryPoint"
|
||||
type="switch"
|
||||
@update:checked="showQueryPoint = $event; updatePlot()">
|
||||
@update:model-value="showQueryPoint = $event; updatePlot()">
|
||||
{{ t('astrolabe', 'Show query point') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
@@ -175,7 +173,7 @@
|
||||
<span class="mcp-result-type">{{ result.doc_type || 'unknown' }}</span>
|
||||
<div class="mcp-result-actions">
|
||||
<NcButton
|
||||
type="tertiary"
|
||||
variant="tertiary"
|
||||
:aria-label="t('astrolabe', 'Show Chunk')"
|
||||
@click="viewChunk(result)">
|
||||
<template #icon>
|
||||
@@ -282,7 +280,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NcButton type="secondary" :disabled="statusLoading" @click="loadVectorStatus">
|
||||
<NcButton variant="secondary" :disabled="statusLoading" @click="loadVectorStatus">
|
||||
<template #icon>
|
||||
<Refresh :size="20" />
|
||||
</template>
|
||||
@@ -307,7 +305,7 @@
|
||||
</a>
|
||||
<span v-else>{{ viewerTitle }}</span>
|
||||
</h3>
|
||||
<NcButton type="tertiary" @click="closeViewer">
|
||||
<NcButton variant="tertiary" @click="closeViewer">
|
||||
<template #icon>
|
||||
<Close :size="20" />
|
||||
</template>
|
||||
@@ -396,18 +394,6 @@ import MarkdownViewer from './components/MarkdownViewer.vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import Plotly from 'plotly.js-dist-min'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
|
||||
// Set worker source with error handling
|
||||
try {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url,
|
||||
).toString()
|
||||
} catch (e) {
|
||||
console.warn('Failed to set PDF.js worker, will use fallback', e)
|
||||
// PDF.js will use fake worker automatically
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -445,7 +431,7 @@ export default {
|
||||
algorithm: 'hybrid',
|
||||
showAdvanced: false,
|
||||
selectedDocTypes: [],
|
||||
limit: '20',
|
||||
limit: 20,
|
||||
scoreThreshold: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -617,7 +603,20 @@ export default {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search error:', err)
|
||||
this.error = this.t('astrolabe', 'Network error. Please try again.')
|
||||
// Check if this is an HTTP error with a response
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
// Use the specific error message from the backend
|
||||
this.error = err.response.data.error
|
||||
} else if (err.response && err.response.status === 401) {
|
||||
// Unauthorized - user needs to authorize the app
|
||||
this.error = this.t('astrolabe', 'Authorization required. Please complete Step 1 in Settings → Astrolabe.')
|
||||
} else if (err.response && err.response.status === 503) {
|
||||
// Service unavailable - MCP server not reachable
|
||||
this.error = this.t('astrolabe', 'Search service unavailable. Please try again later.')
|
||||
} else {
|
||||
// Actual network error or unknown error
|
||||
this.error = this.t('astrolabe', 'Network error. Please try again.')
|
||||
}
|
||||
this.results = []
|
||||
} finally {
|
||||
this.loading = false
|
||||
@@ -639,7 +638,14 @@ export default {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Status error:', err)
|
||||
this.statusError = this.t('astrolabe', 'Network error. Please try again.')
|
||||
// Extract error message from response if available
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
this.statusError = err.response.data.error
|
||||
} else if (err.response && err.response.status === 401) {
|
||||
this.statusError = this.t('astrolabe', 'Authorization required. Please complete Step 1 in Settings → Astrolabe.')
|
||||
} else {
|
||||
this.statusError = this.t('astrolabe', 'Network error. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
this.statusLoading = false
|
||||
}
|
||||
@@ -751,7 +757,7 @@ export default {
|
||||
colorscale: 'Viridis',
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: 'Relative Score',
|
||||
title: { text: 'Relative Score' },
|
||||
x: 1.02,
|
||||
xanchor: 'left',
|
||||
thickness: 20,
|
||||
@@ -786,13 +792,13 @@ export default {
|
||||
}
|
||||
|
||||
const layout = {
|
||||
title: `Vector Space (PCA 3D) - ${results.length} results`,
|
||||
title: { text: `Vector Space (PCA 3D) - ${results.length} results` },
|
||||
width,
|
||||
height,
|
||||
scene: {
|
||||
xaxis: { title: 'PC1' },
|
||||
yaxis: { title: 'PC2' },
|
||||
zaxis: { title: 'PC3' },
|
||||
xaxis: { title: { text: 'PC1' } },
|
||||
yaxis: { title: { text: 'PC2' } },
|
||||
zaxis: { title: { text: 'PC3' } },
|
||||
camera: {
|
||||
eye: { x: 1.5, y: 1.5, z: 1.5 },
|
||||
},
|
||||
|
||||
+65
-111
@@ -8,15 +8,28 @@
|
||||
<AlertCircle :size="48" />
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<div v-else ref="containerRef" class="pdf-canvas-container">
|
||||
<canvas ref="canvasRef" />
|
||||
<div v-else class="pdf-image-container">
|
||||
<img
|
||||
:src="`data:image/png;base64,${imageData}`"
|
||||
class="pdf-page-image"
|
||||
alt="PDF page" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
/**
|
||||
* PDFViewer - Server-side PDF rendering component.
|
||||
*
|
||||
* Displays PDF pages as server-rendered PNG images, avoiding client-side
|
||||
* PDF.js issues with CSP worker restrictions and ES private field access
|
||||
* in Chromium browsers.
|
||||
*
|
||||
* The server uses PyMuPDF to render PDF pages to PNG images, which are
|
||||
* returned as base64-encoded data.
|
||||
*/
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
@@ -33,61 +46,68 @@ const props = defineProps({
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1.5,
|
||||
default: 2.0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
|
||||
|
||||
// Reactive state
|
||||
const pdfDoc = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const imageData = ref(null)
|
||||
const totalPages = ref(0)
|
||||
const canvasRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
|
||||
// Methods
|
||||
async function loadPDF() {
|
||||
/**
|
||||
* Fetch a PDF page from the server as a PNG image.
|
||||
*/
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Clean and encode the file path
|
||||
const cleanPath = props.filePath.startsWith('/')
|
||||
? props.filePath.substring(1)
|
||||
: props.filePath
|
||||
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/')
|
||||
const downloadUrl = generateUrl(`/remote.php/webdav/${encodedPath}`)
|
||||
// Build request URL
|
||||
const url = generateUrl('/apps/astrolabe/api/pdf-preview')
|
||||
const params = {
|
||||
file_path: props.filePath,
|
||||
page: props.pageNumber,
|
||||
scale: props.scale,
|
||||
}
|
||||
|
||||
// Load PDF document
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
url: downloadUrl,
|
||||
withCredentials: true,
|
||||
useWorkerFetch: false, // Disable worker fetch for CSP compliance
|
||||
isEvalSupported: false, // Disable eval for CSP
|
||||
})
|
||||
const response = await axios.get(url, { params })
|
||||
|
||||
pdfDoc.value = await loadingTask.promise
|
||||
totalPages.value = pdfDoc.value.numPages
|
||||
emit('loaded', { totalPages: totalPages.value })
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Failed to load PDF page')
|
||||
}
|
||||
|
||||
const data = response.data
|
||||
|
||||
// Update state
|
||||
imageData.value = data.image
|
||||
totalPages.value = data.total_pages
|
||||
|
||||
// Emit loaded event - App.vue uses this for navigation controls
|
||||
emit('loaded', { totalPages: data.total_pages })
|
||||
emit('page-rendered', { pageNumber: props.pageNumber })
|
||||
|
||||
// Set loading to false - the watcher will handle rendering
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
console.error('PDF load error:', err)
|
||||
|
||||
// Provide user-friendly error messages
|
||||
if (err.name === 'MissingPDFException') {
|
||||
// Provide user-friendly error messages based on axios error structure
|
||||
const status = err.response?.status
|
||||
const serverError = err.response?.data?.error
|
||||
|
||||
if (status === 404) {
|
||||
error.value = t('astrolabe', 'PDF file not found')
|
||||
} else if (err.name === 'InvalidPDFException') {
|
||||
error.value = t('astrolabe', 'Invalid or corrupted PDF file')
|
||||
} else if (err.message?.includes('NetworkError') || err.message?.includes('Network')) {
|
||||
} else if (status === 401 || status === 403) {
|
||||
error.value = serverError || t('astrolabe', 'Authorization required to view PDF')
|
||||
} else if (err.code === 'ERR_NETWORK' || err.message?.includes('Network')) {
|
||||
error.value = t('astrolabe', 'Network error loading PDF')
|
||||
} else if (err.message?.includes('404')) {
|
||||
error.value = t('astrolabe', 'PDF file not found')
|
||||
} else if (serverError) {
|
||||
error.value = serverError
|
||||
} else {
|
||||
error.value = t('astrolabe', 'Unable to load PDF file')
|
||||
error.value = t('astrolabe', 'Unable to load PDF page')
|
||||
}
|
||||
|
||||
emit('error', err)
|
||||
@@ -95,78 +115,12 @@ async function loadPDF() {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage(pageNum) {
|
||||
if (!pdfDoc.value) {
|
||||
return
|
||||
}
|
||||
// Re-fetch when file path or page number changes
|
||||
watch(() => [props.filePath, props.pageNumber], loadPage)
|
||||
|
||||
try {
|
||||
const page = await pdfDoc.value.getPage(pageNum)
|
||||
const canvas = canvasRef.value
|
||||
|
||||
if (!canvas) {
|
||||
console.error('PDF canvas ref not found')
|
||||
error.value = t('astrolabe', 'Canvas element not available')
|
||||
return
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
// Use scale for better resolution on high-DPI screens
|
||||
const viewport = page.getViewport({ scale: props.scale })
|
||||
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
|
||||
// Render page to canvas
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}
|
||||
|
||||
await page.render(renderContext).promise
|
||||
|
||||
emit('page-rendered', { pageNumber: pageNum })
|
||||
} catch (err) {
|
||||
console.error('PDF render error:', err)
|
||||
error.value = t('astrolabe', 'Error rendering PDF page')
|
||||
emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => props.pageNumber, (newPage) => {
|
||||
if (pdfDoc.value && newPage > 0 && newPage <= totalPages.value) {
|
||||
renderPage(newPage)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.filePath, () => {
|
||||
// Reload PDF if file path changes
|
||||
loadPDF()
|
||||
})
|
||||
|
||||
watch(loading, async (newLoading) => {
|
||||
// When loading completes, wait for canvas to be available and render
|
||||
if (!newLoading && pdfDoc.value && !error.value) {
|
||||
// Wait for Vue to update DOM
|
||||
await nextTick()
|
||||
// Canvas should now be rendered (v-else condition)
|
||||
if (canvasRef.value) {
|
||||
await renderPage(props.pageNumber)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Lifecycle hooks
|
||||
// Initial load
|
||||
onMounted(() => {
|
||||
loadPDF()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pdfDoc.value) {
|
||||
pdfDoc.value.destroy()
|
||||
}
|
||||
loadPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -206,19 +160,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-canvas-container {
|
||||
.pdf-image-container {
|
||||
position: relative;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: var(--color-main-background);
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.pdf-page-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+24
-14
@@ -6,7 +6,7 @@
|
||||
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
|
||||
<p>{{ error }}</p>
|
||||
<p class="help-text">{{ t('astrolabe', 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.') }}</p>
|
||||
<NcButton type="primary" @click="retryConnection">
|
||||
<NcButton variant="primary" @click="retryConnection">
|
||||
<template #icon>
|
||||
<Refresh :size="20" />
|
||||
</template>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
|
||||
</div>
|
||||
</div>
|
||||
<NcButton type="secondary" @click="refreshStatus">
|
||||
<NcButton variant="secondary" @click="refreshStatus">
|
||||
<template #icon>
|
||||
<Refresh :size="20" />
|
||||
</template>
|
||||
@@ -85,7 +85,7 @@
|
||||
</p>
|
||||
<p v-else>{{ webhooksError }}</p>
|
||||
<div class="webhook-auth-actions">
|
||||
<NcButton type="primary" @click="openPersonalSettings">
|
||||
<NcButton variant="primary" @click="openPersonalSettings">
|
||||
{{ t('astrolabe', 'Go to Personal Settings') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<NcButton
|
||||
:type="preset.enabled ? 'secondary' : 'primary'"
|
||||
:variant="preset.enabled ? 'secondary' : 'primary'"
|
||||
:disabled="preset.toggling"
|
||||
@click="toggleWebhookPreset(preset)">
|
||||
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
|
||||
@@ -152,19 +152,21 @@
|
||||
|
||||
<div class="settings-form">
|
||||
<NcSelect
|
||||
v-model="settings.algorithm"
|
||||
:model-value="selectedAlgorithmOption"
|
||||
:options="algorithmOptions"
|
||||
:label="t('astrolabe', 'Search Algorithm')"
|
||||
class="form-field" />
|
||||
:input-label="t('astrolabe', 'Search Algorithm')"
|
||||
class="form-field"
|
||||
@update:model-value="settings.algorithm = $event ? $event.id : 'hybrid'" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
|
||||
</p>
|
||||
|
||||
<NcSelect
|
||||
v-model="settings.fusion"
|
||||
:model-value="selectedFusionOption"
|
||||
:options="fusionOptions"
|
||||
:label="t('astrolabe', 'Fusion Method')"
|
||||
class="form-field" />
|
||||
:input-label="t('astrolabe', 'Fusion Method')"
|
||||
class="form-field"
|
||||
@update:model-value="settings.fusion = $event ? $event.id : 'rrf'" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.') }}
|
||||
</p>
|
||||
@@ -184,20 +186,19 @@
|
||||
</div>
|
||||
|
||||
<NcTextField
|
||||
:value="settings.limit"
|
||||
v-model="settings.limit"
|
||||
:label="t('astrolabe', 'Maximum Results')"
|
||||
type="number"
|
||||
:min="5"
|
||||
:max="100"
|
||||
:step="5"
|
||||
class="form-field"
|
||||
@update:value="settings.limit = Number($event)" />
|
||||
class="form-field" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
|
||||
</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<NcButton type="primary" :disabled="saving" @click="saveSettings">
|
||||
<NcButton variant="primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? t('astrolabe', 'Saving...') : t('astrolabe', 'Save Settings') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
@@ -276,6 +277,15 @@ const fusionOptions = computed(() => [
|
||||
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
|
||||
])
|
||||
|
||||
// Computed properties for NcSelect (converts between stored ID and option object)
|
||||
const selectedAlgorithmOption = computed(() =>
|
||||
algorithmOptions.value.find(opt => opt.id === settings.value.algorithm) || algorithmOptions.value[0],
|
||||
)
|
||||
|
||||
const selectedFusionOption = computed(() =>
|
||||
fusionOptions.value.find(opt => opt.id === settings.value.fusion) || fusionOptions.value[0],
|
||||
)
|
||||
|
||||
// Methods
|
||||
async function loadServerStatus() {
|
||||
loading.value = true
|
||||
|
||||
+3
-2
@@ -2,10 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCP\Util;
|
||||
|
||||
Util::addScript(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main');
|
||||
Util::addStyle(OCA\Astrolabe\AppInfo\Application::APP_ID, OCA\Astrolabe\AppInfo\Application::APP_ID . '-main');
|
||||
Util::addScript(Application::APP_ID, Application::APP_ID . '-main');
|
||||
Util::addStyle(Application::APP_ID, Application::APP_ID . '-main');
|
||||
|
||||
?>
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
script('astrolabe', 'astrolabe-adminSettings');
|
||||
style('astrolabe', 'astrolabe-adminSettings');
|
||||
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
|
||||
?>
|
||||
|
||||
<div id="astrolabe-admin-settings" class="section">
|
||||
|
||||
+127
-42
@@ -18,7 +18,7 @@
|
||||
$urlGenerator = \OC::$server->getURLGenerator();
|
||||
|
||||
script('astrolabe', 'astrolabe-personalSettings');
|
||||
style('astrolabe', 'astrolabe-personalSettings');
|
||||
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
|
||||
?>
|
||||
|
||||
<div class="section">
|
||||
@@ -43,7 +43,17 @@ style('astrolabe', 'astrolabe-personalSettings');
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Background Sync Access')); ?></h2>
|
||||
|
||||
<?php if ($_['hasBackgroundAccess'] || $_['backgroundAccessGranted']): ?>
|
||||
<?php
|
||||
// Determine if hybrid mode (multi_user_basic + app passwords)
|
||||
// In hybrid mode, user needs BOTH OAuth AND app password to be "fully configured"
|
||||
$isHybridMode = ($_['authMode'] ?? '') === 'multi_user_basic' && !empty($_['supportsAppPasswords']);
|
||||
$hasOAuthToken = !empty($_['hasOAuthToken']);
|
||||
$hasBackgroundAccess = !empty($_['hasBackgroundAccess']) || !empty($_['backgroundAccessGranted']);
|
||||
|
||||
// In hybrid mode: both credentials required; otherwise just background access
|
||||
$isFullyConfigured = $isHybridMode ? ($hasOAuthToken && $hasBackgroundAccess) : $hasBackgroundAccess;
|
||||
?>
|
||||
<?php if ($isFullyConfigured): ?>
|
||||
<!-- Already configured -->
|
||||
<div class="mcp-background-status">
|
||||
<p>
|
||||
@@ -110,54 +120,129 @@ style('astrolabe', 'astrolabe-personalSettings');
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Not configured - show provisioning options -->
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
|
||||
</p>
|
||||
|
||||
<div class="mcp-grant-section">
|
||||
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
|
||||
<?php if ($isHybridMode): ?>
|
||||
<!-- Hybrid mode: User needs BOTH OAuth AND app password -->
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
|
||||
</p>
|
||||
<a href="<?php p($_['serverUrl']); ?>/oauth/login?next=<?php p(urlencode($urlGenerator->getAbsoluteURL($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])))); ?>" class="button">
|
||||
<span class="icon icon-confirm"></span>
|
||||
<?php p($l->t('Authorize via OAuth')); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mcp-grant-section">
|
||||
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
|
||||
<?php p($l->t('To use semantic search, you need to complete two setup steps:')); ?>
|
||||
</p>
|
||||
|
||||
<div class="mcp-app-password-steps">
|
||||
<p><strong><?php p($l->t('Step 1:')); ?></strong>
|
||||
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
|
||||
<?php p($l->t('Generate app password in Security settings')); ?>
|
||||
<!-- Step 1: OAuth Authorization (for Astrolabe→MCP API calls) -->
|
||||
<div class="mcp-grant-section">
|
||||
<h4>
|
||||
<?php if (!empty($_['hasOAuthToken'])): ?>
|
||||
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php p($l->t('Step 1: Authorize Search Access')); ?>
|
||||
</h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Authorize Astrolabe to perform searches on your behalf.')); ?>
|
||||
</p>
|
||||
<?php if (empty($_['hasOAuthToken'])): ?>
|
||||
<a href="<?php p($_['oauthUrl']); ?>" class="button primary">
|
||||
<span class="icon icon-confirm"></span>
|
||||
<?php p($l->t('Authorize')); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Search access authorized.')); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: App Password (for MCP→Nextcloud background sync) -->
|
||||
<div class="mcp-grant-section">
|
||||
<h4>
|
||||
<?php if (!empty($_['hasBackgroundAccess'])): ?>
|
||||
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php p($l->t('Step 2: Enable Background Indexing')); ?>
|
||||
</h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Provide an app password to allow background indexing of your content.')); ?>
|
||||
</p>
|
||||
<?php if (empty($_['hasBackgroundAccess'])): ?>
|
||||
<div class="mcp-app-password-steps">
|
||||
<p>
|
||||
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
|
||||
<?php p($l->t('Generate app password in Security settings')); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<div class="mcp-input-group">
|
||||
<input type="password" name="appPassword" id="mcp-app-password-input"
|
||||
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
|
||||
required>
|
||||
<button type="submit" class="button primary" id="mcp-save-app-password-button">
|
||||
<span class="icon icon-checkmark"></span>
|
||||
<?php p($l->t('Save')); ?>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Background indexing enabled.')); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Standard OAuth or BasicAuth mode -->
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
|
||||
</p>
|
||||
|
||||
<div class="mcp-grant-section">
|
||||
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
|
||||
</p>
|
||||
<a href="<?php p($_['oauthUrl']); ?>" class="button">
|
||||
<span class="icon icon-confirm"></span>
|
||||
<?php p($l->t('Authorize via OAuth')); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mcp-grant-section">
|
||||
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
|
||||
</p>
|
||||
|
||||
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
|
||||
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<div class="mcp-input-group">
|
||||
<input type="password" name="appPassword" id="mcp-app-password-input"
|
||||
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
|
||||
required>
|
||||
<button type="submit" class="button primary" id="mcp-save-app-password-button">
|
||||
<span class="icon icon-checkmark"></span>
|
||||
<?php p($l->t('Save')); ?>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
|
||||
<div class="mcp-app-password-steps">
|
||||
<p><strong><?php p($l->t('Step 1:')); ?></strong>
|
||||
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
|
||||
<?php p($l->t('Generate app password in Security settings')); ?>
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
|
||||
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<div class="mcp-input-group">
|
||||
<input type="password" name="appPassword" id="mcp-app-password-input"
|
||||
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
|
||||
required>
|
||||
<button type="submit" class="button primary" id="mcp-save-app-password-button">
|
||||
<span class="icon icon-checkmark"></span>
|
||||
<?php p($l->t('Save')); ?>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Tests\Unit\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\BackgroundJob\RefreshUserTokens;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Lock\LockedException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for RefreshUserTokens background job.
|
||||
*
|
||||
* Tests proactive OAuth token refresh functionality.
|
||||
*/
|
||||
final class RefreshUserTokensTest extends TestCase {
|
||||
private ITimeFactory&MockObject $timeFactory;
|
||||
private McpTokenStorage&MockObject $tokenStorage;
|
||||
private IdpTokenRefresher&MockObject $tokenRefresher;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private RefreshUserTokens $job;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->timeFactory = $this->createMock(ITimeFactory::class);
|
||||
$this->tokenStorage = $this->createMock(McpTokenStorage::class);
|
||||
$this->tokenRefresher = $this->createMock(IdpTokenRefresher::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->job = new RefreshUserTokens(
|
||||
$this->timeFactory,
|
||||
$this->tokenStorage,
|
||||
$this->tokenRefresher,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up default withTokenLock behavior that executes the callback.
|
||||
* Call this in tests that need the lock to succeed.
|
||||
*/
|
||||
private function setupDefaultLockBehavior(): void {
|
||||
$this->tokenStorage->method('withTokenLock')
|
||||
->willReturnCallback(fn ($userId, $callback) => $callback());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Constructor Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testConstructorSetsInterval(): void {
|
||||
// Use reflection to access the protected interval property
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$property = $reflection->getProperty('interval');
|
||||
$property->setAccessible(true);
|
||||
|
||||
$this->assertEquals(900, $property->getValue($this->job));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// run() Method Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRunWithNoUsers(): void {
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn([]);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
$this->assertStringContainsString('Starting', $message);
|
||||
} else {
|
||||
$this->assertStringContainsString('total=0', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message);
|
||||
}
|
||||
});
|
||||
|
||||
// Call run() via reflection since it's protected
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunWithMultipleUsersAndMixedResults(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn(['alice', 'bob', 'charlie']);
|
||||
|
||||
// Alice: token with plenty of time (skipped)
|
||||
// Bob: token near expiry with refresh token (refreshed)
|
||||
// Charlie: token near expiry without refresh token (failed)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return match ($userId) {
|
||||
'alice' => [
|
||||
'access_token' => 'alice-token',
|
||||
'refresh_token' => 'alice-refresh',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime)
|
||||
'issued_at' => $now,
|
||||
],
|
||||
'bob' => [
|
||||
'access_token' => 'bob-token',
|
||||
'refresh_token' => 'bob-refresh',
|
||||
'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime)
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
'charlie' => [
|
||||
'access_token' => 'charlie-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
// Bob's refresh should succeed
|
||||
$this->tokenRefresher->method('refreshAccessToken')
|
||||
->with('bob-refresh')
|
||||
->willReturn([
|
||||
'access_token' => 'bob-new-token',
|
||||
'refresh_token' => 'bob-new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'bob',
|
||||
'bob-new-token',
|
||||
'bob-new-refresh',
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 2) {
|
||||
$this->assertStringContainsString('total=3', $message);
|
||||
$this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunProcessesUsersInBatches(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
// Simulate 150 users processed in 2 batches (100 + 50)
|
||||
$batch1 = array_map(fn ($i) => "user{$i}", range(1, 100));
|
||||
$batch2 = array_map(fn ($i) => "user{$i}", range(101, 150));
|
||||
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturnCallback(function (int $limit, int $offset) use (&$callCount, $batch1, $batch2) {
|
||||
$callCount++;
|
||||
// First call: offset 0, return 100 users (full batch)
|
||||
if ($offset === 0) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch1;
|
||||
}
|
||||
// Second call: offset 100, return 50 users (partial batch = last)
|
||||
if ($offset === 100) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch2;
|
||||
}
|
||||
// Should not be called again
|
||||
$this->fail("Unexpected getAllUsersWithTokens call with offset $offset");
|
||||
});
|
||||
|
||||
// All tokens have plenty of time (all skipped)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return [
|
||||
'access_token' => "{$userId}-token",
|
||||
'refresh_token' => "{$userId}-refresh",
|
||||
'expires_at' => $now + 3600,
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $infoCallCount = 0;
|
||||
$infoCallCount++;
|
||||
if ($infoCallCount === 2) {
|
||||
$this->assertStringContainsString('total=150', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=150', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
|
||||
// Verify getAllUsersWithTokens was called exactly twice (2 batches)
|
||||
$this->assertEquals(2, $callCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// refreshUserTokenIfNeeded() Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'valid-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining
|
||||
'issued_at' => $now,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshTriggeredWhenTokenNearExpiry(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s)
|
||||
'issued_at' => $now - 3300, // Issued 55 min ago
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenNoRefreshToken(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('no refresh token'));
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenRefresherReturnsNull(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'invalid-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('invalid-refresh')
|
||||
->willReturn(null);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('Refresh returned null'));
|
||||
|
||||
// Should NOT delete token - let on-demand refresh handle cleanup
|
||||
$this->tokenStorage->expects($this->never())
|
||||
->method('deleteUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesIssuedAtForLifetimeCalculation(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min)
|
||||
// 10/60 = 16.7% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 600, // 10 min remaining
|
||||
'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token without issued_at, uses default 3600s lifetime
|
||||
// 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 300, // 5 min remaining
|
||||
// No issued_at
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshStoresNewTokenWithIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'old-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Verify storeUserToken is called with issued_at parameter
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'new-refresh',
|
||||
$this->greaterThan($now), // expires_at = now + 3600
|
||||
$this->greaterThanOrEqual($now) // issued_at = now
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'original-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// IdP returns new access token but no new refresh token (no rotation)
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
// No refresh_token in response
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Should use the original refresh token
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'original-refresh', // Original refresh token preserved
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshHandlesException(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willThrowException(new \Exception('Network error'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Failed to refresh'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenNoToken(): void {
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn(null);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Locking Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenLockCannotBeAcquired(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100, // ~100s remaining (< 50% of default)
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// Lock acquisition fails (on-demand refresh is holding it)
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||
|
||||
// Token refresher should NOT be called when lock fails
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('Lock held for user testuser'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesLockForTokenRefresh(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->with('testuser', $this->isInstanceOf(\Closure::class))
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||
$now = time();
|
||||
|
||||
// First call (before lock): token is expiring
|
||||
// Calls inside lock callback: token is now fresh
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturnCallback(function () use (&$callCount, $now) {
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
// First check: token is expiring
|
||||
return [
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
];
|
||||
}
|
||||
// Inside lock: token was already refreshed
|
||||
return [
|
||||
'access_token' => 'already-refreshed-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_at' => $now + 3600, // Fresh token
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
// Token refresher should NOT be called since token is already fresh
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('already refreshed'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Invoke the protected run() method.
|
||||
*/
|
||||
private function invokeRun(): void {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('run');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->job, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the private refreshUserTokenIfNeeded() method.
|
||||
*/
|
||||
private function invokeRefreshUserTokenIfNeeded(string $userId): string {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('refreshUserTokenIfNeeded');
|
||||
$method->setAccessible(true);
|
||||
return $method->invoke($this->job, $userId);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Controller;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Controller\ApiController;
|
||||
use OCP\IRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ApiTest extends TestCase {
|
||||
public function testIndex(): void {
|
||||
$request = $this->createMock(IRequest::class);
|
||||
$controller = new ApiController(Application::APP_ID, $request);
|
||||
|
||||
$this->assertEquals($controller->index()->getData()['message'], 'Hello world!');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Tests\Unit\Service;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\Http\Client\IResponse;
|
||||
use OCP\IConfig;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for IdpTokenRefresher.
|
||||
*
|
||||
* Tests the internal URL resolution logic and token refresh flows.
|
||||
*/
|
||||
final class IdpTokenRefresherTest extends TestCase {
|
||||
private IConfig&MockObject $config;
|
||||
private IClientService&MockObject $clientService;
|
||||
private IClient&MockObject $httpClient;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private McpServerClient&MockObject $mcpServerClient;
|
||||
private IdpTokenRefresher $refresher;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->clientService = $this->createMock(IClientService::class);
|
||||
$this->httpClient = $this->createMock(IClient::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->mcpServerClient = $this->createMock(McpServerClient::class);
|
||||
|
||||
$this->clientService->method('newClient')->willReturn($this->httpClient);
|
||||
|
||||
$this->refresher = new IdpTokenRefresher(
|
||||
$this->config,
|
||||
$this->clientService,
|
||||
$this->logger,
|
||||
$this->mcpServerClient
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getNextcloudBaseUrl() tests
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider provideBaseUrlTestCases
|
||||
*/
|
||||
public function testGetNextcloudBaseUrl(string $configValue, string $expected): void {
|
||||
$this->config->method('getSystemValue')
|
||||
->with('astrolabe_internal_url', '')
|
||||
->willReturn($configValue);
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->refresher);
|
||||
$method = $reflection->getMethod('getNextcloudBaseUrl');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->refresher);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test cases for getNextcloudBaseUrl().
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function provideBaseUrlTestCases(): array {
|
||||
return [
|
||||
'default - no config' => ['', 'http://localhost'],
|
||||
'custom internal url' => ['http://web:8080', 'http://web:8080'],
|
||||
'custom url with trailing slash' => ['http://web:8080/', 'http://web:8080'],
|
||||
'kubernetes service' => ['http://nextcloud.default.svc:80', 'http://nextcloud.default.svc:80'],
|
||||
'https internal url' => ['https://internal.example.com', 'https://internal.example.com'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// refreshAccessToken() tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshAccessTokenFailsWithoutClientSecret(): void {
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', ''],
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('no client secret configured'));
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenFailsWithoutMcpServerUrl(): void {
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'MCP server URL not configured'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenWithInternalNextcloudOidc(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
['astrolabe_internal_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response (no external IdP configured)
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'version' => '1.0.0',
|
||||
'auth_mode' => 'multi_user_oauth',
|
||||
// No 'oidc.discovery_url' = use internal Nextcloud OIDC
|
||||
]));
|
||||
|
||||
// Mock token endpoint response
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'access_token' => 'new-access-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
'token_type' => 'Bearer',
|
||||
]));
|
||||
|
||||
// Setup HTTP client to return appropriate responses
|
||||
$this->httpClient->method('get')
|
||||
->with('http://mcp-server:8000/api/v1/status')
|
||||
->willReturn($statusResponse);
|
||||
|
||||
$this->httpClient->method('post')
|
||||
->with(
|
||||
'http://localhost/apps/oidc/token',
|
||||
$this->callback(function ($options) {
|
||||
// Verify the POST body contains expected parameters
|
||||
$body = $options['body'] ?? '';
|
||||
return str_contains($body, 'grant_type=refresh_token')
|
||||
&& str_contains($body, 'client_id=test-client-id')
|
||||
&& str_contains($body, 'client_secret=test-secret')
|
||||
&& str_contains($body, 'refresh_token=test-refresh-token');
|
||||
})
|
||||
)
|
||||
->willReturn($tokenResponse);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertEquals('new-access-token', $result['access_token']);
|
||||
$this->assertEquals('new-refresh-token', $result['refresh_token']);
|
||||
$this->assertEquals(3600, $result['expires_in']);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenWithExternalIdp(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response (external IdP configured)
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'version' => '1.0.0',
|
||||
'auth_mode' => 'multi_user_oauth',
|
||||
'oidc' => [
|
||||
'discovery_url' => 'https://keycloak.example.com/realms/test/.well-known/openid-configuration',
|
||||
],
|
||||
]));
|
||||
|
||||
// Mock OIDC discovery response
|
||||
$discoveryResponse = $this->createMock(IResponse::class);
|
||||
$discoveryResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'issuer' => 'https://keycloak.example.com/realms/test',
|
||||
'token_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/token',
|
||||
'authorization_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/auth',
|
||||
]));
|
||||
|
||||
// Mock token endpoint response
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'access_token' => 'keycloak-access-token',
|
||||
'refresh_token' => 'keycloak-refresh-token',
|
||||
'expires_in' => 300,
|
||||
'token_type' => 'Bearer',
|
||||
]));
|
||||
|
||||
// Setup HTTP client calls in order
|
||||
$this->httpClient->method('get')
|
||||
->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) {
|
||||
if (str_contains($url, 'status')) {
|
||||
return $statusResponse;
|
||||
}
|
||||
if (str_contains($url, '.well-known/openid-configuration')) {
|
||||
return $discoveryResponse;
|
||||
}
|
||||
throw new \Exception("Unexpected URL: $url");
|
||||
});
|
||||
|
||||
$this->httpClient->method('post')
|
||||
->with(
|
||||
'https://keycloak.example.com/realms/test/protocol/openid-connect/token',
|
||||
$this->anything()
|
||||
)
|
||||
->willReturn($tokenResponse);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertEquals('keycloak-access-token', $result['access_token']);
|
||||
$this->assertEquals('keycloak-refresh-token', $result['refresh_token']);
|
||||
$this->assertEquals(300, $result['expires_in']);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenFailsOnMissingRefreshTokenInResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
['astrolabe_internal_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode(['version' => '1.0.0']));
|
||||
|
||||
// Mock token response WITHOUT refresh_token (token rotation failure)
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'access_token' => 'new-access-token',
|
||||
// Missing refresh_token!
|
||||
'expires_in' => 3600,
|
||||
]));
|
||||
|
||||
$this->httpClient->method('get')->willReturn($statusResponse);
|
||||
$this->httpClient->method('post')->willReturn($tokenResponse);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('No refresh token in response'),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesHttpException(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
// HTTP client throws exception
|
||||
$this->httpClient->method('get')
|
||||
->willThrowException(new \Exception('Connection refused'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Connection refused'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesInvalidStatusResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
// Mock invalid JSON response
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn('not valid json');
|
||||
|
||||
$this->httpClient->method('get')->willReturn($statusResponse);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid status response'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesInvalidDiscoveryResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response with external IdP
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'oidc' => [
|
||||
'discovery_url' => 'https://keycloak.example.com/.well-known/openid-configuration',
|
||||
],
|
||||
]));
|
||||
|
||||
// Mock invalid discovery response (missing token_endpoint)
|
||||
$discoveryResponse = $this->createMock(IResponse::class);
|
||||
$discoveryResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'issuer' => 'https://keycloak.example.com',
|
||||
// Missing token_endpoint!
|
||||
]));
|
||||
|
||||
$this->httpClient->method('get')
|
||||
->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) {
|
||||
if (str_contains($url, 'status')) {
|
||||
return $statusResponse;
|
||||
}
|
||||
return $discoveryResponse;
|
||||
});
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid OIDC discovery response'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesInvalidTokenResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
['astrolabe_internal_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode(['version' => '1.0.0']));
|
||||
|
||||
// Mock token response without access_token
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'error' => 'invalid_grant',
|
||||
'error_description' => 'Refresh token expired',
|
||||
]));
|
||||
|
||||
$this->httpClient->method('get')->willReturn($statusResponse);
|
||||
$this->httpClient->method('post')->willReturn($tokenResponse);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid token response'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user