Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66ccacdee1 | |||
| 1a4486a388 | |||
| 1707b2e6e1 | |||
| df3cce4370 | |||
| 1c5e21843e | |||
| 520ef113ba | |||
| 3be229a487 | |||
| 6da69b0336 | |||
| 427e501691 | |||
| 9c275d1a3f | |||
| af43630ca7 | |||
| 49c5439686 | |||
| c5eec64716 | |||
| 3948f6a019 | |||
| 08d37a6597 | |||
| 4712235390 | |||
| d0f18b36e8 | |||
| aca0d236b4 | |||
| 7ab0dcd3d8 | |||
| eafef986f2 | |||
| 8126beb16e | |||
| dfc75a8619 | |||
| 254cb6cf06 | |||
| 940e7d3e4e | |||
| ac985b265e | |||
| e7f452342e | |||
| e4b5617a55 | |||
| 0e9fee5616 | |||
| 093f1d7302 | |||
| 9da5f95bcb | |||
| 1d4aede0f9 | |||
| ec8eab99f3 | |||
| da104c59ac | |||
| b3e55d444b | |||
| 1786e204ec | |||
| 0a599c5c03 | |||
| 66e32d4705 | |||
| 8603ed114e | |||
| 7e6ef90423 | |||
| c5f2c8369f | |||
| b79ac29a9d | |||
| 334d62825c | |||
| 2233cb423c | |||
| 196a6cdfb2 | |||
| 93f5e70128 | |||
| e5248e70ee | |||
| 018b946b5b | |||
| 863ba0d52a | |||
| d3903c5e2e | |||
| 6ea97c5b88 | |||
| c12c825b11 | |||
| 3d8f7692a8 | |||
| b21c874c14 | |||
| a4661099e5 | |||
| a46d74d999 | |||
| 92f69c8dba | |||
| 6692a85007 | |||
| 1f09079b5a | |||
| 2535c95f4e | |||
| 4fac0ca40d | |||
| 719a432a95 | |||
| 14c4512ef8 | |||
| 6f482c9245 | |||
| a6ad3707c6 | |||
| b34f8d96e3 | |||
| d948f51b10 | |||
| 5eb5b5023c | |||
| 504213ae79 | |||
| 5eeaafbe95 | |||
| 0ddc62c371 | |||
| 36d901d5ae | |||
| 0a3052d0d9 | |||
| 2b691f1792 | |||
| e3da2e006c | |||
| 4539f2f486 | |||
| c85ad95faf | |||
| 60f7234908 | |||
| 1dd5698389 | |||
| 3a0096f8df | |||
| 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 |
@@ -1,89 +0,0 @@
|
||||
name: Build and Publish Astrolabe App Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'astrolabe-v*'
|
||||
|
||||
env:
|
||||
APP_NAME: astrolabe
|
||||
APP_DIR: third_party/astrolabe
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: tag
|
||||
run: |
|
||||
echo "TAG=${GITHUB_REF#refs/tags/astrolabe-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate version in info.xml matches tag
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
INFO_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' appinfo/info.xml | tr -d '\t')
|
||||
if [ "$INFO_VERSION" != "${{ steps.tag.outputs.TAG }}" ]; then
|
||||
echo "Version mismatch: info.xml has $INFO_VERSION but tag is ${{ steps.tag.outputs.TAG }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version validated: $INFO_VERSION"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
coverage: none
|
||||
|
||||
- name: Checkout Nextcloud server (for signing)
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: nextcloud/server
|
||||
ref: stable30
|
||||
path: server
|
||||
|
||||
- name: Install dependencies and build
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup signing certificate
|
||||
run: |
|
||||
mkdir -p $HOME/.nextcloud/certificates
|
||||
echo "${{ secrets.APP_PRIVATE_KEY }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.key
|
||||
echo "${{ secrets.APP_PUBLIC_CRT }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.crt
|
||||
|
||||
- name: Build app store package
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: make appstore server_dir=${{ github.workspace }}/server
|
||||
|
||||
- name: Create GitHub release and attach tarball
|
||||
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ env.APP_DIR }}/build/artifacts/${{ env.APP_NAME }}.tar.gz
|
||||
asset_name: ${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
tag: ${{ github.ref }}
|
||||
release_name: Astrolabe ${{ steps.tag.outputs.TAG }}
|
||||
prerelease: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
|
||||
- name: Upload to Nextcloud App Store
|
||||
uses: R0Wi/nextcloud-appstore-push-action@9244bb5445776688cfe90fa1903ea8dff95b0c28 # v1.0.4
|
||||
with:
|
||||
app_name: ${{ env.APP_NAME }}
|
||||
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
@@ -1,323 +0,0 @@
|
||||
# Consolidated CI workflow for Astrolabe Nextcloud app
|
||||
#
|
||||
# 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: Astrolabe CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'third_party/astrolabe/**'
|
||||
- '.github/workflows/astrolabe-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: astrolabe-ci-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
php: ${{ steps.changes.outputs.php }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
frontend:
|
||||
- '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/astrolabe/lib/**'
|
||||
- 'third_party/astrolabe/appinfo/**'
|
||||
- 'third_party/astrolabe/composer.json'
|
||||
- 'third_party/astrolabe/psalm.xml'
|
||||
|
||||
# Node.js build and lint
|
||||
node-build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Node.js build
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astrolabe
|
||||
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' && exit 1)"
|
||||
|
||||
# ESLint
|
||||
eslint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: ESLint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astrolabe
|
||||
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
|
||||
|
||||
# Stylelint
|
||||
stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Stylelint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astrolabe
|
||||
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 stylelint
|
||||
|
||||
# PHP Code Style
|
||||
php-cs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: PHP CS Fixer
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- 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 || true
|
||||
composer i
|
||||
|
||||
- name: Lint
|
||||
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
|
||||
|
||||
# Psalm Static Analysis
|
||||
psalm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: Psalm
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- 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 || 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 static analysis
|
||||
run: |
|
||||
# Get first OCP version from matrix
|
||||
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 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, phpunit]
|
||||
if: always()
|
||||
name: astrolabe-ci-summary
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: |
|
||||
if ${{ needs.changes.outputs.frontend != 'false' && (needs.node-build.result != 'success' || needs.eslint.result != 'success' || needs.stylelint.result != 'success') }}; then
|
||||
echo "Frontend checks failed"
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
echo "All checks passed"
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
# Bump MCP server (default - all commits except helm/astrolabe scopes)
|
||||
# Bump MCP server (default - all commits except helm scope)
|
||||
echo "Checking MCP server for version bump..."
|
||||
|
||||
# Get the most recent MCP tag
|
||||
@@ -83,9 +83,9 @@ jobs:
|
||||
commit_range="${last_mcp_tag}..HEAD"
|
||||
fi
|
||||
|
||||
# Count conventional commits that are NOT scoped to helm or astrolabe
|
||||
# Count conventional commits that are NOT scoped to helm
|
||||
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)
|
||||
{ grep -v "(helm)" || true; } | wc -l)
|
||||
|
||||
MCP_BUMPED=false
|
||||
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||
@@ -115,14 +115,6 @@ jobs:
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
fi
|
||||
|
||||
# Bump Astrolabe (scope: astrolabe)
|
||||
echo "Checking Astrolabe for version bump..."
|
||||
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
|
||||
echo "Bumping Astrolabe version..."
|
||||
./scripts/bump-astrolabe.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
|
||||
fi
|
||||
|
||||
# Output summary
|
||||
if [ -z "$BUMPED_COMPONENTS" ]; then
|
||||
echo "No components required version bumps"
|
||||
@@ -158,10 +150,6 @@ jobs:
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
astrolabe)
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
|
||||
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
||||
uses: anthropics/claude-code-action@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "renovate-bot-cbcoutinho"
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
||||
uses: anthropics/claude-code-action@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
|
||||
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@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
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@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.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@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.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@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -48,32 +48,15 @@ jobs:
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
|
||||
###### Required to build Astrolabe App ######
|
||||
|
||||
- name: Set up Node.js for Astrolabe
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build Astrolabe app
|
||||
run: |
|
||||
cd third_party/astrolabe
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
###### Required to build Astrolabe App ######
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
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@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
@@ -4,3 +4,6 @@
|
||||
[submodule "third_party/notes"]
|
||||
path = third_party/notes
|
||||
url = https://github.com/cbcoutinho/notes
|
||||
[submodule "third_party/astrolabe"]
|
||||
path = third_party/astrolabe
|
||||
url = https://github.com/cbcoutinho/astrolabe
|
||||
|
||||
@@ -5,6 +5,50 @@ 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.5 (2026-02-16)
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove stale astrolabe references from commitizen config
|
||||
- extract Astrolabe to separate repository
|
||||
|
||||
## v0.63.4 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- strip whitespace from category names when splitting
|
||||
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||
|
||||
## v0.63.3 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- expand recurring events in date-range queries
|
||||
|
||||
## v0.63.2 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## 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
|
||||
|
||||
+3
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
## Version Management
|
||||
|
||||
This monorepo uses commitizen for version management with **independent versioning** for three components:
|
||||
This monorepo uses commitizen for version management with **independent versioning** for two components:
|
||||
|
||||
### Components
|
||||
|
||||
@@ -10,7 +10,8 @@ This monorepo uses commitizen for version management with **independent versioni
|
||||
|-----------|-------|--------------|-------------|
|
||||
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
||||
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
||||
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
|
||||
|
||||
> **Note:** The Astrolabe Nextcloud app has been moved to its own repository at [cbcoutinho/astrolabe](https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
@@ -24,10 +25,6 @@ fix(mcp): resolve authentication bug
|
||||
# Helm chart changes
|
||||
feat(helm): add resource limits
|
||||
docs(helm): update values documentation
|
||||
|
||||
# Astrolabe app changes
|
||||
feat(astrolabe): add dark mode toggle
|
||||
fix(astrolabe): resolve search UI bug
|
||||
```
|
||||
|
||||
**Unscoped commits** default to the MCP server:
|
||||
@@ -40,7 +37,6 @@ feat: add new feature # → MCP server (v0.54.0)
|
||||
#### 1. Make Changes with Scoped Commits
|
||||
|
||||
```bash
|
||||
git commit -m "feat(astrolabe): add dark mode toggle"
|
||||
git commit -m "feat(helm): add ingress annotations"
|
||||
git commit -m "feat(mcp): add calendar sync"
|
||||
```
|
||||
@@ -58,10 +54,6 @@ git commit -m "feat(mcp): add calendar sync"
|
||||
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||
# → Updates: Chart.yaml:version
|
||||
|
||||
# Bump Astrolabe (reads commits with scope=astrolabe)
|
||||
./scripts/bump-astrolabe.sh
|
||||
# → Creates tag: astrolabe-v0.2.0
|
||||
# → Updates: info.xml, package.json
|
||||
```
|
||||
|
||||
#### 3. Push Tags
|
||||
@@ -76,7 +68,6 @@ Each component maintains its own `CHANGELOG.md`:
|
||||
|
||||
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
||||
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
|
||||
|
||||
### Manual Version Bumps
|
||||
|
||||
@@ -101,7 +92,6 @@ uv run cz --config .cz.toml bump --increment MINOR
|
||||
|
||||
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
||||
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
||||
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
|
||||
|
||||
### Chart.yaml Version vs appVersion
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /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:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -2,35 +2,17 @@
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing Astrolabe app for testing..."
|
||||
echo "Installing Astrolabe app from app store..."
|
||||
|
||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||
if [ -d /opt/apps/astrolabe ]; then
|
||||
echo "Development astrolabe app found at /opt/apps/astrolabe"
|
||||
|
||||
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
|
||||
if [ -e /var/www/html/custom_apps/astrolabe ]; then
|
||||
echo "Removing existing astrolabe in custom_apps..."
|
||||
rm -rf /var/www/html/custom_apps/astrolabe
|
||||
fi
|
||||
|
||||
# Create symlink from custom_apps to the mounted development version
|
||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
|
||||
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
|
||||
|
||||
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
|
||||
if [ -d /var/www/html/custom_apps/astrolabe ]; then
|
||||
echo "astrolabe app directory found in custom_apps (already installed)"
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
else
|
||||
echo "astrolabe app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install astrolabe
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
fi
|
||||
|
||||
echo "✓ Astrolabe app installed successfully"
|
||||
echo "Astrolabe app installed successfully"
|
||||
echo ""
|
||||
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||
echo " to support testing multiple MCP server deployments."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.57.15"
|
||||
version = "0.57.49"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,106 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## nextcloud-mcp-server-0.57.49 (2026-02-16)
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove stale astrolabe references from commitizen config
|
||||
- extract Astrolabe to separate repository
|
||||
|
||||
## nextcloud-mcp-server-0.57.48 (2026-02-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.47 (2026-02-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.46 (2026-02-12)
|
||||
|
||||
## nextcloud-mcp-server-0.57.45 (2026-02-12)
|
||||
|
||||
## nextcloud-mcp-server-0.57.44 (2026-02-11)
|
||||
|
||||
## nextcloud-mcp-server-0.57.43 (2026-02-11)
|
||||
|
||||
## nextcloud-mcp-server-0.57.42 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- strip whitespace from category names when splitting
|
||||
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||
|
||||
## nextcloud-mcp-server-0.57.41 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- expand recurring events in date-range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.40 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.37 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.36 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.35 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.34 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.33 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.32 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.31 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.30 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.29 (2026-02-04)
|
||||
|
||||
## nextcloud-mcp-server-0.57.28 (2026-02-03)
|
||||
|
||||
## nextcloud-mcp-server-0.57.27 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## 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
|
||||
|
||||
@@ -4,6 +4,6 @@ dependencies:
|
||||
version: 1.16.3
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.38.0
|
||||
digest: sha256:60b09d52759c84f8add5782c867f5a373aa6eb2477dc9380bef0134183c4b1ae
|
||||
generated: "2026-01-20T11:11:57.230612063Z"
|
||||
version: 1.42.0
|
||||
digest: sha256:a9aef6e290f23b1ed961450e0635eb0bce42f8c52805276901a80df7c27473f6
|
||||
generated: "2026-02-10T11:10:44.457881902Z"
|
||||
|
||||
@@ -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.57.15
|
||||
appVersion: "0.62.0"
|
||||
version: 0.57.49
|
||||
appVersion: "0.63.5"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -31,6 +31,6 @@ dependencies:
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.38.0"
|
||||
version: "1.42.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)
|
||||
|
||||
+4
-5
@@ -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.5@sha256:11a3a4f63bad8813c7455b4a3c473ccd1c41e2c48f55decb51718f15691e7568
|
||||
image: docker.io/library/nextcloud:32.0.6@sha256:0e1084cc59df77bec7d6bb29d9ac6939da8372512237a9c51f74ff0a970524f2
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
@@ -37,7 +37,6 @@ services:
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
#- ./third_party:/opt/apps:ro
|
||||
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -54,14 +53,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:66d420cc54ef85bcc1d72220e83d7aaa6c4850bd2904794e3a56f09fd4ccb66e
|
||||
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
|
||||
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:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
|
||||
@@ -356,45 +356,6 @@ 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
|
||||
|
||||
+1
-46
@@ -225,52 +225,7 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
### 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
|
||||
For Astrolabe-specific user setup instructions in hybrid mode, see the [Astrolabe documentation](https://github.com/cbcoutinho/astrolabe/blob/master/docs/user-setup-hybrid-mode.md).
|
||||
|
||||
### See Also
|
||||
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
||||
|
||||
+52
-22
@@ -171,6 +171,58 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
|
||||
|
||||
---
|
||||
|
||||
## SSL/TLS Configuration (Optional)
|
||||
|
||||
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
|
||||
|
||||
### Custom CA Bundle (Recommended)
|
||||
|
||||
Point the server at your CA certificate file:
|
||||
|
||||
```dotenv
|
||||
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||
```
|
||||
|
||||
With Docker, mount the certificate as a read-only volume:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
|
||||
-e NEXTCLOUD_HOST=https://nextcloud.local \
|
||||
--env-file .env \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
### Disable Verification (Development Only)
|
||||
|
||||
> [!WARNING]
|
||||
> Disabling TLS verification is insecure. Only use this for local development or testing.
|
||||
|
||||
```dotenv
|
||||
NEXTCLOUD_VERIFY_SSL=false
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
|
||||
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
|
||||
|
||||
### Scope
|
||||
|
||||
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
|
||||
|
||||
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
|
||||
- OIDC discovery and token endpoints
|
||||
- OAuth client registration (DCR)
|
||||
- Health checks
|
||||
|
||||
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
|
||||
|
||||
---
|
||||
|
||||
## Semantic Search Configuration (Optional)
|
||||
|
||||
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
||||
@@ -531,28 +583,6 @@ 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:
|
||||
|
||||
@@ -11,7 +11,7 @@ This guide explains how to enable and disable webhooks for vector sync in each M
|
||||
Before enabling webhooks, ensure:
|
||||
|
||||
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
||||
2. **Astrolabe app** installed in Nextcloud (provides settings UI and credentials API)
|
||||
2. **[Astrolabe app](https://github.com/cbcoutinho/astrolabe)** 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
|
||||
|
||||
@@ -261,24 +261,6 @@ php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "
|
||||
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
|
||||
@@ -327,7 +309,7 @@ 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
|
||||
-- Check user credentials stored by Astrolabe app
|
||||
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||
```
|
||||
|
||||
|
||||
+13
@@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD=
|
||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||
|
||||
# ===== SSL/TLS =====
|
||||
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
|
||||
#
|
||||
# Disable TLS certificate verification (insecure, development only):
|
||||
#NEXTCLOUD_VERIFY_SSL=false
|
||||
#
|
||||
# Use a custom CA bundle (path to PEM file):
|
||||
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||
#
|
||||
# Docker example: mount the CA bundle as a volume
|
||||
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
|
||||
|
||||
# ===== SECURITY & ADVANCED =====
|
||||
# Cookie security (browser UI)
|
||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||
|
||||
@@ -21,6 +21,8 @@ import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
@@ -252,7 +254,9 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
|
||||
# Validate app password against Nextcloud
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
async with nextcloud_httpx_client(
|
||||
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(
|
||||
@@ -380,7 +384,9 @@ async def delete_app_password(request: Request) -> JSONResponse:
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
async with nextcloud_httpx_client(
|
||||
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
|
||||
) as client:
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
|
||||
@@ -10,7 +10,6 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@@ -20,6 +19,8 @@ from nextcloud_mcp_server.api.management import (
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -57,7 +58,7 @@ async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
@@ -129,7 +130,7 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
@@ -210,7 +211,7 @@ async def create_webhook(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
@@ -286,7 +287,7 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
|
||||
@@ -58,6 +58,7 @@ from nextcloud_mcp_server.config_validators import (
|
||||
)
|
||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
from nextcloud_mcp_server.document_processors import get_registry
|
||||
from nextcloud_mcp_server.http import nextcloud_httpx_client
|
||||
from nextcloud_mcp_server.observability import (
|
||||
ObservabilityMiddleware,
|
||||
setup_metrics,
|
||||
@@ -690,7 +691,7 @@ async def setup_oauth_config():
|
||||
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||
|
||||
# Perform OIDC discovery
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
async with nextcloud_httpx_client(follow_redirects=True) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -994,7 +995,7 @@ async def setup_oauth_config_for_multi_user_basic(
|
||||
|
||||
# Perform OIDC discovery
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
timeout=30.0, follow_redirects=True
|
||||
) as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
@@ -1975,7 +1976,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Try to connect to Nextcloud
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=2.0) as client:
|
||||
response = await client.get(f"{nextcloud_host}/status.php")
|
||||
duration = time.time() - start_time
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,7 +60,7 @@ class AstrolabeClient:
|
||||
# Discover token endpoint
|
||||
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||
discovery_resp = await client.get(discovery_url)
|
||||
discovery_resp.raise_for_status()
|
||||
@@ -107,7 +107,7 @@ class AstrolabeClient:
|
||||
token = await self.get_access_token()
|
||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||
|
||||
response = await client.get(
|
||||
|
||||
@@ -22,6 +22,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_query_idp_userinfo,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -142,7 +144,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -286,7 +288,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(
|
||||
oauth_client.token_endpoint,
|
||||
data=token_params,
|
||||
@@ -296,7 +298,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -314,7 +316,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data=token_params,
|
||||
|
||||
@@ -10,6 +10,8 @@ import httpx
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,7 +134,7 @@ async def register_client(
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
@@ -229,7 +231,7 @@ async def delete_client(
|
||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
async with nextcloud_httpx_client(timeout=30.0) as http_client:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Prefer RFC 7592 Bearer token authentication
|
||||
|
||||
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -107,7 +109,7 @@ class KeycloakOAuthClient:
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client"""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||
self._http_client = nextcloud_httpx_client(timeout=30.0)
|
||||
return self._http_client
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
@@ -27,7 +27,6 @@ import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
@@ -35,6 +34,8 @@ from starlette.responses import JSONResponse, RedirectResponse
|
||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -218,7 +219,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint from discovery
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud(
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -462,7 +463,7 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -482,7 +483,7 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data=token_params,
|
||||
|
||||
@@ -25,6 +25,8 @@ import jwt
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -136,7 +138,7 @@ class TokenBrokerService:
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
self._http_client = nextcloud_httpx_client(
|
||||
timeout=httpx.Timeout(30.0), follow_redirects=True
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
@@ -20,6 +20,7 @@ import httpx
|
||||
import jwt
|
||||
|
||||
from ..config import get_settings
|
||||
from ..http import nextcloud_httpx_client
|
||||
from .storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,7 +69,7 @@ class TokenExchangeService:
|
||||
self.storage: Optional[RefreshTokenStorage] = None
|
||||
|
||||
# Create HTTP client
|
||||
self.http_client = httpx.AsyncClient(
|
||||
self.http_client = nextcloud_httpx_client(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
record_oauth_token_validation,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
||||
|
||||
# Common components for all modes
|
||||
self.http_client = httpx.AsyncClient(timeout=10.0)
|
||||
self.http_client = nextcloud_httpx_client(timeout=10.0)
|
||||
|
||||
# JWT verification support
|
||||
self.jwks_client: PyJWKClient | None = None
|
||||
|
||||
@@ -13,7 +13,6 @@ import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
@@ -22,6 +21,8 @@ from starlette.responses import HTMLResponse, JSONResponse
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Setup Jinja2 environment for templates
|
||||
@@ -257,7 +258,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -290,7 +291,7 @@ async def _query_idp_userinfo(
|
||||
User info dictionary from IdP, or None if query fails
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||
|
||||
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
|
||||
get_preset,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
|
||||
assert nextcloud_host is not None # Type narrowing for type checker
|
||||
assert username is not None and password is not None # Type narrowing
|
||||
return httpx.AsyncClient(
|
||||
return nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
timeout=30.0,
|
||||
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
if not nextcloud_host:
|
||||
raise RuntimeError("Nextcloud host not configured")
|
||||
|
||||
return httpx.AsyncClient(
|
||||
return nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
from httpx import (
|
||||
AsyncBaseTransport,
|
||||
AsyncClient,
|
||||
AsyncHTTPTransport,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
@@ -13,6 +12,7 @@ from httpx import (
|
||||
)
|
||||
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from ..http import nextcloud_httpx_transport
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
@@ -67,7 +67,7 @@ class NextcloudClient:
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
timeout=Timeout(timeout=30, connect=5),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,8 @@ from icalendar import Alarm, Calendar, vRecur
|
||||
from icalendar import Event as ICalEvent
|
||||
from icalendar import Todo as ICalTodo
|
||||
|
||||
from ..config import get_nextcloud_ssl_verify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +36,7 @@ class CalendarClient:
|
||||
url=f"{base_url}/remote.php/dav/",
|
||||
username=username,
|
||||
auth=auth,
|
||||
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
|
||||
)
|
||||
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
||||
|
||||
@@ -255,18 +258,35 @@ class CalendarClient:
|
||||
"""List events in a calendar within date range."""
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
# Get all events using caldav library (now with proper filter)
|
||||
events = await calendar.events()
|
||||
if start_datetime or end_datetime:
|
||||
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||
events = await self._search_events_by_date(
|
||||
calendar, start_datetime, end_datetime
|
||||
)
|
||||
# Expand is only used when both bounds are provided
|
||||
expanded = bool(start_datetime and end_datetime)
|
||||
else:
|
||||
# No date filter — fetch all events
|
||||
events = await calendar.events()
|
||||
expanded = False
|
||||
|
||||
result = []
|
||||
for event in events:
|
||||
await event.load(only_if_unloaded=True)
|
||||
if event.data:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
if expanded:
|
||||
# Server-side expansion: each response resource may contain
|
||||
# multiple VEVENTs (one per recurrence occurrence)
|
||||
for event_dict in self._parse_all_ical_events(event.data):
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
else:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
|
||||
if len(result) >= limit:
|
||||
break
|
||||
@@ -274,6 +294,57 @@ class CalendarClient:
|
||||
logger.debug(f"Found {len(result)} events")
|
||||
return result
|
||||
|
||||
async def _search_events_by_date(
|
||||
self,
|
||||
calendar: AsyncCalendar,
|
||||
start_datetime: Optional[dt.datetime] = None,
|
||||
end_datetime: Optional[dt.datetime] = None,
|
||||
) -> list:
|
||||
"""Execute a CalDAV REPORT with time-range filter."""
|
||||
from caldav.async_collection import AsyncEvent
|
||||
from caldav.elements import cdav, dav
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
# Ensure naive datetimes are treated as UTC
|
||||
if start_datetime and start_datetime.tzinfo is None:
|
||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||
if end_datetime and end_datetime.tzinfo is None:
|
||||
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
|
||||
|
||||
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
|
||||
inner_comp_filter = cdav.CompFilter(name="VEVENT")
|
||||
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
|
||||
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||
filter_element = cdav.Filter() + outer_comp_filter
|
||||
|
||||
# When both bounds are provided, request server-side expansion of
|
||||
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
|
||||
# a separate VEVENT with its own DTSTART, with RRULE stripped.
|
||||
data = cdav.CalendarData()
|
||||
if start_datetime and end_datetime:
|
||||
data += cdav.Expand(start_datetime, end_datetime)
|
||||
|
||||
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||
|
||||
body = etree.tostring(
|
||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
assert calendar.client is not None
|
||||
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||
|
||||
# Parse response (same pattern as AsyncCalendar.search)
|
||||
objects = []
|
||||
response_data = response.expand_simple_props([cdav.CalendarData()])
|
||||
for href, props in response_data.items():
|
||||
if href == str(calendar.url):
|
||||
continue
|
||||
cal_data = props.get(cdav.CalendarData.tag)
|
||||
if cal_data:
|
||||
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||
objects.append(obj)
|
||||
|
||||
return objects
|
||||
|
||||
async def create_event(
|
||||
self, calendar_name: str, event_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -583,7 +654,7 @@ class CalendarClient:
|
||||
# Add categories
|
||||
categories = event_data.get("categories", "")
|
||||
if categories:
|
||||
event.add("categories", categories.split(","))
|
||||
event.add("categories", [c.strip() for c in categories.split(",")])
|
||||
|
||||
# Add priority and status
|
||||
priority = event_data.get("priority", 5)
|
||||
@@ -633,75 +704,92 @@ class CalendarClient:
|
||||
cal.add_component(event)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
def _extract_vevent_data(self, component) -> Dict[str, Any]:
|
||||
"""Extract event data from a single VEVENT component.
|
||||
|
||||
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
||||
"""
|
||||
event_data: Dict[str, Any] = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract event data."""
|
||||
"""Parse iCalendar text and extract the first event."""
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_data = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(
|
||||
dtend.dt, dt.datetime
|
||||
):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(
|
||||
str(a).replace("mailto:", "") for a in attendee
|
||||
)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
return self._extract_vevent_data(component)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar event: {e}")
|
||||
return None
|
||||
|
||||
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract ALL event occurrences.
|
||||
|
||||
Used with server-side expansion where a single VCALENDAR contains
|
||||
multiple VEVENT components (one per recurrence occurrence).
|
||||
"""
|
||||
results: list[Dict[str, Any]] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
results.append(self._extract_vevent_data(component))
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar events: {e}")
|
||||
return results
|
||||
|
||||
def _merge_ical_properties(
|
||||
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
||||
) -> str:
|
||||
@@ -727,6 +815,50 @@ class CalendarClient:
|
||||
if "url" in event_data:
|
||||
component["URL"] = event_data["url"]
|
||||
|
||||
# Handle categories
|
||||
if "categories" in event_data:
|
||||
categories_str = event_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
elif "CATEGORIES" in component:
|
||||
del component["CATEGORIES"]
|
||||
|
||||
# Handle recurrence rule
|
||||
if "recurrence_rule" in event_data:
|
||||
rrule_str = event_data["recurrence_rule"]
|
||||
if rrule_str:
|
||||
component["RRULE"] = vRecur.from_ical(rrule_str)
|
||||
elif "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
|
||||
# Handle attendees
|
||||
if "attendees" in event_data:
|
||||
attendees_str = event_data["attendees"]
|
||||
# Remove all existing attendees first
|
||||
while "ATTENDEE" in component:
|
||||
del component["ATTENDEE"]
|
||||
if attendees_str:
|
||||
for email in attendees_str.split(","):
|
||||
if email.strip():
|
||||
component.add("attendee", f"mailto:{email.strip()}")
|
||||
|
||||
# Handle reminder (VALARM)
|
||||
if "reminder_minutes" in event_data:
|
||||
component.subcomponents = [
|
||||
sub
|
||||
for sub in component.subcomponents
|
||||
if sub.name != "VALARM"
|
||||
]
|
||||
minutes = event_data["reminder_minutes"]
|
||||
if minutes > 0:
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", "Event reminder")
|
||||
alarm.add("trigger", dt.timedelta(minutes=-minutes))
|
||||
component.add_component(alarm)
|
||||
|
||||
# Handle dates
|
||||
if "start_datetime" in event_data:
|
||||
start_str = event_data["start_datetime"]
|
||||
@@ -960,7 +1092,9 @@ class CalendarClient:
|
||||
if "categories" in todo_data:
|
||||
categories_str = todo_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = categories_str.split(",")
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
logger.debug(f"Set CATEGORIES to {categories_str}")
|
||||
|
||||
# Update timestamps
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import logging.config
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
@@ -181,6 +182,10 @@ class Settings:
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl: bool = True
|
||||
nextcloud_ca_bundle: Optional[str] = None
|
||||
|
||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||
@@ -252,9 +257,25 @@ class Settings:
|
||||
log_include_trace_context: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate Qdrant configuration and set defaults."""
|
||||
"""Validate configuration and set defaults."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Validate SSL/TLS configuration
|
||||
if not self.nextcloud_verify_ssl:
|
||||
logger.warning(
|
||||
"NEXTCLOUD_VERIFY_SSL is disabled. "
|
||||
"TLS certificate verification is turned off for all Nextcloud connections. "
|
||||
"This is insecure and should only be used for development/testing."
|
||||
)
|
||||
if self.nextcloud_ca_bundle:
|
||||
import os as _os
|
||||
|
||||
if not _os.path.isfile(self.nextcloud_ca_bundle):
|
||||
raise ValueError(
|
||||
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
|
||||
)
|
||||
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
|
||||
|
||||
# Ensure mutual exclusivity
|
||||
if self.qdrant_url and self.qdrant_location:
|
||||
raise ValueError(
|
||||
@@ -504,6 +525,11 @@ def get_settings() -> Settings:
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl=(
|
||||
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||
),
|
||||
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
|
||||
# ADR-005: Token Audience Validation
|
||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
||||
@@ -569,3 +595,20 @@ def get_settings() -> Settings:
|
||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
||||
== "true",
|
||||
)
|
||||
|
||||
|
||||
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
|
||||
"""Return the SSL verification setting for Nextcloud connections.
|
||||
|
||||
Returns:
|
||||
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
|
||||
- ssl.SSLContext if NEXTCLOUD_CA_BUNDLE is set (custom CA)
|
||||
- True otherwise (default system CA verification)
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.nextcloud_verify_ssl:
|
||||
return False
|
||||
if settings.nextcloud_ca_bundle:
|
||||
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
|
||||
return ctx
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Centralized HTTP client factory for Nextcloud connections.
|
||||
|
||||
All outbound connections to Nextcloud (API calls, OIDC endpoints) should use
|
||||
these factories to ensure consistent SSL/TLS configuration from environment
|
||||
variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_nextcloud_ssl_verify
|
||||
|
||||
|
||||
def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
"""Create an httpx.AsyncClient with Nextcloud SSL settings applied.
|
||||
|
||||
Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment
|
||||
via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg
|
||||
takes precedence if explicitly provided.
|
||||
|
||||
Args:
|
||||
**kwargs: Forwarded to ``httpx.AsyncClient()``.
|
||||
|
||||
Returns:
|
||||
Configured ``httpx.AsyncClient``.
|
||||
"""
|
||||
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
|
||||
def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport:
|
||||
"""Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied.
|
||||
|
||||
Used by ``NextcloudClient`` which wraps the transport in
|
||||
``AsyncDisableCookieTransport``.
|
||||
|
||||
Args:
|
||||
**kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``.
|
||||
|
||||
Returns:
|
||||
Configured ``httpx.AsyncHTTPTransport``.
|
||||
"""
|
||||
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||
return httpx.AsyncHTTPTransport(**kwargs)
|
||||
@@ -11,7 +11,6 @@ import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
@@ -24,6 +23,8 @@ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -69,7 +70,7 @@ async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"OIDC_DISCOVERY_URI",
|
||||
"http://localhost:8080/.well-known/openid-configuration",
|
||||
)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.62.0"
|
||||
version = "0.63.5"
|
||||
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"}
|
||||
@@ -98,13 +98,12 @@ version_files = [
|
||||
# Ignore tags from other components
|
||||
ignored_tag_formats = [
|
||||
"nextcloud-mcp-server-*", # Helm chart tags
|
||||
"astrolabe-v*", # Astrolabe tags
|
||||
]
|
||||
|
||||
# Filter commits by scope (all scopes except helm and astrolabe)
|
||||
# Filter commits by scope (all scopes except helm)
|
||||
[tool.commitizen.customize]
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["I"]
|
||||
@@ -114,7 +113,7 @@ caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx
|
||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||
requires = ["uv_build>=0.10.0,<0.11.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Bump Astrolabe app version
|
||||
set -euo pipefail
|
||||
|
||||
# Parse optional --increment flag
|
||||
INCREMENT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--increment)
|
||||
INCREMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "❌ Error: Unknown option: $1" >&2
|
||||
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate dependencies
|
||||
command -v uv >/dev/null 2>&1 || {
|
||||
echo "❌ Error: uv not found" >&2
|
||||
echo " Install from https://docs.astral.sh/uv/" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate Astrolabe directory exists
|
||||
if [ ! -d "third_party/astrolabe" ]; then
|
||||
echo "❌ Error: Must run from repository root (third_party/astrolabe not found)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd third_party/astrolabe
|
||||
|
||||
# Validate required files exist
|
||||
if [ ! -f "appinfo/info.xml" ]; then
|
||||
echo "❌ Error: appinfo/info.xml not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo "❌ Error: package.json not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bumping Astrolabe version..."
|
||||
if [ -n "$INCREMENT" ]; then
|
||||
echo " Forcing $INCREMENT bump"
|
||||
fi
|
||||
|
||||
# Build commitizen command
|
||||
CZ_CMD="uv run cz --config .cz.toml bump --yes"
|
||||
if [ -n "$INCREMENT" ]; then
|
||||
CZ_CMD="$CZ_CMD --increment $INCREMENT"
|
||||
fi
|
||||
|
||||
# Run commitizen bump and capture output
|
||||
if ! output=$($CZ_CMD 2>&1); then
|
||||
cd ../..
|
||||
|
||||
# Check if this is the expected "no commits to bump" case
|
||||
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
|
||||
echo "ℹ️ No commits eligible for version bump" >&2
|
||||
echo "$output" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise, this is an actual error
|
||||
echo "❌ Error: Version bump failed" >&2
|
||||
echo "$output" >&2
|
||||
echo "" >&2
|
||||
echo "Common causes:" >&2
|
||||
echo " - No commits with scope 'astrolabe' since last version" >&2
|
||||
echo " - No conventional commits found (use feat(astrolabe):, fix(astrolabe):, etc.)" >&2
|
||||
echo " - Git working directory not clean" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$output"
|
||||
echo ""
|
||||
echo "✓ Astrolabe version bumped successfully"
|
||||
echo " Updated: appinfo/info.xml, package.json"
|
||||
echo " Tag format: astrolabe-v\${version}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " cd ../.."
|
||||
echo " git push --follow-tags"
|
||||
|
||||
cd ../..
|
||||
@@ -273,6 +273,86 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
raise
|
||||
|
||||
|
||||
async def test_update_event_extended_fields(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Extended Fields Update Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Base event for extended-field update test",
|
||||
}
|
||||
|
||||
event_uid = None
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created base event for extended fields test: {event_uid}")
|
||||
|
||||
# --- Phase 1: Set all four extended fields ---
|
||||
updated_data = {
|
||||
"categories": "work,meeting",
|
||||
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||
"attendees": "alice@example.com,bob@example.com",
|
||||
"reminder_minutes": 15,
|
||||
}
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, updated_data)
|
||||
|
||||
retrieved, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Verify categories
|
||||
assert "work" in retrieved.get("categories", "")
|
||||
assert "meeting" in retrieved.get("categories", "")
|
||||
|
||||
# Verify recurrence rule
|
||||
assert retrieved.get("recurring") is True
|
||||
assert "WEEKLY" in retrieved.get("recurrence_rule", "")
|
||||
|
||||
# Verify attendees
|
||||
attendees = retrieved.get("attendees", "")
|
||||
assert "alice@example.com" in attendees
|
||||
assert "bob@example.com" in attendees
|
||||
|
||||
logger.info("Phase 1 passed: all extended fields set correctly")
|
||||
|
||||
# --- Phase 2: Clear all four extended fields ---
|
||||
cleared_data = {
|
||||
"categories": "",
|
||||
"recurrence_rule": "",
|
||||
"attendees": "",
|
||||
"reminder_minutes": 0,
|
||||
}
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, cleared_data)
|
||||
|
||||
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Verify categories cleared
|
||||
assert not cleared.get("categories")
|
||||
|
||||
# Verify recurrence cleared
|
||||
assert cleared.get("recurring") is not True
|
||||
assert not cleared.get("recurrence_rule")
|
||||
|
||||
# Verify attendees cleared
|
||||
assert not cleared.get("attendees")
|
||||
|
||||
logger.info("Phase 2 passed: all extended fields cleared correctly")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Extended fields update test failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_create_event_with_attendees(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
@@ -380,6 +460,177 @@ async def test_event_with_url_and_categories(
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_date_range_filtering(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that date range filtering actually excludes events outside the range.
|
||||
|
||||
Reproduces GH-538: get_calendar_events() accepted date range parameters
|
||||
but returned events from the entire calendar history, ignoring date filters.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
past_uid = None
|
||||
future_uid = None
|
||||
|
||||
try:
|
||||
# Create Event A: 30 days in the past
|
||||
past_date = datetime.now() - timedelta(days=30)
|
||||
past_event_data = {
|
||||
"title": f"Past Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Event in the past for date range test",
|
||||
}
|
||||
result_past = await nc_client.calendar.create_event(
|
||||
calendar_name, past_event_data
|
||||
)
|
||||
past_uid = result_past["uid"]
|
||||
logger.info(f"Created past event: {past_uid}")
|
||||
|
||||
# Create Event B: 1 day in the future
|
||||
future_date = datetime.now() + timedelta(days=1)
|
||||
future_event_data = {
|
||||
"title": f"Future Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Event in the future for date range test",
|
||||
}
|
||||
result_future = await nc_client.calendar.create_event(
|
||||
calendar_name, future_event_data
|
||||
)
|
||||
future_uid = result_future["uid"]
|
||||
logger.info(f"Created future event: {future_uid}")
|
||||
|
||||
# Query with date range: today → 7 days ahead
|
||||
now = datetime.now()
|
||||
week_ahead = now + timedelta(days=7)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=week_ahead,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
event_uids = [e["uid"] for e in events]
|
||||
|
||||
# Future event (tomorrow) SHOULD be in results
|
||||
assert future_uid in event_uids, (
|
||||
f"Future event {future_uid} should be in date-filtered results"
|
||||
)
|
||||
|
||||
# Past event (30 days ago) should NOT be in results
|
||||
assert past_uid not in event_uids, (
|
||||
f"Past event {past_uid} should be excluded by date range filter "
|
||||
f"(GH-538: date range was being ignored)"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Date range filtering works: {len(events)} events returned, "
|
||||
f"past event correctly excluded"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup both events
|
||||
for uid in [past_uid, future_uid]:
|
||||
if uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||
|
||||
|
||||
async def test_recurring_event_date_range_expansion(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that recurring events are expanded into individual occurrences.
|
||||
|
||||
When querying with a date range, a recurring event should return one
|
||||
event dict per occurrence within the range, each with the correct
|
||||
start_datetime for that occurrence (not the original master event date).
|
||||
|
||||
This is a follow-up to GH-538: the time-range filter correctly selected
|
||||
recurring events, but returned the master event with its original DTSTART
|
||||
instead of expanding occurrences.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
event_uid = None
|
||||
|
||||
try:
|
||||
# Create a daily recurring event starting 7 days ago
|
||||
start = datetime.now() - timedelta(days=7)
|
||||
event_data = {
|
||||
"title": f"Daily Recurrence {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": start.strftime("%Y-%m-%dT09:00:00"),
|
||||
"end_datetime": start.strftime("%Y-%m-%dT10:00:00"),
|
||||
"description": "Daily recurring event for expansion test",
|
||||
"recurring": True,
|
||||
"recurrence_rule": "FREQ=DAILY",
|
||||
}
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created daily recurring event: {event_uid}")
|
||||
|
||||
# Query with date range: today → 3 days ahead
|
||||
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
query_end = query_start + timedelta(days=3)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=query_start,
|
||||
end_datetime=query_end,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
# Filter to only our recurring event (calendar may have others)
|
||||
our_events = [e for e in events if e["uid"] == event_uid]
|
||||
|
||||
# Should have multiple occurrences (one per day in the range)
|
||||
assert len(our_events) >= 2, (
|
||||
f"Expected multiple expanded occurrences, got {len(our_events)}. "
|
||||
f"Expansion may not be working."
|
||||
)
|
||||
|
||||
# Each occurrence should have a different start_datetime
|
||||
start_dates = [e["start_datetime"] for e in our_events]
|
||||
assert len(set(start_dates)) == len(our_events), (
|
||||
f"Each occurrence should have a unique start_datetime, got: {start_dates}"
|
||||
)
|
||||
|
||||
# No start_datetime should fall outside the queried range
|
||||
for e in our_events:
|
||||
event_start = datetime.fromisoformat(e["start_datetime"])
|
||||
# Remove timezone info for comparison if present
|
||||
if event_start.tzinfo is not None:
|
||||
event_start = event_start.replace(tzinfo=None)
|
||||
assert event_start >= query_start - timedelta(hours=1), (
|
||||
f"Occurrence {e['start_datetime']} is before query start {query_start}"
|
||||
)
|
||||
assert event_start < query_end + timedelta(hours=1), (
|
||||
f"Occurrence {e['start_datetime']} is after query end {query_end}"
|
||||
)
|
||||
|
||||
# Expanded occurrences should NOT have recurrence rules
|
||||
# (server strips RRULE when expanding)
|
||||
for e in our_events:
|
||||
assert not e.get("recurring"), (
|
||||
"Expanded occurrence should not have recurring=True, "
|
||||
"RRULE should be stripped by server-side expansion"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Recurring event expansion works: {len(our_events)} occurrences "
|
||||
f"returned with unique start dates"
|
||||
)
|
||||
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration test for multi-user Astrolabe background sync enablement.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
This test verifies that multiple users can independently:
|
||||
1. Log in to Nextcloud
|
||||
2. Generate an app password in Security settings
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration tests for Astrolabe personal settings page buttons.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
Tests the button functionality on /settings/user/astrolabe:
|
||||
1. Disable Indexing button (POST to /apps/astrolabe/api/revoke)
|
||||
2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration tests for Astrolabe token refresh flow.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
Tests the token refresh mechanism between Astrolabe (Nextcloud app)
|
||||
and the MCP server backend in a multi-user basic auth deployment.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Test Astrolabe integration with multiple MCP server deployments.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
This test suite verifies that the Astrolabe app can be dynamically configured
|
||||
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Integration tests for Calendar VEVENT update MCP tools - extended fields."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_mcp_update_event_extended_fields(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes via MCP."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
event_uid = None
|
||||
|
||||
try:
|
||||
# 1. Create a base event via MCP
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"title": "Extended Fields MCP Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Base event for MCP extended-field update test",
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False, (
|
||||
f"MCP event creation failed: {create_result.content}"
|
||||
)
|
||||
|
||||
result_data = json.loads(create_result.content[0].text)
|
||||
event_uid = result_data["uid"]
|
||||
logger.info(f"Created base event via MCP: {event_uid}")
|
||||
|
||||
# 2. Update with all four extended fields via MCP
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"categories": "work,meeting",
|
||||
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||
"attendees": "alice@example.com,bob@example.com",
|
||||
"reminder_minutes": 15,
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False, (
|
||||
f"MCP event update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 3. Verify via direct client
|
||||
event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Categories
|
||||
assert "work" in event.get("categories", ""), (
|
||||
f"Expected 'work' in categories, got: {event.get('categories')}"
|
||||
)
|
||||
assert "meeting" in event.get("categories", ""), (
|
||||
f"Expected 'meeting' in categories, got: {event.get('categories')}"
|
||||
)
|
||||
|
||||
# Recurrence
|
||||
assert event.get("recurring") is True, "Expected event to be recurring"
|
||||
assert "WEEKLY" in event.get("recurrence_rule", ""), (
|
||||
f"Expected WEEKLY in rrule, got: {event.get('recurrence_rule')}"
|
||||
)
|
||||
|
||||
# Attendees
|
||||
attendees = event.get("attendees", "")
|
||||
assert "alice@example.com" in attendees, (
|
||||
f"Expected alice in attendees, got: {attendees}"
|
||||
)
|
||||
assert "bob@example.com" in attendees, (
|
||||
f"Expected bob in attendees, got: {attendees}"
|
||||
)
|
||||
|
||||
logger.info("MCP extended fields update verified successfully")
|
||||
|
||||
# 4. Clear all four fields via MCP
|
||||
clear_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"categories": "",
|
||||
"recurrence_rule": "",
|
||||
"attendees": "",
|
||||
"reminder_minutes": 0,
|
||||
},
|
||||
)
|
||||
assert clear_result.isError is False, (
|
||||
f"MCP event clear failed: {clear_result.content}"
|
||||
)
|
||||
|
||||
# 5. Verify fields cleared
|
||||
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert not cleared.get("categories"), (
|
||||
f"Expected categories cleared, got: {cleared.get('categories')}"
|
||||
)
|
||||
assert cleared.get("recurring") is not True, (
|
||||
f"Expected recurring cleared, got: {cleared.get('recurring')}"
|
||||
)
|
||||
assert not cleared.get("attendees"), (
|
||||
f"Expected attendees cleared, got: {cleared.get('attendees')}"
|
||||
)
|
||||
|
||||
logger.info("MCP extended fields clear verified successfully")
|
||||
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -185,7 +185,11 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
@@ -230,7 +234,11 @@ 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"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
@@ -349,7 +357,11 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
@@ -393,7 +405,11 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
@@ -432,7 +448,11 @@ 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"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
@@ -502,7 +522,11 @@ 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"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401 (failed validation)
|
||||
@@ -561,7 +585,11 @@ 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"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests for SSL/TLS configuration (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from unittest.mock import patch
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings
|
||||
from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport
|
||||
|
||||
|
||||
class TestSSLSettings:
|
||||
"""Test SSL/TLS fields on Settings dataclass."""
|
||||
|
||||
def test_defaults(self):
|
||||
"""verify_ssl defaults to True, ca_bundle defaults to None."""
|
||||
settings = Settings()
|
||||
assert settings.nextcloud_verify_ssl is True
|
||||
assert settings.nextcloud_ca_bundle is None
|
||||
|
||||
def test_verify_ssl_false_logs_warning(self, caplog):
|
||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||
Settings(nextcloud_verify_ssl=False)
|
||||
assert "NEXTCLOUD_VERIFY_SSL is disabled" in caplog.text
|
||||
|
||||
def test_ca_bundle_nonexistent_path_raises(self):
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
Settings(nextcloud_ca_bundle="/nonexistent/path/ca.pem")
|
||||
|
||||
def test_ca_bundle_existing_path_logs_info(self, caplog, tmp_path):
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_text(
|
||||
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||
)
|
||||
caplog.set_level(logging.INFO, logger="nextcloud_mcp_server.config")
|
||||
settings = Settings(nextcloud_ca_bundle=str(ca_file))
|
||||
assert settings.nextcloud_ca_bundle == str(ca_file)
|
||||
assert "Using custom CA bundle" in caplog.text
|
||||
|
||||
|
||||
class TestGetNextcloudSSLVerify:
|
||||
"""Test the get_nextcloud_ssl_verify() helper function."""
|
||||
|
||||
def test_default_returns_true(self):
|
||||
env = {
|
||||
"NEXTCLOUD_VERIFY_SSL": "true",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
# Clear any cached settings
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert result is True
|
||||
|
||||
def test_verify_false_returns_false(self):
|
||||
env = {
|
||||
"NEXTCLOUD_VERIFY_SSL": "false",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(nextcloud_verify_ssl=False),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert result is False
|
||||
|
||||
def test_ca_bundle_returns_ssl_context(self):
|
||||
ca_bundle = certifi.where()
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(nextcloud_ca_bundle=ca_bundle),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_ca_bundle_ssl_context_has_loaded_certs(self):
|
||||
"""SSLContext created from CA bundle should have loaded certificates."""
|
||||
ca_bundle = certifi.where()
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(nextcloud_ca_bundle=ca_bundle),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
stats = result.cert_store_stats()
|
||||
assert stats["x509_ca"] > 0
|
||||
|
||||
def test_verify_false_takes_precedence_over_ca_bundle(self, tmp_path):
|
||||
"""When verify_ssl=False, ca_bundle is ignored (False wins)."""
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_text(
|
||||
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||
)
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(
|
||||
nextcloud_verify_ssl=False,
|
||||
nextcloud_ca_bundle=str(ca_file),
|
||||
),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestGetSettingsSSLEnvVars:
|
||||
"""Test that get_settings() reads SSL env vars correctly."""
|
||||
|
||||
def test_verify_ssl_env_true(self):
|
||||
env = {"NEXTCLOUD_VERIFY_SSL": "true"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_verify_ssl is True
|
||||
|
||||
def test_verify_ssl_env_false(self):
|
||||
env = {"NEXTCLOUD_VERIFY_SSL": "false"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_verify_ssl is False
|
||||
|
||||
def test_verify_ssl_env_missing_defaults_true(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Remove NEXTCLOUD_VERIFY_SSL if it exists
|
||||
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_verify_ssl is True
|
||||
|
||||
def test_ca_bundle_env(self, tmp_path):
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_text(
|
||||
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||
)
|
||||
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_ca_bundle == str(ca_file)
|
||||
|
||||
|
||||
class TestHTTPClientFactory:
|
||||
"""Test that factory functions apply verify correctly."""
|
||||
|
||||
def test_client_applies_verify_true(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||
):
|
||||
client = nextcloud_httpx_client()
|
||||
# httpx stores verify as an SSLConfig; check the _transport
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
def test_client_applies_verify_false(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
|
||||
):
|
||||
client = nextcloud_httpx_client()
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
def test_client_caller_override_takes_precedence(self):
|
||||
"""Caller-supplied verify kwarg should not be overridden."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||
):
|
||||
client = nextcloud_httpx_client(verify=False)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
def test_transport_applies_verify(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
|
||||
):
|
||||
transport = nextcloud_httpx_transport()
|
||||
assert isinstance(transport, httpx.AsyncHTTPTransport)
|
||||
|
||||
def test_client_passes_extra_kwargs(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||
):
|
||||
client = nextcloud_httpx_client(timeout=5.0, follow_redirects=True)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
+1
Submodule third_party/astrolabe added at c079a70af8
Vendored
-25
@@ -1,25 +0,0 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.9.0"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
|
||||
# Update Astrolabe-specific files only
|
||||
version_files = [
|
||||
"appinfo/info.xml:<version>",
|
||||
"package.json:version"
|
||||
]
|
||||
|
||||
# Ignore tags from other components
|
||||
ignored_tag_formats = [
|
||||
"v*", # MCP server tags
|
||||
"nextcloud-mcp-server-*", # Helm chart tags
|
||||
]
|
||||
|
||||
# Filter commits by scope
|
||||
[tool.commitizen.customize]
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:\\s.+"
|
||||
message_template = "{{change_type}}(astrolabe): {{message}}"
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'@nextcloud',
|
||||
],
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
},
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
/vendor/
|
||||
/vendor-bin/*/vendor/
|
||||
|
||||
/.php-cs-fixer.cache
|
||||
/tests/.phpunit.cache
|
||||
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
js/
|
||||
css/
|
||||
.phpunit.cache/
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
20
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once './vendor-bin/cs-fixer/vendor/autoload.php';
|
||||
|
||||
use Nextcloud\CodingStandard\Config;
|
||||
|
||||
$config = new Config();
|
||||
$config
|
||||
->getFinder()
|
||||
->notPath('build')
|
||||
->notPath('l10n')
|
||||
->notPath('node_modules')
|
||||
->notPath('src')
|
||||
->notPath('vendor')
|
||||
->in(__DIR__);
|
||||
|
||||
return $config;
|
||||
Vendored
-555
@@ -1,555 +0,0 @@
|
||||
# Changelog - Astrolabe
|
||||
|
||||
All notable changes to the Astrolabe Nextcloud app 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 [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- Initial alpha release
|
||||
- Semantic search across Notes, Files, Calendar, Deck, and Contacts
|
||||
- Integration with Nextcloud Unified Search
|
||||
- Personal settings UI for MCP server configuration
|
||||
- Admin settings for global MCP server URL
|
||||
- OAuth PKCE authentication flow
|
||||
- Vector visualization of semantic relationships
|
||||
- Hybrid search combining semantic and keyword matching
|
||||
- Background content indexing
|
||||
- Support for Nextcloud 30-32
|
||||
|
||||
### Notes
|
||||
|
||||
- This is an alpha release intended for early adopters and testing
|
||||
- Requires external MCP server deployment
|
||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
|
||||
## 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
|
||||
|
||||
- Remove URL rewriting in favor of proper nextcloud config
|
||||
- **helm**: migrate to new environment variable naming convention
|
||||
- Migrate to vue 3
|
||||
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
|
||||
- **helm**: add support for multi-user BasicAuth mode
|
||||
|
||||
### Fix
|
||||
|
||||
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
|
||||
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
|
||||
- **auth**: Skip issuer validation for management API tokens
|
||||
- Use settings.enable_offline_access for env var consolidation
|
||||
- Add required config.py attributes
|
||||
- **docker**: remove overwritehost to fix container-to-container DCR
|
||||
- **deps**: update dependency @nextcloud/vue to v9
|
||||
- **deps**: update dependency vue to v3
|
||||
- **helm**: set OIDC client env vars when using existingSecret
|
||||
- **helm**: trigger chart release workflow on helm chart tags
|
||||
- **helm**: address PR #447 reviewer feedback
|
||||
- **helm**: include MCP server version bumps in changelog pattern
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
||||
|
||||
## astrolabe-v0.6.0 (2025-12-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- **config**: enable DCR for multi-user BasicAuth with offline access
|
||||
- **astrolabe**: implement app password provisioning for multi-user background sync
|
||||
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
|
||||
|
||||
## astrolabe-v0.5.0 (2025-12-20)
|
||||
|
||||
### Feat
|
||||
|
||||
- **auth**: add multi-user BasicAuth pass-through mode
|
||||
- **astrolabe**: add dynamic MCP server configuration for testing
|
||||
|
||||
### Fix
|
||||
|
||||
- **config**: address reviewer feedback
|
||||
|
||||
### Refactor
|
||||
|
||||
- **config**: centralize configuration validation and simplify startup
|
||||
|
||||
## astrolabe-v0.4.4 (2025-12-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: screenshots in info.xml
|
||||
|
||||
## astrolabe-v0.4.3 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: screenshots in info.xml
|
||||
|
||||
## astrolabe-v0.4.2 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Update screenshots
|
||||
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
|
||||
|
||||
## astrolabe-v0.4.1 (2025-12-19)
|
||||
|
||||
## astrolabe-v0.4.0 (2025-12-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **ci**: add --increment flag to bump scripts for manual version control
|
||||
|
||||
## astrolabe-v0.3.2 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add contents:write permission to appstore workflow
|
||||
|
||||
## astrolabe-v0.3.1 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: update commitizen pattern to properly update info.xml version
|
||||
|
||||
## astrolabe-v0.3.0 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
|
||||
- **astrolabe**: info.xml
|
||||
|
||||
## astrolabe-v0.2.1 (2025-12-19)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- MCP server now bumps for ANY conventional commit except
|
||||
those explicitly scoped to helm or astrolabe.
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: push all tags explicitly in bump workflow
|
||||
- **ci**: make MCP server default bump target for all non-scoped commits
|
||||
- **ci**: restrict docker build to MCP server tags only
|
||||
- **ci**: correct appstore-push-action version to v1.0.4
|
||||
|
||||
## astrolabe-v0.2.0 (2025-12-19)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- Search algorithms now require Qdrant to be populated.
|
||||
Vector sync must be enabled and documents indexed for search to work.
|
||||
- All OAuth deployments must be reconfigured to specify
|
||||
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
|
||||
choose between multi-audience or token exchange mode.
|
||||
- FASTMCP_-prefixed env vars have been replaced by CLI
|
||||
arguments. Refer to the README for updated usage.
|
||||
|
||||
### Feat
|
||||
|
||||
- **ci**: implement monorepo-aware version bumping workflow
|
||||
- **astrolabe**: add Nextcloud App Store deployment automation
|
||||
- configure commitizen monorepo with independent versioning
|
||||
- add Alembic database migration system
|
||||
- make chunk modal title clickable link to documents
|
||||
- add native Plotly hover styling for clickable points
|
||||
- add click interactivity to Plotly 3D scatter chart
|
||||
- improve chunk viewer with fixed navigation and markdown rendering
|
||||
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
|
||||
- **auth**: implement refresh token rotation for Nextcloud OIDC
|
||||
- **astrolabe**: enhance unified search and add webhook management
|
||||
- **astrolabe**: add webhook management UI to admin settings
|
||||
- **astrolabe**: add OAuth token refresh and webhook presets
|
||||
- **search**: add file_path metadata and chunk offsets to search results
|
||||
- **astrolabe**: use proper icons and thumbnails in unified search
|
||||
- **astrolabe**: add admin search settings and enhanced UI
|
||||
- **astrolabe**: add unified search provider with clickable file links
|
||||
- **astrolabe**: add 3D PCA visualization for semantic search
|
||||
- **astrolabe**: add Nextcloud PHP app for MCP server management
|
||||
- **vector-sync**: enable background sync in OAuth mode
|
||||
- **vector**: add Deck card vector search with visualization support
|
||||
- **vector-viz**: add news_item support for links and chunk expansion
|
||||
- add MCP tool annotations for enhanced UX
|
||||
- **news**: add Nextcloud News app integration
|
||||
- Add tag management methods to WebDAV client
|
||||
- Add OpenAI provider support for embeddings and generation
|
||||
- Add Smithery CLI deployment support
|
||||
- Implement ADR-016 Smithery stateless deployment mode
|
||||
- Add context expansion to semantic search with chunk overlap removal
|
||||
- Use Ollama native batch API in embed_batch()
|
||||
- Implement Qdrant placeholder state management
|
||||
- Switch files to use numeric IDs with file_path resolution
|
||||
- Implement per-chunk vector visualization with context expansion
|
||||
- Improve vector visualization with static assets and fixes
|
||||
- Redesign UI to match Nextcloud ecosystem aesthetic
|
||||
- Replace custom document chunker with LangChain MarkdownTextSplitter
|
||||
- **viz**: Add dual-score display and improve UI controls
|
||||
- add configurable fusion algorithms for BM25 hybrid search
|
||||
- add chunk position tracking to vector indexing and search
|
||||
- add vector viz template and chunk context endpoint
|
||||
- add unified provider architecture with Amazon Bedrock support
|
||||
- add concurrent uploads and --force flag to upload command
|
||||
- implement RAG evaluation framework with CLI tooling
|
||||
- Add OpenTelemetry tracing to @instrument_tool decorator
|
||||
- Implement BM25 hybrid search with native Qdrant RRF fusion
|
||||
- Normalize hybrid search RRF scores to 0-1 range
|
||||
- Enhance vector visualization UI and parallelize search verification
|
||||
- Add Vector Viz tab to app home page
|
||||
- Add vector visualization pane with multi-select document types
|
||||
- Implement custom PCA to remove sklearn dependency
|
||||
- Add multi-document Protocol with cross-app search support
|
||||
- Update nc_semantic_search tool with algorithm selection
|
||||
- Implement unified search algorithm module
|
||||
- Enable SSE transport for mcp service and update test fixtures
|
||||
- Complete Phase 5 - Instrument all 93 MCP tools
|
||||
- Add instrumentation decorator and apply to notes tools (Phase 5)
|
||||
- Add OAuth token and database metrics (Phases 3-4)
|
||||
- Add metrics instrumentation for queue, health, and database operations
|
||||
- Add Grafana dashboard and vector sync metric instrumentation
|
||||
- **ollama**: Pull model on startup if not available in ollama
|
||||
- add dynamic vector sync status updates with htmx polling
|
||||
- add webhook management UI and BeforeNodeDeletedEvent support
|
||||
- validate Nextcloud webhook schemas and document findings
|
||||
- skip tracing for health and metrics endpoints
|
||||
- **helm**: Add document chunking configuration
|
||||
- **vector**: Add configurable chunk size and overlap for document embedding
|
||||
- **vector**: Support multiple embedding models with auto-generated collection names
|
||||
- **helm**: Add observability support with ServiceMonitor and Grafana dashboard
|
||||
- **observability**: Add comprehensive monitoring with Prometheus and OpenTelemetry
|
||||
- **helm**: add Qdrant local mode support with three deployment options [skip ci]
|
||||
- add Qdrant local mode support with in-memory and persistent storage
|
||||
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
||||
- implement MCP sampling for semantic search RAG (ADR-008)
|
||||
- add optional vector database and semantic search to helm chart
|
||||
- add vector sync processing status to /user/page endpoint
|
||||
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
||||
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
||||
- add real elicitation integration test with python-sdk MCP client
|
||||
- unify session architecture and enhance login status visibility
|
||||
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
|
||||
- add scope protection to OAuth provisioning tools
|
||||
- enable authorization services for token exchange in Keycloak
|
||||
- implement scope-based audience mapping and RFC 9728 support
|
||||
- integrate token exchange into MCP server application
|
||||
- implement RFC 8693 Standard Token Exchange for Keycloak
|
||||
- Add userinfo route/page
|
||||
- add browser-based user info page with separate OAuth flow
|
||||
- Implement ADR-004 Progressive Consent foundation (partial)
|
||||
- Complete ADR-004 Progressive Consent OAuth flows implementation
|
||||
- Implement ADR-004 Progressive Consent foundation components
|
||||
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
|
||||
- Auto-configure impersonation role in Keycloak realm import
|
||||
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
|
||||
- Add Keycloak external IdP integration with custom scopes
|
||||
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
|
||||
- Add Keycloak OAuth provider support with refresh token storage
|
||||
- **server**: Add /live & /health endpoints
|
||||
- Initialize helm chart
|
||||
- Add text processing background worker for telling client about progress
|
||||
- **auth**: Add support for client registration deletion
|
||||
- Split read/write scopes into app:read/write scopes
|
||||
- Enable token introspection for opaque tokens
|
||||
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||
- Initialize JWT-scoped tools
|
||||
- **caldav**: Add support for tasks
|
||||
- **webdav**: Add search and list favorite response tools
|
||||
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
|
||||
- Add Groups API client
|
||||
- add sharing API client and server tools
|
||||
- **server**: Experimental support for OAuth2/OIDC authentication
|
||||
- **users**: Initialize user API client
|
||||
- **server**: Add support for `streamable-http` transport type
|
||||
- Add WebDAV resource copy functionality
|
||||
- Add WebDAV resource move/rename functionality
|
||||
- **deck**: Add support for stack, cards, labels
|
||||
- **deck**: Initialize Deck app client/server
|
||||
- **cli**: Replace `mcp run` with click CLI and runtime options
|
||||
- **client**: Preserve fields when modifying contacts/calendar resources
|
||||
- **server**: Add structured output to all tool/resource output
|
||||
- **contacts**: Initialize Contacts App
|
||||
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
||||
- Update webdav client create_directory method to handle recursive directories
|
||||
- **webdav**: add complete file system support
|
||||
- Add TablesClient and associated tools
|
||||
- Switch to using async client
|
||||
- **notes**: Add append to note functionality
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: improve versioning and error handling
|
||||
- **ci**: address critical workflow and validation issues
|
||||
- **astrolabe**: address code review feedback
|
||||
- **security**: address critical security issues from PR #401 code review
|
||||
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
|
||||
- **astrolabe**: revert invalid files_pdfviewer URL for file links
|
||||
- resolve type checking warnings for CI
|
||||
- move Alembic to package submodule for Docker compatibility
|
||||
- update unified search results to match chunk viz display
|
||||
- **astrolabe**: handle OAuth refresh token rotation
|
||||
- address critical code review issues (4 fixes)
|
||||
- resolve CI linting issues for Astroglobe
|
||||
- **news**: revert get_item() to use get_items() + filter
|
||||
- Disable DNS rebinding protection for containerized deployments
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
- address PR review feedback
|
||||
- Update lockfile
|
||||
- Revert mcp version <1.23
|
||||
- resolve all type checking errors (8 errors fixed)
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
- **deps**: update dependency pillow to v12
|
||||
- Add rate limit retry logic to OpenAI provider
|
||||
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
|
||||
- **smithery**: Enable JSON response format for scanner compatibility
|
||||
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
|
||||
- **smithery**: Use container runtime pattern for config discovery
|
||||
- Add Smithery lifespan and auth mode detection
|
||||
- Use alpha_composite for proper RGBA highlight blending
|
||||
- Remove pymupdf.layout.activate() to fix page_chunks behavior
|
||||
- Centralize PDF processing and generate separate images per chunk
|
||||
- Set is_placeholder=False in processor to fix search filtering
|
||||
- Increase placeholder staleness threshold to 5x scan interval
|
||||
- Add placeholder staleness check to prevent duplicate processing
|
||||
- Use empty SparseVector instead of None for placeholders
|
||||
- Return empty array instead of null for query_coords when no results
|
||||
- Align PDF text extraction between indexing and context expansion
|
||||
- Update models and viz to use int-only doc_id
|
||||
- Reconstruct full content for notes to match indexed offsets
|
||||
- Add async/await, PDF metadata, and type safety fixes
|
||||
- **deps**: update dependency mcp to >=1.22,<1.23
|
||||
- Improve 3D plot rendering with explicit dimensions and window resize support
|
||||
- Preserve 3D plot camera and improve documentation
|
||||
- Preserve 3D plot camera position and fix CSS loading
|
||||
- prevent infinite loop in DocumentChunker with position tracking
|
||||
- Relax SearchResult validation to support DBSF fusion scores > 1.0
|
||||
- suppress Starlette middleware type warnings in ty checker
|
||||
- download qrels from BEIR ZIP instead of HuggingFace
|
||||
- Handle named vectors in visualization and semantic search
|
||||
- Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
|
||||
- Update viz routes to use BM25 hybrid search after refactor
|
||||
- Reorder tabs and fix viz pane session access
|
||||
- Use NEXTCLOUD_OIDC_CLIENT_ID/SECRET env vars consistently
|
||||
- return all notes when search query is empty
|
||||
- Move grafana_folder from labels to annotations
|
||||
- add dynamic dimension detection for Ollama embedding models
|
||||
- improve webapp tab UI with CSS Grid and viewport-filling container
|
||||
- add retry logic for ETag conflicts in category change test
|
||||
- optimize Notes API pagination with pruneBefore parameter
|
||||
- Support in-memory Qdrant for CI testing
|
||||
- **helm**: Set default strategy to Recreate
|
||||
- **observability**: isolate metrics endpoint to dedicated port
|
||||
- **readiness**: Only check external Qdrant in network mode
|
||||
- **vector**: Handle missing 'modified' field in notes gracefully
|
||||
- **ci**: Use helm dependency build instead of update to use Chart.lock
|
||||
- **helm**: update Qdrant dependency condition to match new mode structure
|
||||
- **ci**: add Helm repository setup to chart release workflow
|
||||
- implement deletion grace period and vector sync status tool
|
||||
- remove unnecessary urllib3<2.0 constraint
|
||||
- integrate vector sync tasks with Starlette lifespan for streamable-http
|
||||
- **deps**: update dependency mcp to >=1.21,<1.22
|
||||
- Consolidate OAuth callbacks and implement PKCE for all flows
|
||||
- Implement proper OAuth resource parameters and PRM-based discovery
|
||||
- Simplify token verifier to be RFC 7519 compliant
|
||||
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
|
||||
- Correct OAuth token audience validation for multi-audience mode
|
||||
- **deps**: update dependency mcp to >=1.20,<1.21
|
||||
- add missing await for get_nextcloud_client in capabilities resource
|
||||
- use valid Fernet encryption keys in token exchange tests
|
||||
- accept resource URL in token audience for Nextcloud JWT tokens
|
||||
- remove token-exchange-nextcloud scope and accept tokens without audience
|
||||
- move audience mapper from scope to nextcloud-mcp-server client
|
||||
- move token-exchange-nextcloud from default to optional scopes
|
||||
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
|
||||
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
|
||||
- correct OAuth token audience validation using RFC 8707 resource parameter
|
||||
- remove remaining references to deleted oauth_callback and oauth_token
|
||||
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
|
||||
- browser OAuth userinfo endpoint and refresh token rotation
|
||||
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
|
||||
- make provisioning checks opt-in (default false)
|
||||
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
|
||||
- Complete Keycloak external IdP integration with all tests passing
|
||||
- Complete Keycloak external IdP integration with all tests passing
|
||||
- Update DCR token_type tests for OIDC app changes
|
||||
- **helm**: Remove image tag overide
|
||||
- **helm**: Update helm chart with extraArgs
|
||||
- Update helm chart variables
|
||||
- **helm**: Update helm version with release
|
||||
- **helm**: Update helm version with release
|
||||
- **helm**: Update helm version with release
|
||||
- **helm**: Update helm version with release
|
||||
- Trigger release
|
||||
- Add support for RFC 7592 client registration and deletion
|
||||
- Update webdav models for proper serialization
|
||||
- **deps**: update dependency mcp to >=1.19,<1.20
|
||||
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||
- **caldav**: Fix caldav search() due to missing todos
|
||||
- **caldav**: Check that calendar exists after creation to avoid race condition
|
||||
- **caldav**: Properly parse datetimes as vDDDTypes
|
||||
- Increase HTTP client timeout to 30s
|
||||
- Handle RequestError in mcp tools
|
||||
- **deps**: update dependency mcp to >=1.18,<1.19
|
||||
- **deps**: update dependency pillow to v12
|
||||
- **oauth**: Remove the option to force_register new clients
|
||||
- Update user/groups API to OCS v2
|
||||
- **deps**: update dependency mcp to >=1.17,<1.18
|
||||
- **deps**: update dependency mcp to >=1.16,<1.17
|
||||
- **deps**: update dependency mcp to >=1.15,<1.16
|
||||
- **docker**: Provide --host 0.0.0.0 in default docker image
|
||||
- **deps**: update dependency mcp to >=1.13,<1.14
|
||||
- **server**: Replace ErrorResponses with standard McpErrors
|
||||
- **notes**: Include ETags in responses to avoid accidently updates
|
||||
- **notes**: Remove note contents from responses to reduce token usage
|
||||
- **model**: Serialize timestamps in RFC3339 format
|
||||
- **client**: Use paging to fetch all notes
|
||||
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||
- **calendar**: Fix iCalendar date vs datetime format
|
||||
- **calendar**: Remove try/except in calendar API
|
||||
- apply ruff formatting to pass CI checks
|
||||
- **calendar**: address PR feedback from maintainer
|
||||
- apply ruff formatting to test_webdav_operations.py
|
||||
- **deps**: update dependency mcp to >=1.10,<1.11
|
||||
- update tests
|
||||
- Commitizen release process
|
||||
- Do not update dependencies when running in Dockerfile
|
||||
- Configure logging
|
||||
- Limit search results to notes with score > 0.5
|
||||
- Install deps before checking service
|
||||
- **deps**: update dependency mcp to >=1.9,<1.10
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: extract PDF viewer to dedicated component
|
||||
- **astrolabe**: reframe UI as semantic search service
|
||||
- **news**: simplify vector sync to fetch all items
|
||||
- Move background tasks to server lifespan and deprecate SSE transport
|
||||
- Simplify PDF text extraction with single to_markdown call
|
||||
- migrate asyncio to anyio for consistent structured concurrency
|
||||
- replace httpx client with NextcloudClient in upload command
|
||||
- Optimize Nextcloud access verification with centralized filtering
|
||||
- Make all search algorithms query Qdrant payload, not Nextcloud
|
||||
- move webapp from /user/page to /app
|
||||
- consolidate database storage for webhooks and OAuth tokens
|
||||
- simplify OpenTelemetry tracing configuration
|
||||
- migrate vector sync from asyncio.Queue to anyio memory object streams
|
||||
- update to Qdrant query_points API and fix Playwright Keycloak login
|
||||
- Eliminate duplicate validation logic in UnifiedTokenVerifier
|
||||
- integrate token exchange into unified get_client() pattern
|
||||
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
|
||||
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
|
||||
- Unify OAuth configuration to be provider-agnostic
|
||||
- Transform document parsing into pluggable processor architecture
|
||||
- Update JWT client to use DCR, re-enable tool filtering
|
||||
- Migrate from internal CalendarClient to caldav library
|
||||
- Unify logging & remove factory deployment
|
||||
- Add tools for all resources to enable tool-only workflows
|
||||
- Add `http` to --transport option
|
||||
- Use _make_request where available
|
||||
- **calendar**: optimize logging for production readiness
|
||||
- Modularize NC and Notes app client
|
||||
|
||||
### Perf
|
||||
|
||||
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
|
||||
- **news**: use direct API endpoint for get_item()
|
||||
- Optimize vector viz search performance
|
||||
- Optimize PDF processing with parallel extraction and single-render highlights
|
||||
- Eliminate double-fetching in semantic search sampling
|
||||
- fix vector viz search performance and visual encoding
|
||||
- make note deletion concurrent in upload --force
|
||||
- Exclude vector-sync status polling from distributed tracing
|
||||
- **notes**: Improve notes search performance using async iterators
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
In the Nextcloud community, participants from all over the world come together to create Free Software for a free internet. This is made possible by the support, hard work and enthusiasm of thousands of people, including those who create and use Nextcloud software.
|
||||
|
||||
Our code of conduct offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other.
|
||||
|
||||
The Code of Conduct is shared by all contributors and users who engage with the Nextcloud team and its community services. It presents a summary of the shared values and “common sense” thinking in our community.
|
||||
|
||||
You can find our full code of conduct on our website: https://nextcloud.com/code-of-conduct/
|
||||
|
||||
Please, keep our CoC in mind when you contribute! That way, everyone can be a part of our community in a productive, positive, creative and fun way.
|
||||
Vendored
-661
@@ -1,661 +0,0 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
Vendored
-101
@@ -1,101 +0,0 @@
|
||||
# Nextcloud App Store Release Makefile for Astrolabe
|
||||
#
|
||||
# Based on: https://nextcloudappstore.readthedocs.io/en/latest/developer.html
|
||||
|
||||
app_name=astrolabe
|
||||
project_dir=$(CURDIR)
|
||||
build_dir=$(project_dir)/build
|
||||
appstore_dir=$(build_dir)/artifacts
|
||||
package_name=$(appstore_dir)/$(app_name)
|
||||
cert_dir=$(HOME)/.nextcloud/certificates
|
||||
|
||||
# Nextcloud server path (configurable via environment variable)
|
||||
server_dir?=../../server
|
||||
occ=$(server_dir)/occ
|
||||
|
||||
# Signing
|
||||
private_key=$(cert_dir)/$(app_name).key
|
||||
certificate=$(cert_dir)/$(app_name).crt
|
||||
sign_cmd=php $(occ) integrity:sign-app --privateKey=$(private_key) --certificate=$(certificate)
|
||||
|
||||
# Clean build artifacts
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf $(build_dir)
|
||||
|
||||
# Validate required dependencies
|
||||
.PHONY: validate-deps
|
||||
validate-deps:
|
||||
@command -v composer >/dev/null 2>&1 || { echo "Error: composer not found. Install from https://getcomposer.org/"; exit 1; }
|
||||
@command -v npm >/dev/null 2>&1 || { echo "Error: npm not found. Install Node.js from https://nodejs.org/"; exit 1; }
|
||||
@command -v php >/dev/null 2>&1 || { echo "Error: php not found. Install PHP 8.1 or higher."; exit 1; }
|
||||
@echo "✓ All dependencies found"
|
||||
|
||||
# Install PHP and Node dependencies
|
||||
.PHONY: install-deps
|
||||
install-deps: validate-deps
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
|
||||
# Build production frontend assets
|
||||
.PHONY: build-frontend
|
||||
build-frontend:
|
||||
npm run build
|
||||
|
||||
# Run all linters
|
||||
.PHONY: lint
|
||||
lint:
|
||||
composer lint
|
||||
composer cs:check
|
||||
npm run lint
|
||||
npm run stylelint
|
||||
|
||||
# Assemble app files into build directory (exclude dev files)
|
||||
.PHONY: assemble
|
||||
assemble: clean install-deps build-frontend
|
||||
mkdir -p $(package_name)
|
||||
# Copy app files
|
||||
rsync -av \
|
||||
--exclude='.git*' \
|
||||
--exclude='build/' \
|
||||
--exclude='tests/' \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.github/' \
|
||||
--exclude='composer.json' \
|
||||
--exclude='composer.lock' \
|
||||
--exclude='package.json' \
|
||||
--exclude='package-lock.json' \
|
||||
--exclude='vite.config.js' \
|
||||
--exclude='.eslintrc.js' \
|
||||
--exclude='.php-cs-fixer.*' \
|
||||
--exclude='psalm.xml' \
|
||||
--exclude='*.iml' \
|
||||
--exclude='.idea' \
|
||||
--exclude='src/' \
|
||||
./ $(package_name)/
|
||||
|
||||
# Validate signing prerequisites
|
||||
.PHONY: validate-signing
|
||||
validate-signing:
|
||||
@test -f $(occ) || { echo "Error: Nextcloud server not found at $(server_dir)"; echo "Set server_dir variable: make appstore server_dir=/path/to/server"; exit 1; }
|
||||
@test -f $(private_key) || { echo "Error: Private key not found at $(private_key)"; exit 1; }
|
||||
@test -f $(certificate) || { echo "Error: Certificate not found at $(certificate)"; exit 1; }
|
||||
@echo "✓ Signing prerequisites validated"
|
||||
|
||||
# Create signed release tarball for App Store
|
||||
.PHONY: appstore
|
||||
appstore: assemble validate-signing
|
||||
# Sign the app
|
||||
$(sign_cmd) --path=$(package_name)
|
||||
# Create tarball
|
||||
cd $(appstore_dir) && \
|
||||
tar -czf $(app_name).tar.gz $(app_name)
|
||||
# Show package info
|
||||
@echo "========================================="
|
||||
@echo "App package created:"
|
||||
@echo " $(appstore_dir)/$(app_name).tar.gz"
|
||||
@echo ""
|
||||
@echo "Signature:"
|
||||
@cat $(package_name)/appinfo/signature.json | head -n 5
|
||||
@echo "========================================="
|
||||
Vendored
-223
@@ -1,223 +0,0 @@
|
||||
# Astrolabe: The Intelligence Layer for Nextcloud
|
||||
|
||||
Your Nextcloud instance is more than just a bucket for files—it is a galaxy of ideas, projects, and knowledge. But until now, you've been navigating it in the dark, relying on exact filenames and rigid keywords.
|
||||
|
||||
**It's time to turn the lights on.**
|
||||
|
||||
Astrolabe is a fully integrated Nextcloud application that transforms your server into a semantic intelligence engine. It doesn't just store your data; it **maps it, understands it, and connects it** to the AI future.
|
||||
|
||||
---
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### 🔍 Search That Actually Understands
|
||||
|
||||
Forget clunky external tools. Astrolabe registers as a **native Nextcloud Search Provider**.
|
||||
|
||||
- **Seamless**: Lives right in the standard Nextcloud search bar you already use
|
||||
- **Semantic**: Type "marketing strategy for the winter launch" and Astrolabe finds the relevant PDFs, chat logs, and text files—even if those exact words never appear in the document
|
||||
- **Intelligent**: It finds the **concept**, not just the string
|
||||
|
||||
### 🌌 Visualize Your Data Universe
|
||||
|
||||
Data shouldn't just be a list; it should be a landscape. Astrolabe includes a dedicated dashboard that visualizes your document chunks as a **3D PCA Vector Plot**.
|
||||
|
||||
- **See the Connections**: View your data as a constellation of points in 3D space
|
||||
- **Explore Clusters**: Visually identify how your documents relate to one another
|
||||
- **True "Astroglobe" Experience**: Rotate, zoom, and fly through your semantic universe just like navigators once studied the stars
|
||||
|
||||
### 🤖 Power Your AI Agents
|
||||
|
||||
Astrolabe isn't just for humans; it's for your AI agents, too. It acts as a bridge, running a **Model Context Protocol (MCP) Server** directly from your Nextcloud.
|
||||
|
||||
- **Bring Your Own Brain**: Connect external AI clients (like Claude Desktop or Cursor) to your private data
|
||||
- **Agentic Workflows**: Enable LLMs to "sample" your files, read content, and perform complex reasoning tasks using your Nextcloud data as the source of truth
|
||||
- **Private & Secure**: Your data never leaves your infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From App Store (Recommended)
|
||||
|
||||
1. Open **Apps** in your Nextcloud
|
||||
2. Search for **"Astrolabe"**
|
||||
3. Click **"Download and enable"**
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Clone into your Nextcloud apps directory
|
||||
cd /path/to/nextcloud/apps
|
||||
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
|
||||
cd nextcloud-mcp-server/third_party/astrolabe
|
||||
|
||||
# Install dependencies
|
||||
composer install
|
||||
|
||||
# Enable the app
|
||||
php /path/to/nextcloud/occ app:enable astrolabe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure the MCP Server URL
|
||||
|
||||
Add this to your Nextcloud `config/config.php`:
|
||||
|
||||
```php
|
||||
'mcp_server_url' => 'http://localhost:8000',
|
||||
```
|
||||
|
||||
### 2. Start the MCP Server
|
||||
|
||||
The MCP server handles semantic search and AI agent connections. See the [MCP Server Installation Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md) for details.
|
||||
|
||||
Quick start with Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
```
|
||||
|
||||
### 3. Authorize Access
|
||||
|
||||
1. Go to **Settings → Personal → Astrolabe**
|
||||
2. Click **"Authorize Access"**
|
||||
3. Sign in to your identity provider
|
||||
4. Approve the requested permissions
|
||||
|
||||
That's it! You can now use semantic search and explore your data universe.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Personal Settings
|
||||
|
||||
Located in: **Settings → Personal → Astrolabe**
|
||||
|
||||
- **Semantic Search Dashboard**: Interactive 3D visualization of your document chunks
|
||||
- **OAuth Authorization**: Authorize Nextcloud to access the MCP server on your behalf
|
||||
- **Session Information**: View connection status and authentication details
|
||||
- **Connection Management**: Revoke access or disconnect when needed
|
||||
|
||||
### Admin Settings
|
||||
|
||||
Located in: **Settings → Administration → Astrolabe**
|
||||
|
||||
- **Server Status**: Monitor MCP server health and version
|
||||
- **Vector Sync Metrics**: See how many documents are indexed, processing rates, and sync status
|
||||
- **Configuration Validation**: Verify server URL and connectivity
|
||||
- **Feature Availability**: Check which capabilities are enabled
|
||||
|
||||
### Unified Search Integration
|
||||
|
||||
Astrolabe integrates directly with Nextcloud's **Unified Search**:
|
||||
|
||||
- Available in the top search bar across all Nextcloud pages
|
||||
- Returns semantic matches ranked by relevance
|
||||
- Shows excerpts from matching documents
|
||||
- Links directly to source files in Nextcloud
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### For Individuals
|
||||
|
||||
- **Research**: Find all notes related to a project, even if they use different terminology
|
||||
- **Organization**: Discover forgotten documents related to your current work
|
||||
- **Exploration**: Visualize how your knowledge connects and evolves over time
|
||||
|
||||
### For Teams
|
||||
|
||||
- **Knowledge Discovery**: Surface institutional knowledge that would otherwise stay buried
|
||||
- **Collaboration**: Find team members working on similar problems
|
||||
- **Documentation**: Locate relevant documentation without knowing exact titles
|
||||
|
||||
### For Developers
|
||||
|
||||
- **AI Integration**: Connect Claude Desktop, Cursor, or other MCP clients to Nextcloud
|
||||
- **RAG Workflows**: Build retrieval-augmented generation pipelines on your private data
|
||||
- **Custom Agents**: Use the MCP protocol to create specialized workflows
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Nextcloud**: Version 30 or later
|
||||
- **MCP Server**: Running instance (Docker recommended)
|
||||
- **Identity Provider**: OAuth provider supporting PKCE (Nextcloud OIDC Login or Keycloak)
|
||||
- **Vector Sync**: Optional but recommended for semantic search (see [configuration guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md))
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### User Guides
|
||||
|
||||
- [MCP Server Installation](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md)
|
||||
- [Configuration Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md)
|
||||
- [OAuth Setup](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/oauth-setup.md)
|
||||
|
||||
### Technical Details
|
||||
|
||||
- [ADR-018: Nextcloud PHP App Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-018-nextcloud-php-app-for-settings-ui.md)
|
||||
- [OAuth PKCE Flow Details](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-004-progressive-consent.md)
|
||||
- [Vector Sync Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-002-vector-sync-authentication.md)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Cannot connect to MCP server:**
|
||||
- Verify `mcp_server_url` in `config.php`
|
||||
- Check MCP server is running: `curl http://localhost:8000/health`
|
||||
- Review logs: `tail -f data/nextcloud.log`
|
||||
|
||||
**Authorization fails:**
|
||||
- Ensure MCP server is in OAuth mode
|
||||
- Verify identity provider is accessible
|
||||
- Check browser console for errors
|
||||
|
||||
**Semantic search returns no results:**
|
||||
- Verify vector sync is enabled and running
|
||||
- Check indexing status in Admin settings
|
||||
- Allow time for initial indexing to complete
|
||||
|
||||
For more help, see the [Troubleshooting Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/troubleshooting.md).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Here's how to get started:
|
||||
|
||||
1. Fork the [nextcloud-mcp-server repository](https://github.com/cbcoutinho/nextcloud-mcp-server)
|
||||
2. Create a feature branch: `git checkout -b feature/your-feature`
|
||||
3. Make your changes in `third_party/astrolabe/`
|
||||
4. Test thoroughly with a local Nextcloud instance
|
||||
5. Submit a pull request
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
**Astrolabe** is developed as part of the [Nextcloud MCP Server](https://github.com/cbcoutinho/nextcloud-mcp-server) project, bringing the power of semantic search and AI integration to Nextcloud.
|
||||
|
||||
**Author**: Chris Coutinho <chris@coutinho.io>
|
||||
|
||||
---
|
||||
|
||||
**Your Data. Mapped. Visualized. Connected.**
|
||||
|
||||
Install Astrolabe for Nextcloud.
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>astrolabe</id>
|
||||
<name>Astrolabe</name>
|
||||
<summary>AI-powered semantic search across your Nextcloud</summary>
|
||||
<description>< for configuration details.
|
||||
]]></description>
|
||||
<version>0.9.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||
<namespace>Astrolabe</namespace>
|
||||
<category>ai</category>
|
||||
<bugs>https://github.com/cbcoutinho/nextcloud-mcp-server/issues</bugs>
|
||||
<repository type="git">https://github.com/cbcoutinho/nextcloud-mcp-server</repository>
|
||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/02-semantic-search-with-plot.png?raw=1</screenshot>
|
||||
<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="31" max-version="32"/>
|
||||
</dependencies>
|
||||
<settings>
|
||||
<personal>OCA\Astrolabe\Settings\Personal</personal>
|
||||
<personal-section>OCA\Astrolabe\Settings\PersonalSection</personal-section>
|
||||
<admin>OCA\Astrolabe\Settings\Admin</admin>
|
||||
<admin-section>OCA\Astrolabe\Settings\AdminSection</admin-section>
|
||||
</settings>
|
||||
<navigations>
|
||||
<navigation>
|
||||
<id>astrolabe</id>
|
||||
<name>Astrolabe</name>
|
||||
<route>astrolabe.page.index</route>
|
||||
<icon>app.svg</icon>
|
||||
<type>link</type>
|
||||
</navigation>
|
||||
</navigations>
|
||||
<background-jobs>
|
||||
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
||||
</background-jobs>
|
||||
</info>
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Routes configuration for MCP Server UI app.
|
||||
*
|
||||
* Defines URL routes for OAuth flow and form handlers.
|
||||
*/
|
||||
|
||||
return [
|
||||
'routes' => [
|
||||
// OAuth routes
|
||||
[
|
||||
'name' => 'oauth#initiateOAuth',
|
||||
'url' => '/oauth/authorize',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'oauth#oauthCallback',
|
||||
'url' => '/oauth/callback',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'oauth#disconnect',
|
||||
'url' => '/oauth/disconnect',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
|
||||
// API routes (form handlers)
|
||||
[
|
||||
'name' => 'api#revokeAccess',
|
||||
'url' => '/api/revoke',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
|
||||
// Background sync credentials routes
|
||||
[
|
||||
'name' => 'credentials#storeAppPassword',
|
||||
'url' => '/api/v1/background-sync/credentials',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#getCredentials',
|
||||
'url' => '/api/v1/background-sync/credentials/{userId}',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#deleteCredentials',
|
||||
'url' => '/api/v1/background-sync/credentials/revoke',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#getStatus',
|
||||
'url' => '/api/v1/background-sync/status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
|
||||
// Vector search API routes
|
||||
[
|
||||
'name' => 'api#search',
|
||||
'url' => '/api/search',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#vectorStatus',
|
||||
'url' => '/api/vector-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#chunkContext',
|
||||
'url' => '/api/chunk-context',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#pdfPreview',
|
||||
'url' => '/api/pdf-preview',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
|
||||
// Admin settings routes
|
||||
[
|
||||
'name' => 'api#serverStatus',
|
||||
'url' => '/api/admin/server-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#adminVectorStatus',
|
||||
'url' => '/api/admin/vector-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#saveSearchSettings',
|
||||
'url' => '/api/admin/search-settings',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
|
||||
// Webhook management routes (admin only)
|
||||
[
|
||||
'name' => 'api#getWebhookPresets',
|
||||
'url' => '/api/admin/webhooks/presets',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#enableWebhookPreset',
|
||||
'url' => '/api/admin/webhooks/presets/{presetId}/enable',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'api#disableWebhookPreset',
|
||||
'url' => '/api/admin/webhooks/presets/{presetId}/disable',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
],
|
||||
];
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"name": "nextcloud/astrolabe",
|
||||
"description": "This app provides a management UI for the Nextcloud MCP Server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Chris Coutinho",
|
||||
"email": "chris@coutinho.io",
|
||||
"homepage": "https://github.com/cbcoutinho"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OCA\\Astrolabe\\": "lib/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"OCP\\": "vendor/nextcloud/ocp/OCP/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": [
|
||||
"@composer bin all install --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@composer bin all install --ansi"
|
||||
],
|
||||
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
|
||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"psalm": "psalm --threads=1 --no-cache",
|
||||
"test:unit": "./vendor/bin/phpunit -c tests/unit/phpunit.xml --colors=always",
|
||||
"openapi": "generate-spec",
|
||||
"rector": "rector && composer cs:fix"
|
||||
},
|
||||
"require": {
|
||||
"bamarni/composer-bin-plugin": "^1.8",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^3.8",
|
||||
"nextcloud/ocp": "dev-stable30",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"bamarni/composer-bin-plugin": true
|
||||
},
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
-3305
File diff suppressed because it is too large
Load Diff
-3
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\AppInfo;
|
||||
|
||||
use OCA\Astrolabe\Listener\AstrolabeAdminSettingsListener;
|
||||
use OCA\Astrolabe\Search\SemanticSearchProvider;
|
||||
use OCA\Astrolabe\Settings\AstrolabeAdminSettings;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
|
||||
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'astrolabe';
|
||||
|
||||
/** @psalm-suppress PossiblyUnusedMethod */
|
||||
public function __construct() {
|
||||
parent::__construct(self::APP_ID);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
// Register unified search provider for semantic search
|
||||
$context->registerSearchProvider(SemanticSearchProvider::class);
|
||||
|
||||
// Register declarative admin settings
|
||||
$context->registerDeclarativeSettings(AstrolabeAdminSettings::class);
|
||||
|
||||
// Register event listeners for declarative settings
|
||||
$context->registerEventListener(
|
||||
DeclarativeSettingsGetValueEvent::class,
|
||||
AstrolabeAdminSettingsListener::class
|
||||
);
|
||||
$context->registerEventListener(
|
||||
DeclarativeSettingsSetValueEvent::class,
|
||||
AstrolabeAdminSettingsListener::class
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
<?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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,854 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCA\Astrolabe\Service\WebhookPresets;
|
||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\IConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* API controller for MCP Server UI.
|
||||
*
|
||||
* Handles form submissions and AJAX requests from settings panels.
|
||||
*/
|
||||
class ApiController extends Controller {
|
||||
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,
|
||||
IRequest $request,
|
||||
McpServerClient $client,
|
||||
IUserSession $userSession,
|
||||
IURLGenerator $urlGenerator,
|
||||
LoggerInterface $logger,
|
||||
McpTokenStorage $tokenStorage,
|
||||
IConfig $config,
|
||||
IdpTokenRefresher $tokenRefresher,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->client = $client;
|
||||
$this->userSession = $userSession;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->logger = $logger;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->config = $config;
|
||||
$this->tokenRefresher = $tokenRefresher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke user's background access (delete refresh token).
|
||||
*
|
||||
* Called from personal settings form POST.
|
||||
* Redirects back to personal settings after completion.
|
||||
*
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function revokeAccess(): RedirectResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
// Should not happen (NoAdminRequired ensures user is logged in)
|
||||
$this->logger->error('Revoke access called without authenticated user');
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Get user's OAuth token
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
if (!$token) {
|
||||
$this->logger->error("Cannot revoke access: No token found for user $userId");
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
$accessToken = $token['access_token'];
|
||||
|
||||
// Call MCP server API to revoke access
|
||||
$result = $this->client->revokeUserAccess($userId, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$this->logger->error("Failed to revoke access for user $userId", [
|
||||
'error' => $result['error']
|
||||
]);
|
||||
// TODO: Add flash message/notification for user feedback
|
||||
} else {
|
||||
$this->logger->info("Successfully revoked background access for user $userId");
|
||||
|
||||
// Delete local OAuth tokens from Nextcloud config
|
||||
// This ensures hasBackgroundAccess() returns false on next page load
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
$this->logger->debug("Deleted local OAuth tokens for user $userId");
|
||||
|
||||
// TODO: Add success flash message/notification
|
||||
}
|
||||
|
||||
// Redirect back to personal settings
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search via MCP server.
|
||||
*
|
||||
* AJAX endpoint for vector search UI in app page.
|
||||
* Uses user's OAuth token for authentication.
|
||||
*
|
||||
* @param string $query Search query
|
||||
* @param string $algorithm Search algorithm (semantic, bm25, hybrid)
|
||||
* @param int $limit Number of results (max 50)
|
||||
* @param string $doc_types Comma-separated document types (e.g., "note,file")
|
||||
* @param string $include_pca Whether to include PCA coordinates for visualization
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function search(
|
||||
string $query = '',
|
||||
string $algorithm = 'hybrid',
|
||||
int $limit = 10,
|
||||
string $doc_types = '',
|
||||
string $include_pca = 'true',
|
||||
): JSONResponse {
|
||||
if (empty($query)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Missing required parameter: query'
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get current user
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback that calls IdP directly
|
||||
/** @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. Please authorize the app first.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Validate algorithm
|
||||
$validAlgorithms = ['semantic', 'bm25', 'hybrid'];
|
||||
if (!in_array($algorithm, $validAlgorithms)) {
|
||||
$algorithm = 'hybrid';
|
||||
}
|
||||
|
||||
// Enforce limit bounds
|
||||
$limit = max(1, min($limit, 50));
|
||||
|
||||
// Parse doc_types filter
|
||||
$docTypesArray = null;
|
||||
if (!empty($doc_types)) {
|
||||
$validDocTypes = ['note', 'file', 'deck_card', 'calendar', 'contact', 'news_item'];
|
||||
$docTypesArray = array_filter(
|
||||
explode(',', $doc_types),
|
||||
fn ($t) => in_array(trim($t), $validDocTypes)
|
||||
);
|
||||
$docTypesArray = array_map('trim', $docTypesArray);
|
||||
if (empty($docTypesArray)) {
|
||||
$docTypesArray = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse include_pca (string "true"/"false" from query params)
|
||||
$includePcaBool = in_array(strtolower($include_pca), ['true', '1', 'yes'], true);
|
||||
|
||||
// Execute search via MCP server with OAuth token
|
||||
$result = $this->client->search($query, $algorithm, $limit, $includePcaBool, $docTypesArray, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $result['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'results' => $result['results'] ?? [],
|
||||
'algorithm_used' => $result['algorithm_used'] ?? $algorithm,
|
||||
'total_documents' => $result['total_documents'] ?? 0,
|
||||
];
|
||||
|
||||
// Include PCA visualization coordinates if requested and available
|
||||
if ($includePcaBool) {
|
||||
$response['coordinates_3d'] = $result['coordinates_3d'] ?? [];
|
||||
$response['query_coords'] = $result['query_coords'] ?? [];
|
||||
if (isset($result['pca_variance'])) {
|
||||
$response['pca_variance'] = $result['pca_variance'];
|
||||
}
|
||||
}
|
||||
|
||||
return new JSONResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status from MCP server.
|
||||
*
|
||||
* AJAX endpoint for status refresh in personal settings.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function vectorStatus(): JSONResponse {
|
||||
$status = $this->client->getVectorSyncStatus();
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP server status.
|
||||
*
|
||||
* Admin-only endpoint for admin settings page.
|
||||
* Returns server version, uptime, and vector sync availability.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function serverStatus(): JSONResponse {
|
||||
$status = $this->client->getStatus();
|
||||
|
||||
// Validate that status is an array before accessing
|
||||
if (!is_array($status)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid response from MCP server'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status for admin.
|
||||
*
|
||||
* Admin-only endpoint for admin settings page.
|
||||
* Returns indexing metrics and sync status.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function adminVectorStatus(): JSONResponse {
|
||||
$status = $this->client->getVectorSyncStatus();
|
||||
|
||||
// Validate that status is an array before accessing
|
||||
if (!is_array($status)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid response from MCP server'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save admin search settings.
|
||||
*
|
||||
* Admin-only endpoint to configure AI Search provider parameters.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function saveSearchSettings(): JSONResponse {
|
||||
// Parse JSON body
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
if ($data === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid JSON body'
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Validate and save algorithm
|
||||
$validAlgorithms = ['hybrid', 'semantic', 'bm25'];
|
||||
$algorithm = $data['algorithm'] ?? AdminSettings::DEFAULT_SEARCH_ALGORITHM;
|
||||
if (!in_array($algorithm, $validAlgorithms)) {
|
||||
$algorithm = AdminSettings::DEFAULT_SEARCH_ALGORITHM;
|
||||
}
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_ALGORITHM,
|
||||
$algorithm
|
||||
);
|
||||
|
||||
// Validate and save fusion method
|
||||
$validFusions = ['rrf', 'dbsf'];
|
||||
$fusion = $data['fusion'] ?? AdminSettings::DEFAULT_SEARCH_FUSION;
|
||||
if (!in_array($fusion, $validFusions)) {
|
||||
$fusion = AdminSettings::DEFAULT_SEARCH_FUSION;
|
||||
}
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_FUSION,
|
||||
$fusion
|
||||
);
|
||||
|
||||
// Validate and save score threshold (0-100)
|
||||
$scoreThreshold = (int)($data['scoreThreshold'] ?? AdminSettings::DEFAULT_SEARCH_SCORE_THRESHOLD);
|
||||
$scoreThreshold = max(0, min(100, $scoreThreshold));
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_SCORE_THRESHOLD,
|
||||
(string)$scoreThreshold
|
||||
);
|
||||
|
||||
// Validate and save limit (5-100)
|
||||
$limit = (int)($data['limit'] ?? AdminSettings::DEFAULT_SEARCH_LIMIT);
|
||||
$limit = max(5, min(100, $limit));
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_LIMIT,
|
||||
(string)$limit
|
||||
);
|
||||
|
||||
$this->logger->info('Admin search settings saved', [
|
||||
'algorithm' => $algorithm,
|
||||
'fusion' => $fusion,
|
||||
'scoreThreshold' => $scoreThreshold,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'settings' => [
|
||||
'algorithm' => $algorithm,
|
||||
'fusion' => $fusion,
|
||||
'scoreThreshold' => $scoreThreshold,
|
||||
'limit' => $limit,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available webhook presets.
|
||||
*
|
||||
* Admin-only endpoint that lists webhook presets filtered by installed apps.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function getWebhookPresets(): JSONResponse {
|
||||
// Get admin's OAuth token for API calls
|
||||
$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 access token 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);
|
||||
}
|
||||
|
||||
// Get installed apps to filter presets
|
||||
$installedAppsResult = $this->client->getInstalledApps($accessToken);
|
||||
if (isset($installedAppsResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $installedAppsResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$installedApps = $installedAppsResult['apps'] ?? [];
|
||||
|
||||
// Get registered webhooks to check preset status
|
||||
$webhooksResult = $this->client->listWebhooks($accessToken);
|
||||
if (isset($webhooksResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $webhooksResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
|
||||
|
||||
// Filter presets by installed apps
|
||||
$presets = WebhookPresets::filterPresetsByInstalledApps($installedApps);
|
||||
|
||||
// Add enabled status to each preset
|
||||
// IMPORTANT: Match both event type AND filter to avoid false positives
|
||||
// (e.g., Notes and Files both use FILE_EVENT_* but with different filters)
|
||||
$presetsWithStatus = [];
|
||||
foreach ($presets as $presetId => $preset) {
|
||||
// Check if all events for this preset are registered with matching filters
|
||||
$allEventsRegistered = true;
|
||||
foreach ($preset['events'] as $presetEvent) {
|
||||
$eventMatched = false;
|
||||
foreach ($registeredWebhooks as $webhook) {
|
||||
// Match event type
|
||||
if ($webhook['event'] !== $presetEvent['event']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match filter (both must have filter or both must not have filter)
|
||||
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
|
||||
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
|
||||
|
||||
// Compare filters (use json_encode for deep comparison)
|
||||
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
|
||||
$eventMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$eventMatched) {
|
||||
$allEventsRegistered = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$presetsWithStatus[$presetId] = array_merge($preset, [
|
||||
'enabled' => $allEventsRegistered
|
||||
]);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'presets' => $presetsWithStatus
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a webhook preset.
|
||||
*
|
||||
* Admin-only endpoint that registers all webhooks for a preset.
|
||||
*
|
||||
* @param string $presetId Preset ID to enable
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function enableWebhookPreset(string $presetId): JSONResponse {
|
||||
// Get admin's OAuth token
|
||||
$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 access token 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);
|
||||
}
|
||||
|
||||
// Get preset configuration
|
||||
$preset = WebhookPresets::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => "Unknown preset: $presetId"
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get MCP server URL for webhook callback URI
|
||||
$mcpServerUrl = $this->client->getServerUrl();
|
||||
$callbackUri = $mcpServerUrl . '/api/v1/webhooks/callback';
|
||||
|
||||
// Register each event in the preset
|
||||
$registered = [];
|
||||
$errors = [];
|
||||
foreach ($preset['events'] as $eventConfig) {
|
||||
$result = $this->client->createWebhook(
|
||||
$eventConfig['event'],
|
||||
$callbackUri,
|
||||
!empty($eventConfig['filter']) ? $eventConfig['filter'] : null,
|
||||
$accessToken
|
||||
);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = [
|
||||
'event' => $eventConfig['event'],
|
||||
'error' => $result['error']
|
||||
];
|
||||
} else {
|
||||
$registered[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to register some webhooks',
|
||||
'registered' => $registered,
|
||||
'errors' => $errors
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$this->logger->info("Enabled webhook preset $presetId for user $userId", [
|
||||
'preset_id' => $presetId,
|
||||
'webhooks_registered' => count($registered)
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => "Enabled {$preset['name']}",
|
||||
'webhooks' => $registered
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a webhook preset.
|
||||
*
|
||||
* Admin-only endpoint that deletes all webhooks for a preset.
|
||||
*
|
||||
* @param string $presetId Preset ID to disable
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function disableWebhookPreset(string $presetId): JSONResponse {
|
||||
// Get admin's OAuth token
|
||||
$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 access token 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);
|
||||
}
|
||||
|
||||
// Get preset configuration
|
||||
$preset = WebhookPresets::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => "Unknown preset: $presetId"
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get all registered webhooks
|
||||
$webhooksResult = $this->client->listWebhooks($accessToken);
|
||||
if (isset($webhooksResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $webhooksResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
|
||||
|
||||
// Find webhooks that match this preset's events AND filters
|
||||
// IMPORTANT: Must match both event type AND filter to avoid deleting
|
||||
// webhooks from other presets (e.g., Notes vs Files both use FILE_EVENT_*)
|
||||
$webhooksToDelete = [];
|
||||
foreach ($registeredWebhooks as $webhook) {
|
||||
// Check if this webhook matches any event in the preset
|
||||
foreach ($preset['events'] as $presetEvent) {
|
||||
// Match event type
|
||||
if ($webhook['event'] !== $presetEvent['event']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match filter (both must have filter or both must not have filter)
|
||||
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
|
||||
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
|
||||
|
||||
// Compare filters (use json_encode for deep comparison)
|
||||
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
|
||||
$webhooksToDelete[] = $webhook;
|
||||
break; // This webhook matches, no need to check other preset events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete each matching webhook
|
||||
$deleted = [];
|
||||
$errors = [];
|
||||
foreach ($webhooksToDelete as $webhook) {
|
||||
$result = $this->client->deleteWebhook($webhook['id'], $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = [
|
||||
'webhook_id' => $webhook['id'],
|
||||
'event' => $webhook['event'],
|
||||
'error' => $result['error']
|
||||
];
|
||||
} else {
|
||||
$deleted[] = $webhook['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to delete some webhooks',
|
||||
'deleted' => $deleted,
|
||||
'errors' => $errors
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$this->logger->info("Disabled webhook preset $presetId for user $userId", [
|
||||
'preset_id' => $presetId,
|
||||
'webhooks_deleted' => count($deleted)
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => "Disabled {$preset['name']}",
|
||||
'deleted' => $deleted
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk context for visualization.
|
||||
*
|
||||
* @param string $doc_type Document type
|
||||
* @param string $doc_id Document ID
|
||||
* @param int $start Start offset
|
||||
* @param int $end End offset
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function chunkContext(
|
||||
string $doc_type,
|
||||
string $doc_id,
|
||||
int $start,
|
||||
int $end,
|
||||
): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['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->getChunkContext($doc_type, $doc_id, $start, $end, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Controller for managing background sync credentials (app passwords).
|
||||
*
|
||||
* Handles storing and validating app passwords for multi-user BasicAuth mode.
|
||||
*/
|
||||
class CredentialsController extends Controller {
|
||||
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,
|
||||
IRequest $request,
|
||||
McpTokenStorage $tokenStorage,
|
||||
IUserSession $userSession,
|
||||
LoggerInterface $logger,
|
||||
IConfig $config,
|
||||
McpServerClient $client,
|
||||
IClientService $httpClientService,
|
||||
IURLGenerator $urlGenerator,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->userSession = $userSession;
|
||||
$this->logger = $logger;
|
||||
$this->config = $config;
|
||||
$this->client = $client;
|
||||
$this->httpClientService = $httpClientService;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store app password for background sync.
|
||||
*
|
||||
* Validates the app password by making a test request to Nextcloud,
|
||||
* then stores it encrypted if valid.
|
||||
*
|
||||
* @param string $appPassword Nextcloud app password
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function storeAppPassword(string $appPassword): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
$this->logger->error('storeAppPassword called without authenticated user');
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
if (!preg_match('/^[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}$/', $appPassword)) {
|
||||
$this->logger->warning("Invalid app password format for user: $userId");
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid app password format'
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Validate app password with Nextcloud
|
||||
$isValid = $this->validateAppPassword($userId, $appPassword);
|
||||
|
||||
if (!$isValid) {
|
||||
$this->logger->warning("App password validation failed for user: $userId");
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid app password. Please check the password and try again.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Store encrypted app password locally in Nextcloud
|
||||
try {
|
||||
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||
$this->logger->info("Stored app password locally for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$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 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate app password by making a test request to Nextcloud.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param string $appPassword App password to validate
|
||||
* @return bool True if valid, false otherwise
|
||||
*/
|
||||
private function validateAppPassword(string $userId, string $appPassword): bool {
|
||||
try {
|
||||
// Use 127.0.0.1 for internal validation (we're running inside Nextcloud container)
|
||||
// Using IP address instead of 'localhost' to avoid Nextcloud's overwrite.cli.url rewriting
|
||||
// getAbsoluteURL() returns the external URL which isn't accessible from inside the container
|
||||
$baseUrl = 'http://127.0.0.1';
|
||||
|
||||
// Make a test request to Nextcloud API with BasicAuth
|
||||
// Using OCS API user endpoint as a lightweight test
|
||||
$testUrl = $baseUrl . '/ocs/v1.php/cloud/user?format=json';
|
||||
|
||||
$this->logger->debug("Validating app password for user: $userId against $testUrl");
|
||||
|
||||
// Use Nextcloud's HTTP client
|
||||
$httpClient = $this->httpClientService->newClient();
|
||||
|
||||
$response = $httpClient->get($testUrl, [
|
||||
'auth' => [$userId, $appPassword],
|
||||
'headers' => [
|
||||
'OCS-APIRequest' => 'true',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
// Success is 200 OK
|
||||
if ($statusCode === 200) {
|
||||
$this->logger->debug("App password validation successful for user: $userId");
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logger->warning("App password validation failed for user: $userId (HTTP $statusCode)");
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Exception during app password validation for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background sync credentials status for the current user.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function getStatus(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'has_background_access' => $hasAccess,
|
||||
'sync_type' => $syncType,
|
||||
'provisioned_at' => $provisionedAt,
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credentials for a specific user (admin only).
|
||||
*
|
||||
* Note: This does NOT return the actual password, only metadata.
|
||||
*
|
||||
* @param string $userId User ID to check
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function getCredentials(string $userId): JSONResponse {
|
||||
// This endpoint should only be accessible by admins
|
||||
// For now, just return metadata (not actual credentials)
|
||||
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'user_id' => $userId,
|
||||
'has_background_access' => $hasAccess,
|
||||
'sync_type' => $syncType,
|
||||
'provisioned_at' => $provisionedAt,
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete background sync credentials for the current user.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function deleteCredentials(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
try {
|
||||
// Delete both OAuth tokens and app password (if any exist)
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
$this->tokenStorage->deleteBackgroundSyncPassword($userId);
|
||||
|
||||
$this->logger->info("Deleted background sync credentials for user: $userId");
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => 'Credentials deleted successfully'
|
||||
], Http::STATUS_OK);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to delete credentials for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to delete credentials'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
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;
|
||||
use OCP\IRequest;
|
||||
use OCP\ISession;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* OAuth controller for MCP Server UI.
|
||||
*
|
||||
* Implements OAuth 2.0 Authorization Code flow with PKCE (RFC 9207).
|
||||
* PKCE is always used for all clients (public and confidential) as recommended
|
||||
* by modern OAuth 2.0 best practices.
|
||||
*
|
||||
* - Public clients: PKCE only
|
||||
* - Confidential clients: PKCE + client_secret (defense in depth)
|
||||
*/
|
||||
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,
|
||||
IRequest $request,
|
||||
IConfig $config,
|
||||
ISession $session,
|
||||
IUserSession $userSession,
|
||||
IURLGenerator $urlGenerator,
|
||||
McpTokenStorage $tokenStorage,
|
||||
LoggerInterface $logger,
|
||||
IL10N $l,
|
||||
IClientService $clientService,
|
||||
McpServerClient $client,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->config = $config;
|
||||
$this->session = $session;
|
||||
$this->userSession = $userSession;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->logger = $logger;
|
||||
$this->l = $l;
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth authorization flow.
|
||||
*
|
||||
* Always generates PKCE code verifier and challenge (RFC 9207).
|
||||
* Stores state and code verifier in session, then redirects user to IdP authorization endpoint.
|
||||
*
|
||||
* @return RedirectResponse|TemplateResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function initiateOAuth() {
|
||||
$this->logger->info('initiateOAuth called');
|
||||
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
$this->logger->error('initiateOAuth: User not authenticated');
|
||||
return new TemplateResponse(
|
||||
'astrolabe',
|
||||
'settings/error',
|
||||
['error' => $this->l->t('User not authenticated')]
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->info('initiateOAuth: User authenticated: ' . $user->getUID());
|
||||
|
||||
try {
|
||||
// Get MCP server configuration
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Always generate PKCE values (RFC 9207: PKCE recommended for all clients)
|
||||
$codeVerifier = bin2hex(random_bytes(32));
|
||||
$codeChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
|
||||
|
||||
// Check if confidential client secret is also configured
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
$isConfidentialClient = !empty($clientSecret);
|
||||
|
||||
if ($isConfidentialClient) {
|
||||
$this->logger->info('Using confidential client mode with PKCE and client secret');
|
||||
} else {
|
||||
$this->logger->info('Using public client mode with PKCE only');
|
||||
}
|
||||
|
||||
// Generate state for CSRF protection
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
// Store values in session
|
||||
$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
|
||||
$this->session->set('mcp_oauth_state', $state);
|
||||
$this->session->set('mcp_oauth_user_id', $user->getUID());
|
||||
|
||||
// Build OAuth authorization URL
|
||||
$authUrl = $this->buildAuthorizationUrl(
|
||||
$mcpServerUrl,
|
||||
$state,
|
||||
$codeChallenge
|
||||
);
|
||||
|
||||
$this->logger->info('Initiating OAuth flow for user: ' . $user->getUID());
|
||||
|
||||
return new RedirectResponse($authUrl);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to initiate OAuth flow', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return new TemplateResponse(
|
||||
'astrolabe',
|
||||
'settings/error',
|
||||
['error' => $this->l->t('Failed to initiate OAuth: %s', [$e->getMessage()])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback after user authorization.
|
||||
*
|
||||
* Validates state, exchanges authorization code for access token using PKCE,
|
||||
* and stores tokens for the user.
|
||||
*
|
||||
* @param string $code Authorization code
|
||||
* @param string $state State parameter for CSRF protection
|
||||
* @param string|null $error Error from IdP
|
||||
* @param string|null $error_description Error description from IdP
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function oauthCallback(
|
||||
string $code = '',
|
||||
string $state = '',
|
||||
?string $error = null,
|
||||
?string $error_description = null,
|
||||
): RedirectResponse {
|
||||
try {
|
||||
// Check for errors from IdP
|
||||
if ($error) {
|
||||
throw new \Exception("OAuth error: $error - " . ($error_description ?? ''));
|
||||
}
|
||||
|
||||
// Validate state to prevent CSRF
|
||||
$storedState = $this->session->get('mcp_oauth_state');
|
||||
if (empty($storedState) || $state !== $storedState) {
|
||||
throw new \Exception('Invalid state parameter (CSRF protection)');
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier (always required)
|
||||
$codeVerifier = $this->session->get('mcp_oauth_code_verifier');
|
||||
if (empty($codeVerifier)) {
|
||||
throw new \Exception('PKCE code verifier not found in session');
|
||||
}
|
||||
|
||||
// Get user ID from session
|
||||
$userId = $this->session->get('mcp_oauth_user_id');
|
||||
if (empty($userId)) {
|
||||
throw new \Exception('User ID not found in session');
|
||||
}
|
||||
|
||||
// Get MCP server configuration
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
$tokenData = $this->exchangeCodeForToken(
|
||||
$mcpServerUrl,
|
||||
$code,
|
||||
$codeVerifier
|
||||
);
|
||||
|
||||
// Store tokens for user
|
||||
$this->tokenStorage->storeUserToken(
|
||||
$userId,
|
||||
$tokenData['access_token'],
|
||||
$tokenData['refresh_token'] ?? '',
|
||||
time() + ($tokenData['expires_in'] ?? 3600)
|
||||
);
|
||||
|
||||
// Clean up session
|
||||
$this->session->remove('mcp_oauth_code_verifier');
|
||||
$this->session->remove('mcp_oauth_state');
|
||||
$this->session->remove('mcp_oauth_user_id');
|
||||
|
||||
$this->logger->info("OAuth flow completed successfully for user: $userId");
|
||||
|
||||
// Redirect back to personal settings
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('OAuth callback failed', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
// Clean up session
|
||||
$this->session->remove('mcp_oauth_code_verifier');
|
||||
$this->session->remove('mcp_oauth_state');
|
||||
$this->session->remove('mcp_oauth_user_id');
|
||||
|
||||
// Redirect to settings with error
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', [
|
||||
'section' => 'astrolabe',
|
||||
'error' => urlencode($e->getMessage())
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect user's MCP OAuth tokens.
|
||||
*
|
||||
* Deletes stored tokens from Nextcloud. Note: Does not revoke tokens on IdP side.
|
||||
*
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function disconnect(): RedirectResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
try {
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
$this->logger->info("Disconnected MCP OAuth for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to disconnect MCP OAuth for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OAuth authorization URL.
|
||||
*
|
||||
* Queries MCP server for IdP configuration, then performs OIDC discovery
|
||||
* to find the authorization endpoint. Supports both Nextcloud OIDC and
|
||||
* external IdPs like Keycloak.
|
||||
*
|
||||
* Always uses PKCE (RFC 9207 recommends PKCE for all clients).
|
||||
*
|
||||
* @param string $mcpServerUrl Base URL of MCP server
|
||||
* @param string $state CSRF state parameter
|
||||
* @param string $codeChallenge PKCE code challenge
|
||||
* @return string Authorization URL
|
||||
* @throws \Exception if OIDC discovery fails
|
||||
*/
|
||||
private function buildAuthorizationUrl(
|
||||
string $mcpServerUrl,
|
||||
string $state,
|
||||
string $codeChallenge,
|
||||
): string {
|
||||
// First, query MCP server to discover which IdP it's configured to use
|
||||
$this->logger->info('buildAuthorizationUrl: Starting', [
|
||||
'mcp_server_url' => $mcpServerUrl,
|
||||
]);
|
||||
|
||||
try {
|
||||
$statusUrl = $mcpServerUrl . '/api/v1/status';
|
||||
$this->logger->info('buildAuthorizationUrl: Fetching MCP server status', [
|
||||
'url' => $statusUrl,
|
||||
]);
|
||||
|
||||
$statusResponse = $this->httpClient->get($statusUrl);
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON in status response: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
$this->logger->info('buildAuthorizationUrl: MCP server status received', [
|
||||
'auth_mode' => $statusData['auth_mode'] ?? 'unknown',
|
||||
'has_oidc' => isset($statusData['oidc']),
|
||||
'oidc_discovery_url' => $statusData['oidc']['discovery_url'] ?? 'not_set',
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('buildAuthorizationUrl: Failed to fetch MCP server status', [
|
||||
'url' => $mcpServerUrl . '/api/v1/status',
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL
|
||||
// Priority: 1) MCP server's configured discovery URL, 2) Nextcloud OIDC app
|
||||
if (isset($statusData['oidc']['discovery_url'])) {
|
||||
// MCP server has external IdP configured (e.g., Keycloak)
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
$this->logger->info('Using IdP from MCP server configuration', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
} else {
|
||||
// Fall back to Nextcloud's OIDC app
|
||||
// Use internal localhost URL for HTTP request (accessible from inside container)
|
||||
// We'll transform the returned URLs to external format after discovery
|
||||
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
|
||||
$internalBaseUrl = 'http://localhost';
|
||||
|
||||
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
// Perform OIDC discovery
|
||||
$this->logger->info('buildAuthorizationUrl: Starting OIDC discovery', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->get($discoveryUrl);
|
||||
$responseBody = $response->getBody();
|
||||
$this->logger->info('buildAuthorizationUrl: Got OIDC discovery response', [
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'body_length' => strlen($responseBody),
|
||||
]);
|
||||
|
||||
$discovery = json_decode($responseBody, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON in OIDC discovery: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
if (!isset($discovery['authorization_endpoint'])) {
|
||||
throw new \RuntimeException('Missing authorization_endpoint in OIDC discovery');
|
||||
}
|
||||
|
||||
$authEndpoint = $discovery['authorization_endpoint'];
|
||||
|
||||
// Transform internal URL to external URL if using Nextcloud OIDC app
|
||||
// The discovery was done via internal http://localhost but browsers need
|
||||
// the external URL (e.g., http://localhost:8080)
|
||||
if (isset($internalBaseUrl)) {
|
||||
$externalBaseUrl = $this->urlGenerator->getAbsoluteURL('/');
|
||||
$externalBaseUrl = rtrim($externalBaseUrl, '/');
|
||||
$authEndpoint = str_replace($internalBaseUrl, $externalBaseUrl, $authEndpoint);
|
||||
}
|
||||
|
||||
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
|
||||
'auth_endpoint' => $authEndpoint,
|
||||
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('buildAuthorizationUrl: OIDC discovery failed', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw new \Exception('Failed to discover OAuth endpoints: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Build callback URL
|
||||
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
|
||||
'astrolabe.oauth.oauthCallback'
|
||||
);
|
||||
|
||||
// Get public MCP server URL for token audience (RFC 8707 Resource Indicator)
|
||||
// Use public URL that clients/browsers see, not internal Docker URL
|
||||
$mcpServerPublicUrl = $this->config->getSystemValue('mcp_server_public_url', $mcpServerUrl);
|
||||
|
||||
// Build authorization URL parameters
|
||||
$params = [
|
||||
'client_id' => $this->client->getClientId(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid profile email offline_access', // Request MCP scopes
|
||||
'state' => $state,
|
||||
'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience
|
||||
];
|
||||
|
||||
// Add PKCE parameters (always required per RFC 9207)
|
||||
$params['code_challenge'] = $codeChallenge;
|
||||
$params['code_challenge_method'] = 'S256';
|
||||
|
||||
return $authEndpoint . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token.
|
||||
*
|
||||
* Always uses PKCE code_verifier (RFC 9207).
|
||||
* For confidential clients: Also includes client_secret for additional security.
|
||||
* For public clients: Uses PKCE code_verifier only.
|
||||
*
|
||||
* Queries MCP server for IdP configuration, then performs OIDC discovery
|
||||
* to find the token endpoint. Supports both Nextcloud OIDC and external IdPs.
|
||||
*
|
||||
* @param string $mcpServerUrl Base URL of MCP server
|
||||
* @param string $code Authorization code
|
||||
* @param string $codeVerifier PKCE code verifier
|
||||
* @return array Token data containing access_token, refresh_token, expires_in
|
||||
* @throws \Exception on HTTP or token error
|
||||
*/
|
||||
private function exchangeCodeForToken(
|
||||
string $mcpServerUrl,
|
||||
string $code,
|
||||
string $codeVerifier,
|
||||
): array {
|
||||
// Query MCP server to discover which IdP it's configured to use
|
||||
try {
|
||||
$statusResponse = $this->httpClient->get($mcpServerUrl . '/api/v1/status');
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid status response from MCP server');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to fetch MCP server status during token exchange', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL and token endpoint
|
||||
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
|
||||
|
||||
if (!$useInternalNextcloud) {
|
||||
// External IdP configured - use discovery
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->get($discoveryUrl);
|
||||
$discovery = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
|
||||
throw new \RuntimeException('Invalid OIDC discovery response');
|
||||
}
|
||||
|
||||
$tokenEndpoint = $discovery['token_endpoint'];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('OIDC discovery failed during token exchange', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw new \Exception('Failed to discover token endpoint: ' . $e->getMessage());
|
||||
}
|
||||
} else {
|
||||
// Nextcloud's OIDC app - use internal URL directly (no HTTP request needed)
|
||||
// This avoids network issues when overwritehost includes external port
|
||||
$tokenEndpoint = 'http://localhost/apps/oidc/token';
|
||||
}
|
||||
|
||||
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
|
||||
'astrolabe.oauth.oauthCallback'
|
||||
);
|
||||
|
||||
// Build token request parameters
|
||||
$postData = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'client_id' => $this->client->getClientId(),
|
||||
];
|
||||
|
||||
// Always include PKCE code verifier (RFC 9207)
|
||||
$postData['code_verifier'] = $codeVerifier;
|
||||
|
||||
// Also include client secret if configured (defense in depth for confidential clients)
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
if (!empty($clientSecret)) {
|
||||
$postData['client_secret'] = $clientSecret;
|
||||
$this->logger->info('Using PKCE with client secret for token exchange');
|
||||
} else {
|
||||
$this->logger->info('Using PKCE only for token exchange');
|
||||
}
|
||||
|
||||
// Use Nextcloud's HTTP client for token request
|
||||
try {
|
||||
$response = $this->httpClient->post($tokenEndpoint, [
|
||||
'body' => http_build_query($postData),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
|
||||
throw new \RuntimeException('Invalid token response from server');
|
||||
}
|
||||
|
||||
return $tokenData;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Token exchange failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'token_endpoint' => $tokenEndpoint,
|
||||
]);
|
||||
throw new \Exception('Token exchange failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL-safe encoding (for PKCE).
|
||||
*
|
||||
* @param string $data Data to encode
|
||||
* @return string Base64 URL-encoded string
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedClass
|
||||
*/
|
||||
class PageController extends Controller {
|
||||
#[NoCSRFRequired]
|
||||
#[NoAdminRequired]
|
||||
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/')]
|
||||
public function index(): TemplateResponse {
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'index',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Listener;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\IConfig;
|
||||
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
|
||||
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent>
|
||||
*/
|
||||
class AstrolabeAdminSettingsListener implements IEventListener {
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof DeclarativeSettingsGetValueEvent && !$event instanceof DeclarativeSettingsSetValueEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getApp() !== Application::APP_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getFormId() !== 'astrolabe-admin-settings') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof DeclarativeSettingsGetValueEvent) {
|
||||
$this->handleGetValue($event);
|
||||
} elseif ($event instanceof DeclarativeSettingsSetValueEvent) {
|
||||
$this->handleSetValue($event);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void {
|
||||
$fieldId = $event->getFieldId();
|
||||
|
||||
// Map field IDs to system config keys
|
||||
$value = match($fieldId) {
|
||||
'mcp_server_url' => $this->config->getSystemValue('mcp_server_url', ''),
|
||||
'mcp_server_api_key' => '****', // Never leak the API key on read
|
||||
'astrolabe_client_id' => $this->config->getSystemValue('astrolabe_client_id', ''),
|
||||
'astrolabe_client_secret' => '****', // Never leak the secret on read
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($value !== null) {
|
||||
$event->setValue($value);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleSetValue(DeclarativeSettingsSetValueEvent $event): void {
|
||||
$fieldId = $event->getFieldId();
|
||||
$value = $event->getValue();
|
||||
|
||||
// Only save if value is not empty (allow clearing by setting to empty string)
|
||||
// For password fields, if the value is '****', don't update (user didn't change it)
|
||||
if ($fieldId === 'mcp_server_api_key' && $value === '****') {
|
||||
$event->stopPropagation();
|
||||
return;
|
||||
}
|
||||
if ($fieldId === 'astrolabe_client_secret' && $value === '****') {
|
||||
$event->stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
match($fieldId) {
|
||||
'mcp_server_url' => $this->config->setSystemValue('mcp_server_url', (string)$value),
|
||||
'mcp_server_api_key' => $this->config->setSystemValue('mcp_server_api_key', (string)$value),
|
||||
'astrolabe_client_id' => $this->config->setSystemValue('astrolabe_client_id', (string)$value),
|
||||
'astrolabe_client_secret' => $this->config->setSystemValue('astrolabe_client_secret', (string)$value),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$this->logger->info('Astrolabe admin setting updated', [
|
||||
'field' => $fieldId,
|
||||
'app' => Application::APP_ID,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to update Astrolabe admin setting', [
|
||||
'field' => $fieldId,
|
||||
'error' => $e->getMessage(),
|
||||
'app' => Application::APP_ID,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$event->stopPropagation();
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use OCP\Files\FileInfo;
|
||||
use OCP\Files\IMimeTypeDetector;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IPreview;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\Search\IProvider;
|
||||
use OCP\Search\ISearchQuery;
|
||||
use OCP\Search\SearchResult;
|
||||
use OCP\Search\SearchResultEntry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unified Search provider for MCP Server semantic search.
|
||||
*
|
||||
* Delegates search queries to the MCP server's vector search API,
|
||||
* returning semantically relevant results from indexed Nextcloud content
|
||||
* (notes, files, calendar, deck cards).
|
||||
*
|
||||
* Security: Results are filtered server-side to only include documents
|
||||
* owned by the searching user. User identity comes from OAuth token.
|
||||
*/
|
||||
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,
|
||||
private IMimeTypeDetector $mimeTypeDetector,
|
||||
private IPreview $previewManager,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique identifier for this search provider.
|
||||
*/
|
||||
public function getId(): string {
|
||||
return Application::APP_ID . '_semantic';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name shown in search results grouping.
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->l10n->t('Astrolabe');
|
||||
}
|
||||
|
||||
/**
|
||||
* Order in search results. Lower = higher priority.
|
||||
* Use negative value when user is in our app's context.
|
||||
*/
|
||||
public function getOrder(string $route, array $routeParameters): int {
|
||||
if (str_contains($route, Application::APP_ID)) {
|
||||
return -1; // Prioritize when in Astrolabe app
|
||||
}
|
||||
return 40; // Above most apps, below files/mail
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search via MCP server.
|
||||
*
|
||||
* SECURITY: Results are filtered server-side to only include documents
|
||||
* owned by the searching user. User identity comes from OAuth token.
|
||||
*/
|
||||
public function search(IUser $user, ISearchQuery $query): SearchResult {
|
||||
$term = $query->getTerm();
|
||||
$limit = $query->getLimit();
|
||||
$cursor = $query->getCursor();
|
||||
|
||||
// Skip empty queries
|
||||
if (empty(trim($term))) {
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
$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' => $userId,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Check if MCP server is available and vector sync enabled
|
||||
$status = $this->client->getStatus();
|
||||
if (!empty($status['error']) || !($status['vector_sync_enabled'] ?? false)) {
|
||||
$this->logger->debug('MCP server not available or vector sync disabled', [
|
||||
'status' => $status,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Load admin search settings
|
||||
$algorithm = $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_ALGORITHM,
|
||||
AdminSettings::DEFAULT_SEARCH_ALGORITHM
|
||||
);
|
||||
$fusion = $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_FUSION,
|
||||
AdminSettings::DEFAULT_SEARCH_FUSION
|
||||
);
|
||||
$scoreThreshold = (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_SCORE_THRESHOLD,
|
||||
(string)AdminSettings::DEFAULT_SEARCH_SCORE_THRESHOLD
|
||||
);
|
||||
$configuredLimit = (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_LIMIT,
|
||||
(string)AdminSettings::DEFAULT_SEARCH_LIMIT
|
||||
);
|
||||
|
||||
// Use configured limit if query limit is higher
|
||||
$effectiveLimit = min($limit, $configuredLimit);
|
||||
|
||||
// Calculate offset from cursor
|
||||
$offset = $cursor ? (int)$cursor : 0;
|
||||
|
||||
// Execute semantic search with OAuth token and admin settings
|
||||
// Server extracts user_id from token - results filtered to that user's documents
|
||||
$results = $this->client->searchForUnifiedSearch(
|
||||
query: $term,
|
||||
token: $accessToken,
|
||||
limit: $effectiveLimit,
|
||||
offset: $offset,
|
||||
algorithm: $algorithm,
|
||||
fusion: $fusion,
|
||||
scoreThreshold: $scoreThreshold / 100.0, // Convert percentage to 0-1 range
|
||||
);
|
||||
|
||||
if (!empty($results['error'])) {
|
||||
$this->logger->warning('Semantic search failed', [
|
||||
'error' => $results['error'],
|
||||
'query' => $term,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Transform results to SearchResultEntry objects
|
||||
$entries = [];
|
||||
foreach ($results['results'] ?? [] as $result) {
|
||||
$entries[] = $this->transformResult($result);
|
||||
}
|
||||
|
||||
// Return paginated if more results might exist
|
||||
$totalFound = $results['total_found'] ?? count($entries);
|
||||
if (count($entries) >= $effectiveLimit && $totalFound > $offset + $effectiveLimit) {
|
||||
return SearchResult::paginated(
|
||||
$this->getName(),
|
||||
$entries,
|
||||
(string)($offset + $effectiveLimit)
|
||||
);
|
||||
}
|
||||
|
||||
return SearchResult::complete($this->getName(), $entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform MCP search result to Nextcloud SearchResultEntry.
|
||||
*/
|
||||
private function transformResult(array $result): SearchResultEntry {
|
||||
$docType = $result['doc_type'] ?? 'unknown';
|
||||
$title = $result['title'] ?? $this->l10n->t('Untitled');
|
||||
$score = $result['score'] ?? 0;
|
||||
$id = isset($result['id']) ? (string)$result['id'] : null;
|
||||
$mimeType = $result['mime_type'] ?? null;
|
||||
|
||||
// Build resource URL based on document type
|
||||
$resourceUrl = $this->buildResourceUrl($result);
|
||||
|
||||
// Get icon and thumbnail based on document type
|
||||
[$thumbnailUrl, $iconClass] = $this->getIconAndThumbnail($docType, $id, $mimeType);
|
||||
|
||||
// Build metadata string with chunk and page info
|
||||
$metadataParts = [];
|
||||
|
||||
// Chunk info (always available)
|
||||
if (isset($result['chunk_index']) && isset($result['total_chunks'])) {
|
||||
$chunkNum = $result['chunk_index'] + 1; // Convert 0-based to 1-based
|
||||
$metadataParts[] = sprintf('Chunk %d/%d', $chunkNum, $result['total_chunks']);
|
||||
}
|
||||
|
||||
// Page info for PDFs
|
||||
if (!empty($result['page_number']) && !empty($result['page_count'])) {
|
||||
$metadataParts[] = sprintf('Page %d/%d', $result['page_number'], $result['page_count']);
|
||||
}
|
||||
|
||||
// Combine metadata parts
|
||||
$metadata = !empty($metadataParts) ? implode(' · ', $metadataParts) : '';
|
||||
|
||||
// Subline shows only chunk/page metadata (no excerpt, consistent with chunk viz)
|
||||
$subline = $metadata ?: sprintf(
|
||||
'%s · %d%% %s',
|
||||
$this->getDocTypeLabel($docType),
|
||||
(int)($score * 100),
|
||||
$this->l10n->t('relevant')
|
||||
);
|
||||
|
||||
return new SearchResultEntry(
|
||||
$thumbnailUrl,
|
||||
$title,
|
||||
$subline,
|
||||
$resourceUrl,
|
||||
$iconClass,
|
||||
false // not rounded
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL to navigate to Astrolabe with chunk viewer.
|
||||
*
|
||||
* Links to Astrolabe app with query parameters that trigger the chunk modal,
|
||||
* allowing users to preview the chunk before navigating to the full document.
|
||||
*/
|
||||
private function buildResourceUrl(array $result): string {
|
||||
// Build base URL to Astrolabe app
|
||||
$baseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index');
|
||||
|
||||
// Extract chunk parameters
|
||||
$docType = $result['doc_type'] ?? 'unknown';
|
||||
$id = $result['id'] ?? null;
|
||||
$chunkStart = $result['chunk_start_offset'] ?? null;
|
||||
$chunkEnd = $result['chunk_end_offset'] ?? null;
|
||||
|
||||
// If we have chunk information, build URL with parameters
|
||||
if ($id !== null && $chunkStart !== null && $chunkEnd !== null) {
|
||||
$params = [
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $id,
|
||||
'chunk_start' => $chunkStart,
|
||||
'chunk_end' => $chunkEnd,
|
||||
];
|
||||
|
||||
// Add optional metadata
|
||||
if (isset($result['title'])) {
|
||||
$params['title'] = $result['title'];
|
||||
}
|
||||
if (isset($result['path'])) {
|
||||
$params['path'] = $result['path'];
|
||||
}
|
||||
if (isset($result['page_number'])) {
|
||||
$params['page_number'] = $result['page_number'];
|
||||
}
|
||||
if (isset($result['board_id'])) {
|
||||
$params['board_id'] = $result['board_id'];
|
||||
}
|
||||
|
||||
// Encode parameters for URL
|
||||
$queryString = http_build_query($params);
|
||||
return $baseUrl . '?' . $queryString;
|
||||
}
|
||||
|
||||
// Fallback to base URL if no chunk information
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon and thumbnail for document type.
|
||||
*
|
||||
* Returns [thumbnailUrl, iconClass] tuple.
|
||||
* For files, uses mimetype-specific icons and preview thumbnails when available.
|
||||
* For other document types, uses appropriate icon classes.
|
||||
*
|
||||
* @return array{string, string} [thumbnailUrl, iconClass]
|
||||
*/
|
||||
private function getIconAndThumbnail(string $docType, ?string $id, ?string $mimeType): array {
|
||||
if ($docType === 'file' && $id !== null && $mimeType !== null) {
|
||||
// For files, check if preview is supported
|
||||
$thumbnailUrl = '';
|
||||
if ($this->previewManager->isMimeSupported($mimeType)) {
|
||||
$thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute(
|
||||
'core.Preview.getPreviewByFileId',
|
||||
['x' => 32, 'y' => 32, 'fileId' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
// Get mimetype-specific icon class
|
||||
$iconClass = $mimeType === FileInfo::MIMETYPE_FOLDER
|
||||
? 'icon-folder'
|
||||
: $this->mimeTypeDetector->mimeTypeIcon($mimeType);
|
||||
|
||||
return [$thumbnailUrl, $iconClass];
|
||||
}
|
||||
|
||||
// For non-file document types, use icon classes
|
||||
$iconClass = match ($docType) {
|
||||
'note' => 'icon-notes',
|
||||
'deck_card' => 'icon-deck',
|
||||
'calendar', 'calendar_event' => 'icon-calendar',
|
||||
'news_item' => 'icon-rss',
|
||||
'contact' => 'icon-contacts',
|
||||
default => 'icon-file',
|
||||
};
|
||||
|
||||
return ['', $iconClass];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable label for document type.
|
||||
*/
|
||||
private function getDocTypeLabel(string $docType): string {
|
||||
return match ($docType) {
|
||||
'note' => $this->l10n->t('Note'),
|
||||
'file' => $this->l10n->t('File'),
|
||||
'deck_card' => $this->l10n->t('Deck Card'),
|
||||
'calendar', 'calendar_event' => $this->l10n->t('Calendar'),
|
||||
'news_item' => $this->l10n->t('News'),
|
||||
'contact' => $this->l10n->t('Contact'),
|
||||
default => $this->l10n->t('Document'),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Refreshes OAuth tokens directly with the Identity Provider.
|
||||
*
|
||||
* Works with both Nextcloud OIDC and external IdPs like Keycloak.
|
||||
* Uses OIDC discovery to find the token endpoint automatically.
|
||||
*
|
||||
* This service is only used for confidential clients (with client_secret).
|
||||
* Public clients without client_secret cannot refresh tokens.
|
||||
*/
|
||||
class IdpTokenRefresher {
|
||||
private IConfig $config;
|
||||
private IClient $httpClient;
|
||||
private LoggerInterface $logger;
|
||||
private McpServerClient $mcpServerClient;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
IClientService $clientService,
|
||||
LoggerInterface $logger,
|
||||
McpServerClient $mcpServerClient,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->logger = $logger;
|
||||
$this->mcpServerClient = $mcpServerClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Nextcloud base URL for constructing internal OIDC endpoint URLs.
|
||||
*
|
||||
* 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 {
|
||||
// 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, '/');
|
||||
}
|
||||
|
||||
// Default: container environment with web server on localhost:80
|
||||
// This works because PHP runs inside the same container as Apache
|
||||
return 'http://localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token.
|
||||
*
|
||||
* Calls IdP's token endpoint directly (NOT MCP server).
|
||||
*
|
||||
* @param string $refreshToken The refresh token
|
||||
* @return array|null New token data or null on failure
|
||||
*/
|
||||
public function refreshAccessToken(string $refreshToken): ?array {
|
||||
// Check if confidential client secret is configured
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
|
||||
if (empty($clientSecret)) {
|
||||
$this->logger->warning('Cannot refresh: no client secret configured. Confidential client required for token refresh.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get MCP server URL
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Query MCP server to discover which IdP it's configured to use
|
||||
$statusResponse = $this->httpClient->get($mcpServerUrl . '/api/v1/status');
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid status response from MCP server');
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL and token endpoint
|
||||
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
|
||||
|
||||
if (!$useInternalNextcloud) {
|
||||
// External IdP configured - use OIDC discovery
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
|
||||
$this->logger->debug('IdpTokenRefresher: Using external IdP', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
|
||||
$discoveryResponse = $this->httpClient->get($discoveryUrl);
|
||||
$discovery = json_decode($discoveryResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
|
||||
throw new \RuntimeException('Invalid OIDC discovery response');
|
||||
}
|
||||
|
||||
$tokenEndpoint = $discovery['token_endpoint'];
|
||||
} else {
|
||||
// Nextcloud's OIDC app - use internal URL
|
||||
$tokenEndpoint = $this->getNextcloudBaseUrl() . '/apps/oidc/token';
|
||||
|
||||
$this->logger->debug('IdpTokenRefresher: Using Nextcloud OIDC app', [
|
||||
'token_endpoint' => $tokenEndpoint,
|
||||
]);
|
||||
}
|
||||
|
||||
// Call IdP's token endpoint with refresh_token grant
|
||||
$postData = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->mcpServerClient->getClientId(),
|
||||
'client_secret' => $clientSecret,
|
||||
];
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Requesting token refresh');
|
||||
|
||||
$response = $this->httpClient->post($tokenEndpoint, [
|
||||
'body' => http_build_query($postData),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
|
||||
throw new \RuntimeException('Invalid token response from IdP');
|
||||
}
|
||||
|
||||
// Validate refresh_token is present (required for token rotation)
|
||||
if (!isset($tokenData['refresh_token'])) {
|
||||
$this->logger->error(
|
||||
'IdpTokenRefresher: No refresh token in response - token rotation will fail',
|
||||
[
|
||||
'has_access_token' => isset($tokenData['access_token']),
|
||||
'response_keys' => array_keys($tokenData),
|
||||
]
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Token refresh successful');
|
||||
|
||||
return $tokenData;
|
||||
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* HTTP client for communicating with the MCP server's management API.
|
||||
*
|
||||
* This service wraps the MCP server's REST API endpoints defined in ADR-018.
|
||||
* It handles authentication via OAuth bearer tokens and provides typed methods
|
||||
* for all management operations.
|
||||
*/
|
||||
class McpServerClient {
|
||||
private IClient $httpClient;
|
||||
private IConfig $config;
|
||||
private LoggerInterface $logger;
|
||||
private string $baseUrl;
|
||||
|
||||
public function __construct(
|
||||
IClientService $clientService,
|
||||
IConfig $config,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->config = $config;
|
||||
$this->logger = $logger;
|
||||
|
||||
// Get MCP server configuration from Nextcloud config
|
||||
$baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
|
||||
$this->baseUrl = is_string($baseUrl) ? $baseUrl : 'http://localhost:8000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server status (version, auth mode, features).
|
||||
*
|
||||
* Public endpoint - no authentication required.
|
||||
*
|
||||
* @return array{
|
||||
* version?: string,
|
||||
* auth_mode?: string,
|
||||
* vector_sync_enabled?: bool,
|
||||
* uptime_seconds?: int,
|
||||
* management_api_version?: string,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getStatus(): array {
|
||||
try {
|
||||
$response = $this->httpClient->get($this->baseUrl . '/api/v1/status');
|
||||
$data = json_decode($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 MCP server status', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_url' => $this->baseUrl,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user session details.
|
||||
*
|
||||
* Requires authentication via OAuth bearer token.
|
||||
*
|
||||
* @param string $userId The user ID to query
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* session_id?: string,
|
||||
* background_access_granted?: bool,
|
||||
* background_access_details?: array,
|
||||
* idp_profile?: array,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getUserSession(string $userId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/session',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($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 session for user $userId", [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke user's background access (delete refresh token).
|
||||
*
|
||||
* Requires authentication via OAuth bearer token.
|
||||
*
|
||||
* @param string $userId The user ID whose access to revoke
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{success?: bool, message?: string, error?: string}
|
||||
*/
|
||||
public function revokeUserAccess(string $userId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/revoke',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($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 revoke access for user $userId", [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status (indexing metrics).
|
||||
*
|
||||
* Public endpoint - no authentication required.
|
||||
* Only available if VECTOR_SYNC_ENABLED=true on server.
|
||||
*
|
||||
* @return array{
|
||||
* status?: string,
|
||||
* indexed_documents?: int,
|
||||
* pending_documents?: int,
|
||||
* last_sync_time?: string,
|
||||
* documents_per_second?: float,
|
||||
* errors_24h?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getVectorSyncStatus(): array {
|
||||
try {
|
||||
$response = $this->httpClient->get($this->baseUrl . '/api/v1/vector-sync/status');
|
||||
$data = json_decode($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 vector sync status', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search for vector visualization.
|
||||
*
|
||||
* Requires OAuth bearer token for user-filtered search.
|
||||
* Only available if VECTOR_SYNC_ENABLED=true on server.
|
||||
*
|
||||
* @param string $query Search query string
|
||||
* @param string $algorithm Search algorithm: "semantic", "bm25", or "hybrid"
|
||||
* @param int $limit Number of results (max 50)
|
||||
* @param bool $includePca Whether to include PCA coordinates for 2D plot
|
||||
* @param array|null $docTypes Document types to filter (e.g., ['note', 'file'])
|
||||
* @param string|null $token OAuth bearer token for authentication
|
||||
* @return array{
|
||||
* results?: array,
|
||||
* pca_coordinates?: array,
|
||||
* algorithm_used?: string,
|
||||
* total_documents?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function search(
|
||||
string $query,
|
||||
string $algorithm = 'hybrid',
|
||||
int $limit = 10,
|
||||
bool $includePca = true,
|
||||
?array $docTypes = null,
|
||||
?string $token = null,
|
||||
): array {
|
||||
try {
|
||||
$requestBody = [
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
'limit' => min($limit, 50), // Enforce max limit
|
||||
'include_pca' => $includePca,
|
||||
];
|
||||
|
||||
// Add doc_types filter if specified
|
||||
if ($docTypes !== null && count($docTypes) > 0) {
|
||||
$requestBody['doc_types'] = $docTypes;
|
||||
}
|
||||
|
||||
$options = ['json' => $requestBody];
|
||||
|
||||
// Add authorization header if token provided
|
||||
if ($token !== null) {
|
||||
$options['headers'] = [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
];
|
||||
}
|
||||
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/vector-viz/search',
|
||||
$options
|
||||
);
|
||||
$data = json_decode($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 execute search', [
|
||||
'error' => $e->getMessage(),
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search for Nextcloud Unified Search.
|
||||
*
|
||||
* Simplified search method specifically for the unified search provider.
|
||||
* Uses OAuth bearer token for authentication and user-scoped filtering.
|
||||
*
|
||||
* @param string $query Search query string
|
||||
* @param string $token OAuth bearer token for authentication
|
||||
* @param int $limit Maximum number of results (default: 20)
|
||||
* @param int $offset Pagination offset (default: 0)
|
||||
* @param string $algorithm Search algorithm: hybrid, semantic, or bm25 (default: hybrid)
|
||||
* @param string $fusion Fusion method for hybrid: rrf or dbsf (default: rrf)
|
||||
* @param float $scoreThreshold Minimum score threshold 0-1 (default: 0)
|
||||
* @return 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
|
||||
* }
|
||||
*/
|
||||
public function searchForUnifiedSearch(
|
||||
string $query,
|
||||
string $token,
|
||||
int $limit = 20,
|
||||
int $offset = 0,
|
||||
string $algorithm = 'hybrid',
|
||||
string $fusion = 'rrf',
|
||||
float $scoreThreshold = 0.0,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/search',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
'fusion' => $fusion,
|
||||
'score_threshold' => $scoreThreshold,
|
||||
'limit' => min($limit, 100),
|
||||
'offset' => $offset,
|
||||
'include_pca' => false,
|
||||
'include_chunks' => true,
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($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('Unified search failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'query' => $query,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the MCP server is reachable and API key is valid.
|
||||
*
|
||||
* @return bool True if server is reachable and healthy
|
||||
*/
|
||||
public function isServerReachable(): bool {
|
||||
$status = $this->getStatus();
|
||||
return !isset($status['error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured MCP server internal URL (for API calls).
|
||||
*
|
||||
* @return string The internal base URL
|
||||
*/
|
||||
public function getServerUrl(): string {
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public MCP server URL (for display, OAuth audience).
|
||||
*
|
||||
* Falls back to internal URL if public URL not configured.
|
||||
*
|
||||
* @return string The public URL users/browsers see
|
||||
*/
|
||||
public function getPublicServerUrl(): string {
|
||||
return $this->config->getSystemValue('mcp_server_public_url', $this->baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OAuth client ID from system config.
|
||||
*
|
||||
* The Astrolabe app has its own OAuth client (separate from MCP server's client).
|
||||
* Client ID must be configured in config.php for OAuth functionality to work.
|
||||
*
|
||||
* @return string OAuth client ID or empty string if not configured
|
||||
*/
|
||||
public function getClientId(): string {
|
||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||
|
||||
if (empty($clientId)) {
|
||||
$this->logger->warning('astrolabe_client_id is not configured in config.php - OAuth functionality will not work');
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->logger->debug('Using client ID from system config: ' . substr($clientId, 0, 8) . '...');
|
||||
return $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered webhooks for a user.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* webhooks?: array<array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool
|
||||
* }>,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function listWebhooks(string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/webhooks',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($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 list webhooks', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new webhook registration.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $event Event type (e.g., "\\OCA\\Files::postCreate")
|
||||
* @param string $uri Callback URI for webhook notifications
|
||||
* @param array|null $eventFilter Optional event filter parameters
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function createWebhook(
|
||||
string $event,
|
||||
string $uri,
|
||||
?array $eventFilter,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$requestBody = [
|
||||
'event' => $event,
|
||||
'uri' => $uri,
|
||||
];
|
||||
|
||||
if ($eventFilter !== null) {
|
||||
$requestBody['event_filter'] = $eventFilter;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/webhooks',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => $requestBody
|
||||
]
|
||||
);
|
||||
$data = json_decode($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 create webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
'event' => $event,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook registration.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param int $webhookId Webhook ID to delete
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{success?: bool, error?: string}
|
||||
*/
|
||||
public function deleteWebhook(int $webhookId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->delete(
|
||||
$this->baseUrl . '/api/v1/webhooks/' . $webhookId,
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
// Successful DELETE may return 204 No Content
|
||||
if ($response->getStatusCode() === 204) {
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
$data = json_decode($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 delete webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
'webhook_id' => $webhookId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of installed Nextcloud apps.
|
||||
*
|
||||
* Used to filter webhook presets based on available apps.
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* apps?: array<string>,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getInstalledApps(string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/apps',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($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 installed apps', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk context (text, surrounding context, page image).
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $docType Document type
|
||||
* @param string $docId Document ID
|
||||
* @param int $start Start offset
|
||||
* @param int $end End offset
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array
|
||||
*/
|
||||
public function getChunkContext(
|
||||
string $docType,
|
||||
string $docId,
|
||||
int $start,
|
||||
int $end,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/chunk-context',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
],
|
||||
'query' => [
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $docId,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'context' => 500
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($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 chunk context', [
|
||||
'error' => $e->getMessage(),
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $docId,
|
||||
]);
|
||||
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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Storage service for per-user MCP OAuth tokens.
|
||||
*
|
||||
* Stores encrypted access and refresh tokens in user preferences.
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store MCP OAuth tokens for a user.
|
||||
*
|
||||
* Tokens are encrypted before storage to protect user credentials.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @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
|
||||
$encrypted = $this->crypto->encrypt(json_encode($tokenData));
|
||||
|
||||
// Store in user preferences
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens',
|
||||
$encrypted
|
||||
);
|
||||
|
||||
$this->logger->info("Stored MCP OAuth tokens for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP OAuth tokens for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return array|null Token data array with keys: access_token, refresh_token, expires_at
|
||||
*/
|
||||
public function getUserToken(string $userId): ?array {
|
||||
try {
|
||||
$encrypted = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens',
|
||||
''
|
||||
);
|
||||
|
||||
if (empty($encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt and parse token data
|
||||
$decrypted = $this->crypto->decrypt($encrypted);
|
||||
$tokenData = json_decode($decrypted, true);
|
||||
|
||||
if (!$tokenData || !isset($tokenData['access_token'])) {
|
||||
$this->logger->warning("Invalid token data for user: $userId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tokenData;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to retrieve MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired or about to 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
|
||||
*/
|
||||
public function isExpired(array $token): bool {
|
||||
if (!isset($token['expires_at'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored tokens for a user.
|
||||
*
|
||||
* Used when user disconnects or revokes access.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
*/
|
||||
public function deleteUserToken(string $userId): void {
|
||||
try {
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens'
|
||||
);
|
||||
|
||||
$this->logger->info("Deleted MCP OAuth tokens for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to delete MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// If not expired, return immediately without lock
|
||||
if (!$this->isExpired($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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store app password for background sync.
|
||||
*
|
||||
* App passwords are encrypted before storage and used as an alternative
|
||||
* to OAuth refresh tokens for background sync operations.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param string $appPassword Nextcloud app password
|
||||
*/
|
||||
public function storeBackgroundSyncPassword(
|
||||
string $userId,
|
||||
string $appPassword,
|
||||
): void {
|
||||
try {
|
||||
// Encrypt app password before storage
|
||||
$encrypted = $this->crypto->encrypt($appPassword);
|
||||
|
||||
// Store in user preferences
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_password',
|
||||
$encrypted
|
||||
);
|
||||
|
||||
// Mark credential type
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_type',
|
||||
'app_password'
|
||||
);
|
||||
|
||||
// Store provisioned timestamp
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_provisioned_at',
|
||||
(string)time()
|
||||
);
|
||||
|
||||
$this->logger->info("Stored background sync app password for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store app password for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app password for background sync.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string|null Decrypted app password, or null if not set
|
||||
*/
|
||||
public function getBackgroundSyncPassword(string $userId): ?string {
|
||||
try {
|
||||
$encrypted = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_password',
|
||||
''
|
||||
);
|
||||
|
||||
if (empty($encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt app password
|
||||
return $this->crypto->decrypt($encrypted);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to retrieve app password for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete background sync app password for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
*/
|
||||
public function deleteBackgroundSyncPassword(string $userId): void {
|
||||
try {
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_password'
|
||||
);
|
||||
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_type'
|
||||
);
|
||||
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_provisioned_at'
|
||||
);
|
||||
|
||||
$this->logger->info("Deleted background sync app password for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to delete app password for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has provisioned background sync access.
|
||||
*
|
||||
* Returns true if either OAuth tokens or app password is configured.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return bool True if background sync is provisioned
|
||||
*/
|
||||
public function hasBackgroundSyncAccess(string $userId): bool {
|
||||
// Check for OAuth tokens
|
||||
$oauthToken = $this->getUserToken($userId);
|
||||
if ($oauthToken !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for app password
|
||||
$appPassword = $this->getBackgroundSyncPassword($userId);
|
||||
return $appPassword !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background sync credential type for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string|null 'oauth' or 'app_password', or null if not provisioned
|
||||
*/
|
||||
public function getBackgroundSyncType(string $userId): ?string {
|
||||
$type = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_type',
|
||||
''
|
||||
);
|
||||
|
||||
// Fallback to OAuth if tokens exist but type not set
|
||||
if (empty($type) && $this->getUserToken($userId) !== null) {
|
||||
return 'oauth';
|
||||
}
|
||||
|
||||
return empty($type) ? null : $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background sync provisioned timestamp for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return int|null Unix timestamp, or null if not provisioned
|
||||
*/
|
||||
public function getBackgroundSyncProvisionedAt(string $userId): ?int {
|
||||
$timestamp = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_provisioned_at',
|
||||
''
|
||||
);
|
||||
|
||||
return empty($timestamp) ? null : (int)$timestamp;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
/**
|
||||
* Webhook preset configurations for common sync scenarios.
|
||||
*
|
||||
* Defines pre-configured webhook bundles that simplify webhook setup
|
||||
* for common use cases like Notes sync, Calendar sync, etc.
|
||||
*/
|
||||
class WebhookPresets {
|
||||
// File/Notes webhook events
|
||||
public const FILE_EVENT_CREATED = 'OCP\\Files\\Events\\Node\\NodeCreatedEvent';
|
||||
public const FILE_EVENT_WRITTEN = 'OCP\\Files\\Events\\Node\\NodeWrittenEvent';
|
||||
// Use BeforeNodeDeletedEvent instead of NodeDeletedEvent to get node.id
|
||||
// See: https://github.com/nextcloud/server/issues/56371
|
||||
public const FILE_EVENT_DELETED = 'OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent';
|
||||
|
||||
// Calendar webhook events
|
||||
public const CALENDAR_EVENT_CREATED = 'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent';
|
||||
public const CALENDAR_EVENT_UPDATED = 'OCP\\Calendar\\Events\\CalendarObjectUpdatedEvent';
|
||||
public const CALENDAR_EVENT_DELETED = 'OCP\\Calendar\\Events\\CalendarObjectDeletedEvent';
|
||||
|
||||
// Tables webhook events (Nextcloud 30+)
|
||||
public const TABLES_EVENT_ROW_ADDED = 'OCA\\Tables\\Event\\RowAddedEvent';
|
||||
public const TABLES_EVENT_ROW_UPDATED = 'OCA\\Tables\\Event\\RowUpdatedEvent';
|
||||
public const TABLES_EVENT_ROW_DELETED = 'OCA\\Tables\\Event\\RowDeletedEvent';
|
||||
|
||||
// Forms webhook events (Nextcloud 30+)
|
||||
public const FORMS_EVENT_FORM_SUBMITTED = 'OCA\\Forms\\Events\\FormSubmittedEvent';
|
||||
|
||||
// NOTE: Deck and Contacts do NOT support webhooks
|
||||
// Their event classes do not implement IWebhookCompatibleEvent interface.
|
||||
// Alternative sync strategies:
|
||||
// - Deck: Use polling with ETag-based change detection
|
||||
// - Contacts: Use CardDAV sync-token mechanism for efficient syncing
|
||||
|
||||
/**
|
||||
* Get all available webhook presets.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* name: string,
|
||||
* description: string,
|
||||
* app: string,
|
||||
* events: array<array{event: string, filter: array}>
|
||||
* }>
|
||||
*/
|
||||
public static function getPresets(): array {
|
||||
return [
|
||||
'notes_sync' => [
|
||||
'name' => 'Notes Sync',
|
||||
'description' => 'Real-time synchronization for Notes app (create, update, delete)',
|
||||
'app' => 'notes',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'calendar_sync' => [
|
||||
'name' => 'Calendar Sync',
|
||||
'description' => 'Real-time synchronization for Calendar events (create, update, delete)',
|
||||
'app' => 'calendar',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'tables_sync' => [
|
||||
'name' => 'Tables Sync',
|
||||
'description' => 'Real-time synchronization for Tables rows (add, update, delete)',
|
||||
'app' => 'tables',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_ADDED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'forms_sync' => [
|
||||
'name' => 'Forms Sync',
|
||||
'description' => 'Real-time synchronization for Forms submissions',
|
||||
'app' => 'forms',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FORMS_EVENT_FORM_SUBMITTED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'files_sync' => [
|
||||
'name' => 'All Files Sync',
|
||||
'description' => 'Real-time synchronization for all file operations (create, update, delete)',
|
||||
'app' => 'files',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a webhook preset by ID.
|
||||
*
|
||||
* @param string $presetId Preset identifier (e.g., "notes_sync", "calendar_sync")
|
||||
* @return array|null Preset configuration or null if not found
|
||||
*/
|
||||
public static function getPreset(string $presetId): ?array {
|
||||
$presets = self::getPresets();
|
||||
return $presets[$presetId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of event class names for a preset.
|
||||
*
|
||||
* @param string $presetId Preset identifier
|
||||
* @return array<string> List of fully qualified event class names
|
||||
*/
|
||||
public static function getPresetEvents(string $presetId): array {
|
||||
$preset = self::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn ($eventConfig) => $eventConfig['event'],
|
||||
$preset['events']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter webhook presets to only show those for installed apps.
|
||||
*
|
||||
* @param array<string> $installedApps List of installed app names
|
||||
* @return array<string, array> Filtered presets
|
||||
*/
|
||||
public static function filterPresetsByInstalledApps(array $installedApps): array {
|
||||
$filtered = [];
|
||||
foreach (self::getPresets() as $presetId => $preset) {
|
||||
$appName = $preset['app'];
|
||||
// "files" is always available (core functionality)
|
||||
if ($appName === 'files' || in_array($appName, $installedApps)) {
|
||||
$filtered[$presetId] = $preset;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IConfig;
|
||||
use OCP\Settings\ISettings;
|
||||
|
||||
/**
|
||||
* Admin settings panel for Astrolabe.
|
||||
*
|
||||
* Displays semantic search service status, indexing metrics,
|
||||
* configuration, and provides administrative controls.
|
||||
*/
|
||||
class Admin implements ISettings {
|
||||
// Search settings keys and defaults
|
||||
public const SETTING_SEARCH_ALGORITHM = 'search_algorithm';
|
||||
public const SETTING_SEARCH_FUSION = 'search_fusion';
|
||||
public const SETTING_SEARCH_SCORE_THRESHOLD = 'search_score_threshold';
|
||||
public const SETTING_SEARCH_LIMIT = 'search_limit';
|
||||
|
||||
public const DEFAULT_SEARCH_ALGORITHM = 'hybrid';
|
||||
public const DEFAULT_SEARCH_FUSION = 'rrf';
|
||||
public const DEFAULT_SEARCH_SCORE_THRESHOLD = 0;
|
||||
public const DEFAULT_SEARCH_LIMIT = 20;
|
||||
|
||||
private $client;
|
||||
private $config;
|
||||
private $initialState;
|
||||
|
||||
public function __construct(
|
||||
McpServerClient $client,
|
||||
IConfig $config,
|
||||
IInitialState $initialState,
|
||||
) {
|
||||
$this->client = $client;
|
||||
$this->config = $config;
|
||||
$this->initialState = $initialState;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
public function getForm(): TemplateResponse {
|
||||
// Get configuration from config.php (local, fast)
|
||||
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
|
||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||
$clientIdConfigured = !empty($clientId);
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
$clientSecretConfigured = !empty($clientSecret);
|
||||
|
||||
// Load search settings from app config
|
||||
$searchSettings = [
|
||||
'algorithm' => $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_ALGORITHM,
|
||||
self::DEFAULT_SEARCH_ALGORITHM
|
||||
),
|
||||
'fusion' => $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_FUSION,
|
||||
self::DEFAULT_SEARCH_FUSION
|
||||
),
|
||||
'scoreThreshold' => (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_SCORE_THRESHOLD,
|
||||
(string)self::DEFAULT_SEARCH_SCORE_THRESHOLD
|
||||
),
|
||||
'limit' => (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_LIMIT,
|
||||
(string)self::DEFAULT_SEARCH_LIMIT
|
||||
),
|
||||
];
|
||||
|
||||
// Provide initial state for Vue.js frontend
|
||||
// MCP server data will be fetched asynchronously by Vue component
|
||||
$this->initialState->provideInitialState('admin-config', [
|
||||
'config' => [
|
||||
'serverUrl' => $serverUrl,
|
||||
'apiKeyConfigured' => $apiKeyConfigured,
|
||||
'clientIdConfigured' => $clientIdConfigured,
|
||||
'clientSecretConfigured' => $clientSecretConfigured,
|
||||
],
|
||||
'searchSettings' => $searchSettings,
|
||||
]);
|
||||
|
||||
$parameters = [];
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/admin',
|
||||
$parameters,
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getSection(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
/**
|
||||
* Admin settings section for Astrolabe.
|
||||
*
|
||||
* Creates a dedicated section in admin settings for semantic search administration.
|
||||
*/
|
||||
class AdminSection implements IIconSection {
|
||||
private $l;
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
|
||||
$this->l = $l;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getID(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The translated section name
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->l->t('Astrolabe');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up in list)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Section icon (SVG or image URL)
|
||||
*/
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath('astrolabe', 'app-dark.svg');
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\Settings\DeclarativeSettingsTypes;
|
||||
use OCP\Settings\IDeclarativeSettingsForm;
|
||||
|
||||
class AstrolabeAdminSettings implements IDeclarativeSettingsForm {
|
||||
public function __construct(
|
||||
private IL10N $l,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getSchema(): array {
|
||||
return [
|
||||
'id' => 'astrolabe-admin-settings',
|
||||
'priority' => 10,
|
||||
'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
|
||||
'section_id' => 'astrolabe',
|
||||
'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL,
|
||||
'title' => $this->l->t('MCP Server Configuration'),
|
||||
'description' => $this->l->t('Configure the connection to your Nextcloud MCP Server'),
|
||||
'doc_url' => 'https://github.com/cbcoutinho/nextcloud-mcp-server',
|
||||
|
||||
'fields' => [
|
||||
[
|
||||
'id' => 'mcp_server_url',
|
||||
'title' => $this->l->t('MCP Server URL'),
|
||||
'description' => $this->l->t('The base URL of your Nextcloud MCP Server instance (e.g., http://localhost:8000)'),
|
||||
'type' => DeclarativeSettingsTypes::URL,
|
||||
'placeholder' => 'http://localhost:8000',
|
||||
'default' => '',
|
||||
],
|
||||
[
|
||||
'id' => 'mcp_server_api_key',
|
||||
'title' => $this->l->t('API Key'),
|
||||
'description' => $this->l->t('Authentication key for the MCP server (leave empty if not required)'),
|
||||
'type' => DeclarativeSettingsTypes::PASSWORD,
|
||||
'placeholder' => $this->l->t('Enter API key'),
|
||||
'default' => '',
|
||||
],
|
||||
[
|
||||
'id' => 'astrolabe_client_id',
|
||||
'title' => $this->l->t('OAuth Client ID'),
|
||||
'description' => $this->l->t('The OAuth client ID for Astrolabe (required for multi-user deployments)'),
|
||||
'type' => DeclarativeSettingsTypes::TEXT,
|
||||
'placeholder' => $this->l->t('Enter OAuth client ID'),
|
||||
'default' => '',
|
||||
],
|
||||
[
|
||||
'id' => 'astrolabe_client_secret',
|
||||
'title' => $this->l->t('OAuth Client Secret'),
|
||||
'description' => $this->l->t('Optional: Client secret for OAuth. If not set, PKCE will be used as fallback.'),
|
||||
'type' => DeclarativeSettingsTypes::PASSWORD,
|
||||
'placeholder' => $this->l->t('Enter client secret (optional)'),
|
||||
'default' => '',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
-238
@@ -1,238 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Settings\ISettings;
|
||||
|
||||
/**
|
||||
* Personal settings panel for Astrolabe.
|
||||
*
|
||||
* Displays semantic search status, background indexing access,
|
||||
* and provides controls for managing content indexing.
|
||||
*
|
||||
* Uses OAuth PKCE flow - each user must authorize background access.
|
||||
*/
|
||||
class Personal implements ISettings {
|
||||
private $client;
|
||||
private $userSession;
|
||||
private $initialState;
|
||||
private $tokenStorage;
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(
|
||||
McpServerClient $client,
|
||||
IUserSession $userSession,
|
||||
IInitialState $initialState,
|
||||
McpTokenStorage $tokenStorage,
|
||||
IURLGenerator $urlGenerator,
|
||||
) {
|
||||
$this->client = $client;
|
||||
$this->userSession = $userSession;
|
||||
$this->initialState = $initialState;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
public function getForm(): TemplateResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new TemplateResponse(Application::APP_ID, 'settings/error', [
|
||||
'error' => 'User not authenticated'
|
||||
], TemplateResponse::RENDER_AS_BLANK);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Fetch server status to determine auth mode
|
||||
$serverStatus = $this->client->getStatus();
|
||||
|
||||
// Check for server connection error
|
||||
if (isset($serverStatus['error'])) {
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/error',
|
||||
[
|
||||
'error' => 'Cannot connect to MCP server',
|
||||
'details' => $serverStatus['error'],
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// Get auth mode from server (defaults to oauth if not specified)
|
||||
$authMode = $serverStatus['auth_mode'] ?? 'oauth';
|
||||
$supportsAppPasswords = $serverStatus['supports_app_passwords'] ?? false;
|
||||
|
||||
// Check if user has MCP OAuth token
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
// 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 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);
|
||||
|
||||
// OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth)
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
|
||||
elseif (!$token || $this->tokenStorage->isExpired($token)) {
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/oauth-required',
|
||||
[
|
||||
'oauth_url' => $oauthUrl,
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
'has_expired' => ($token !== null), // true if token exists but expired
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// User has valid token - fetch data from MCP server
|
||||
$accessToken = $token['access_token'];
|
||||
|
||||
// Fetch server status (public endpoint, no token needed)
|
||||
$serverStatus = $this->client->getStatus();
|
||||
|
||||
// Fetch user session data (requires token)
|
||||
$userSession = $this->client->getUserSession($userId, $accessToken);
|
||||
|
||||
// Check for server connection error
|
||||
if (isset($serverStatus['error'])) {
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/error',
|
||||
[
|
||||
'error' => 'Cannot connect to MCP server',
|
||||
'details' => $serverStatus['error'],
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// Check for authentication error (invalid/expired token)
|
||||
if (isset($userSession['error'])) {
|
||||
// Token might be invalid - delete it and show OAuth UI
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/oauth-required',
|
||||
[
|
||||
'oauth_url' => $oauthUrl,
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
'has_expired' => true,
|
||||
'error_message' => 'Your session has expired. Please sign in again.',
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// Check background sync credential status
|
||||
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
$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,
|
||||
'serverStatus' => $serverStatus,
|
||||
'session' => $userSession,
|
||||
]);
|
||||
|
||||
// Consolidated template parameters (camelCase convention)
|
||||
$parameters = [
|
||||
'userId' => $userId,
|
||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||
'serverStatus' => $serverStatus,
|
||||
'session' => $userSession,
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
// 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(
|
||||
Application::APP_ID,
|
||||
'settings/personal',
|
||||
$parameters,
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getSection(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
/**
|
||||
* Personal settings section for Astrolabe.
|
||||
*
|
||||
* Creates a dedicated section in personal settings for semantic search configuration.
|
||||
*/
|
||||
class PersonalSection implements IIconSection {
|
||||
private $l;
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
|
||||
$this->l = $l;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getID(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The translated section name
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->l->t('Astrolabe');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up in list, 0-99)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Section icon (SVG or image URL)
|
||||
*/
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath('astrolabe', 'app-dark.svg');
|
||||
}
|
||||
}
|
||||
Vendored
-149
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "astrolabe",
|
||||
"version": "0.0.1",
|
||||
"description": "Manage the MCP Server from within Nextcloud UI",
|
||||
"license": {
|
||||
"name": "agpl"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"basic_auth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
},
|
||||
"bearer_auth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"OCSMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status",
|
||||
"statuscode"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"statuscode": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"totalitems": {
|
||||
"type": "string"
|
||||
},
|
||||
"itemsperpage": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/ocs/v2.php/apps/astrolabe/api": {
|
||||
"get": {
|
||||
"operationId": "api-index",
|
||||
"summary": "An example API endpoint",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Data returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
-10638
File diff suppressed because it is too large
Load Diff
Vendored
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.9.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
"npm": "^10.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite --mode development build",
|
||||
"watch": "vite --mode development build --watch",
|
||||
"lint": "eslint src",
|
||||
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css"
|
||||
},
|
||||
"type": "module",
|
||||
"browserslist": [
|
||||
"extends @nextcloud/browserslist-config"
|
||||
],
|
||||
"dependencies": {
|
||||
"@nextcloud/axios": "^2.5.1",
|
||||
"@nextcloud/dialogs": "^7.2.0",
|
||||
"@nextcloud/initial-state": "^3.0.0",
|
||||
"@nextcloud/l10n": "^3.1.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
"plotly.js-dist-min": "^3.0.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nextcloud/browserslist-config": "3.1.2",
|
||||
"@nextcloud/eslint-config": "8.4.2",
|
||||
"@nextcloud/stylelint-config": "3.1.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"sass-embedded": "^1.97.1",
|
||||
"terser": "5.44.1",
|
||||
"vite": "7.2.7"
|
||||
}
|
||||
}
|
||||
-488
@@ -1,488 +0,0 @@
|
||||
<?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
-22
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<psalm
|
||||
errorLevel="1"
|
||||
resolveFromConfigFile="true"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="https://getpsalm.org/schema/config"
|
||||
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
|
||||
findUnusedBaselineEntry="true"
|
||||
findUnusedCode="true"
|
||||
phpVersion="8.1"
|
||||
errorBaseline="psalm-baseline.xml"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="lib" />
|
||||
<ignoreFiles>
|
||||
<directory name="vendor" />
|
||||
</ignoreFiles>
|
||||
</projectFiles>
|
||||
<extraFiles>
|
||||
<directory name="vendor"/>
|
||||
</extraFiles>
|
||||
</psalm>
|
||||
Vendored
-30
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__ . '/lib',
|
||||
__DIR__ . '/tests',
|
||||
])
|
||||
->withPhpSets(php80: true)
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
codeQuality: true,
|
||||
codingStyle: true,
|
||||
typeDeclarations: true,
|
||||
privatization: true,
|
||||
instanceOf: true,
|
||||
earlyReturn: true,
|
||||
strictBooleans: true,
|
||||
carbon: true,
|
||||
rectorPreset: true,
|
||||
phpunitCodeQuality: true,
|
||||
doctrineCodeQuality: true,
|
||||
symfonyCodeQuality: true,
|
||||
symfonyConfigs: true,
|
||||
twig: true,
|
||||
phpunit: true,
|
||||
);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user