Compare commits
274 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c7882adb24 | |||
| 9491d698e8 | |||
| 5b71ac3251 | |||
| 815a09be34 | |||
| c46f9eb212 | |||
| 28219e00e7 | |||
| daaf460b0c | |||
| 04f05f725c | |||
| b499aa2abe | |||
| 72df7dd1eb | |||
| 2e7774654b | |||
| 61ce873411 | |||
| 0af9657fea | |||
| 8507e480d6 | |||
| 905d18baf7 | |||
| b5e5d86790 | |||
| c35e94b0bc | |||
| c09ebe99cc | |||
| d5544a7731 | |||
| bc62f2a066 | |||
| 38adb96be4 | |||
| c76dd21eeb | |||
| c5bf4cda8a | |||
| 0b6a6b0842 | |||
| 9c4c4d4563 | |||
| 2d74b1a1fb | |||
| 26ba237142 | |||
| 7b75304c9f | |||
| 9004e14022 | |||
| e7a3dd698a | |||
| c12007c342 | |||
| f37cf8a159 | |||
| 07f2952599 | |||
| 6cf916876a | |||
| 27b11eabf9 | |||
| da31dec33e | |||
| a61bcccdac | |||
| 774de68966 | |||
| 44b77875f7 | |||
| 5469cf05f0 | |||
| 6832ae1198 | |||
| 619faaf1df | |||
| 34387ff202 | |||
| 76d3174264 | |||
| 723337754f | |||
| 2d79fc6c3d | |||
| 80972f5d37 | |||
| f0ade4ad28 | |||
| 737f10f190 | |||
| 813e9a60cb | |||
| 5c25b87cbe | |||
| e48c5fa9a2 | |||
| 303efeddf7 | |||
| c9bf3d0b52 | |||
| 9f64609722 | |||
| 89becbb92b | |||
| fef13a6d3d | |||
| c4973290a6 | |||
| c018268681 | |||
| 79cfb65590 | |||
| 9750845092 | |||
| 7e8171132b | |||
| 910792178b | |||
| 80c5647f3e | |||
| a306549907 | |||
| 295e3d2783 | |||
| 47dcdf8b61 | |||
| 8c6ae9ff33 | |||
| 04fee00a0b | |||
| 9e1fc1ebeb | |||
| 6eceefdacc | |||
| b147814cc4 | |||
| 5a58c81626 | |||
| 1cc460b0d8 | |||
| 104a2ec9e3 | |||
| e87ae56041 | |||
| c95459234b | |||
| f16f852b23 | |||
| b93d7bd19b | |||
| 9a69cef815 | |||
| 2424afbdda | |||
| 0a987467b5 | |||
| ab6f7ca0b2 | |||
| 42fa33d0bf | |||
| 006a3d95d6 | |||
| 1835965f44 | |||
| cb4e8acd9f | |||
| 02418a9531 | |||
| f89151d099 | |||
| dc86386bf8 | |||
| 929c40709a | |||
| a60560256d | |||
| aa583ab973 | |||
| 4103924b83 | |||
| c192bd2ec9 | |||
| 2005d2841f | |||
| c6295b48a5 | |||
| 7444c73a5a | |||
| cf0781d2fe | |||
| 6681cd0603 | |||
| c305a549d3 | |||
| 1f1dd94598 | |||
| 01ad2b3d21 | |||
| e4cddef343 | |||
| f15baefe7e | |||
| 585ed46f2d | |||
| dbbbab5320 | |||
| e5844b3da8 | |||
| fdbf88831a | |||
| 6affad1c8b | |||
| 370c3ff444 | |||
| e486e92f91 | |||
| 7465e962d4 | |||
| 99fe764c5e | |||
| 46f896b526 | |||
| a61572e8ef | |||
| a474996df4 | |||
| 5d6dd5ad38 | |||
| 21e4d3effd | |||
| 817df43af1 | |||
| 906b9d892c | |||
| 534723c9f6 | |||
| 1d5832ed3a | |||
| 844bd589e0 | |||
| 127af15623 | |||
| ff5fc5d5b2 | |||
| 158865d99f | |||
| 94674eca27 | |||
| a8b5d6e701 | |||
| e0675b2127 | |||
| 86582bdb8f | |||
| dc8009a785 | |||
| b5e658e1ff | |||
| 6a19c2d136 | |||
| 99e359ffbf | |||
| f16f4e8cb5 | |||
| 8597f2a272 | |||
| 11f67e2bc4 | |||
| 2e49a16e49 | |||
| 713fddeaa5 | |||
| 0dfefb0516 | |||
| 63d2aeaa43 | |||
| 07f0a7c0dc | |||
| 84bde6d5ed | |||
| 9695f8a6d7 | |||
| a2c410e8d2 | |||
| 271b5f6155 | |||
| ba4f7c1429 | |||
| c763e96596 | |||
| 23e9cbaec5 | |||
| ddd5defa40 | |||
| 723dcc524d | |||
| 46eba0a693 | |||
| b61980a623 | |||
| 65cc894e21 | |||
| 700996e100 | |||
| 546f0c0674 | |||
| e625eab689 | |||
| ef9e1b3ff8 | |||
| dd23191987 | |||
| 55312b1032 | |||
| a987643f8e |
@@ -1,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,275 +0,0 @@
|
||||
# Consolidated CI workflow for Astroglobe Nextcloud app
|
||||
#
|
||||
# Runs on PRs that modify the astroglobe directory
|
||||
# Based on Nextcloud app skeleton workflows
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Astroglobe CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'third_party/astroglobe/**'
|
||||
- '.github/workflows/astroglobe-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: astroglobe-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/astroglobe/src/**'
|
||||
- 'third_party/astroglobe/package.json'
|
||||
- 'third_party/astroglobe/package-lock.json'
|
||||
- 'third_party/astroglobe/vite.config.js'
|
||||
- 'third_party/astroglobe/**/*.js'
|
||||
- 'third_party/astroglobe/**/*.ts'
|
||||
- 'third_party/astroglobe/**/*.vue'
|
||||
php:
|
||||
- 'third_party/astroglobe/lib/**'
|
||||
- 'third_party/astroglobe/appinfo/**'
|
||||
- 'third_party/astroglobe/composer.json'
|
||||
- 'third_party/astroglobe/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/astroglobe
|
||||
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/astroglobe
|
||||
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/astroglobe
|
||||
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/astroglobe
|
||||
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/astroglobe
|
||||
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/astroglobe
|
||||
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/astroglobe
|
||||
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/astroglobe/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/astroglobe
|
||||
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/astroglobe/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/astroglobe/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
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, node-build, eslint, stylelint, php-cs, psalm]
|
||||
if: always()
|
||||
name: astroglobe-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') }}; 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,33 +83,36 @@ 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
|
||||
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
||||
echo "Bumping MCP server version..."
|
||||
./scripts/bump-mcp.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
||||
MCP_BUMPED=true
|
||||
else
|
||||
echo "No commits found for MCP server since $last_mcp_tag"
|
||||
fi
|
||||
|
||||
# Bump Helm chart (scope: helm)
|
||||
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
|
||||
echo "Checking Helm chart for version bump..."
|
||||
HELM_HAS_COMMITS=false
|
||||
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
|
||||
echo "Bumping Helm chart version..."
|
||||
./scripts/bump-helm.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
HELM_HAS_COMMITS=true
|
||||
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"
|
||||
if [ "$HELM_HAS_COMMITS" = true ]; then
|
||||
echo "Bumping Helm chart version (helm-scoped commits)..."
|
||||
./scripts/bump-helm.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
elif [ "$MCP_BUMPED" = true ]; then
|
||||
echo "Bumping Helm chart version (appVersion changed)..."
|
||||
./scripts/bump-helm.sh --increment PATCH
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
fi
|
||||
|
||||
# Output summary
|
||||
@@ -147,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,9 +33,10 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "renovate-bot-cbcoutinho"
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@7145c3e0510bcdbdd29f67cc4a8c1958f1acfa2f # v1
|
||||
uses: anthropics/claude-code-action@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@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
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@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
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@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
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@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
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@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
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@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
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
|
||||
|
||||
+138
@@ -5,6 +5,144 @@ 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
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## v0.61.5 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## v0.61.4 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
||||
|
||||
## v0.61.3 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||
|
||||
## v0.61.2 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: bump helm chart version when MCP appVersion changes
|
||||
|
||||
## v0.61.1 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||
|
||||
## v0.61.0 (2026-01-14)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## v0.60.4 (2026-01-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
|
||||
## v0.60.3 (2025-12-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
|
||||
## v0.60.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
|
||||
|
||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
||||
|
||||
### Quick Query Script (Recommended for Agents)
|
||||
|
||||
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
|
||||
|
||||
```bash
|
||||
# Basic query
|
||||
./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users"
|
||||
|
||||
# Vertical output (one column per line) - useful for wide tables
|
||||
./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1"
|
||||
|
||||
# With different credentials
|
||||
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
|
||||
```
|
||||
|
||||
### Direct Docker Access
|
||||
|
||||
For interactive sessions or complex operations:
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||
@@ -264,6 +283,40 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
||||
|
||||
### SQLite Databases (MCP Services)
|
||||
|
||||
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
|
||||
|
||||
```bash
|
||||
# List tables
|
||||
./scripts/sqlitequery.py ".tables"
|
||||
|
||||
# Query specific service
|
||||
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||
./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients"
|
||||
./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords"
|
||||
|
||||
# With column headers
|
||||
./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5"
|
||||
|
||||
# JSON output
|
||||
./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions"
|
||||
|
||||
# View schema
|
||||
./scripts/sqlitequery.py -s oauth ".schema refresh_tokens"
|
||||
```
|
||||
|
||||
**Services**: `mcp` (default), `oauth`, `keycloak`, `basic`
|
||||
|
||||
**SQLite Tables**:
|
||||
- `refresh_tokens` - OAuth refresh tokens with user profiles
|
||||
- `audit_logs` - Security audit trail
|
||||
- `oauth_clients` - DCR OAuth client credentials
|
||||
- `oauth_sessions` - OAuth flow session state
|
||||
- `registered_webhooks` - Webhook registrations
|
||||
- `app_passwords` - Multi-user BasicAuth passwords
|
||||
- `alembic_version` - Migration tracking
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
**For detailed architecture, see:**
|
||||
|
||||
+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:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /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:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
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.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /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."
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Configure MCP server URL for Astrolabe background sync
|
||||
# This URL is used by Astrolabe to send app passwords to the MCP server
|
||||
|
||||
set -e
|
||||
|
||||
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
|
||||
# From Nextcloud's perspective (inside Docker network), we reach it via service name
|
||||
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
|
||||
|
||||
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
||||
|
||||
# Set the mcp_server_url in config.php via occ
|
||||
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
|
||||
|
||||
echo "MCP server URL configured successfully"
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.56.2"
|
||||
version = "0.57.48"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,198 @@ 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.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
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## nextcloud-mcp-server-0.57.14 (2026-01-26)
|
||||
|
||||
## nextcloud-mcp-server-0.57.13 (2026-01-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.12 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.11 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.10 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.9 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.8 (2026-01-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.7 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## nextcloud-mcp-server-0.57.6 (2026-01-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.5 (2026-01-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.4 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
||||
|
||||
## nextcloud-mcp-server-0.57.3 (2026-01-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.2 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||
|
||||
## nextcloud-mcp-server-0.57.1 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: bump helm chart version when MCP appVersion changes
|
||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||
|
||||
## nextcloud-mcp-server-0.57.0 (2026-01-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## nextcloud-mcp-server-0.56.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
@@ -4,6 +4,6 @@ dependencies:
|
||||
version: 1.16.3
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.36.0
|
||||
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
||||
generated: "2025-12-22T11:09:39.166328543Z"
|
||||
version: 1.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.56.2
|
||||
appVersion: "0.60.2"
|
||||
version: 0.57.48
|
||||
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.36.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)
|
||||
|
||||
+12
-17
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
|
||||
image: docker.io/library/mariadb:lts@sha256:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -19,11 +19,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
||||
image: docker.io/library/redis:alpine@sha256:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||
image: docker.io/library/nextcloud:32.0.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:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||
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:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -88,8 +87,8 @@ services:
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
|
||||
# Vector sync configuration (ADR-007)
|
||||
#- VECTOR_SYNC_ENABLED=true
|
||||
# Semantic search configuration (ADR-007, ADR-021)
|
||||
#- ENABLE_SEMANTIC_SEARCH=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
@@ -140,14 +139,13 @@ services:
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
#- ENABLE_OFFLINE_ACCESS=true
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
|
||||
# Token storage (required for middleware initialization)
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
- ENABLE_SEMANTIC_SEARCH=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
@@ -180,7 +178,6 @@ services:
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1)
|
||||
#- ENABLE_OFFLINE_ACCESS=true
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
@@ -189,9 +186,8 @@ services:
|
||||
# Tokens must contain BOTH MCP and Nextcloud audiences
|
||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||
|
||||
# Vector sync configuration (ADR-007)
|
||||
# Semantic search configuration (ADR-007, ADR-021)
|
||||
- ENABLE_SEMANTIC_SEARCH=true
|
||||
#- VECTOR_SYNC_ENABLED=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
@@ -211,7 +207,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -259,7 +255,6 @@ services:
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||
#- ENABLE_OFFLINE_ACCESS=true
|
||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
@@ -293,13 +288,13 @@ services:
|
||||
- 127.0.0.1:8081:8081
|
||||
environment:
|
||||
- SMITHERY_DEPLOYMENT=true
|
||||
- VECTOR_SYNC_ENABLED=false
|
||||
- ENABLE_SEMANTIC_SEARCH=false
|
||||
- PORT=8081
|
||||
profiles:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
# Authentication Flows by Deployment Mode
|
||||
|
||||
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
|
||||
|
||||
## Quick Reference Matrix
|
||||
|
||||
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|
||||
|------|-------------------|-----------------|-----------------|
|
||||
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
|
||||
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
|
||||
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
|
||||
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
|
||||
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
This document covers three distinct communication patterns:
|
||||
|
||||
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
|
||||
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
|
||||
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
|
||||
|
||||
---
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### 1. Single-User BasicAuth
|
||||
|
||||
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no auth required) │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ Authorization: Basic │
|
||||
│ │ (embedded credentials) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
|
||||
- Single shared `NextcloudClient` created at startup
|
||||
- No MCP-level authentication required (server trusts local clients)
|
||||
- All requests use the same Nextcloud user
|
||||
|
||||
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
|
||||
|
||||
**Implementation:** Background jobs use `get_settings()` to access credentials
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-User BasicAuth
|
||||
|
||||
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ Authorization: Basic │ │
|
||||
│ (user credentials) │ │
|
||||
│ │── BasicAuthMiddleware ────▶│
|
||||
│ │ Extracts credentials │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (pass-through) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
|
||||
- Credentials passed through to Nextcloud (not stored)
|
||||
- Client created per-request from extracted credentials
|
||||
- Stateless - no credential storage between requests
|
||||
|
||||
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
|
||||
|
||||
#### Background Sync (Optional)
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Store App Password ──────▶│ │
|
||||
│ (via management API) │ │
|
||||
│ │── Store in SQLite ────────▶│
|
||||
│ │ (encrypted) │
|
||||
│◀── Confirmation ────────────│ │
|
||||
│ │ │
|
||||
│ [Background Job] │ │
|
||||
│ │── Retrieve app password ──▶│
|
||||
│ │ (from encrypted storage) │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (stored app password) │
|
||||
│ │◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- `ENABLE_OFFLINE_ACCESS=true`
|
||||
- `TOKEN_ENCRYPTION_KEY` for credential encryption
|
||||
- `TOKEN_STORAGE_DB` for SQLite storage path
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
|
||||
│ (user initiates) │ │
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ (management API calls) │ │
|
||||
│ │── Validate via JWKS ──────▶│
|
||||
│ │ (or introspection) │
|
||||
│◀── API Response ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
|
||||
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
|
||||
- Authorization check: `token.sub == requested_resource_owner`
|
||||
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
|
||||
|
||||
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
|
||||
|
||||
---
|
||||
|
||||
### 3. OAuth Single-Audience (Default)
|
||||
|
||||
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
|
||||
|
||||
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: ["mcp-server", │ │
|
||||
│ "nextcloud"] │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ (UnifiedTokenVerifier) │
|
||||
│ │ │
|
||||
│ │── HTTP + Same Token ──────▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │ (multi-audience token) │
|
||||
│ │ │
|
||||
│ │ NC validates its own aud │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
|
||||
- MCP server validates only MCP audience (per RFC 7519)
|
||||
- Nextcloud independently validates its own audience
|
||||
- No token exchange needed - same token used throughout
|
||||
- Stateless operation for interactive requests
|
||||
|
||||
**Token validation flow:**
|
||||
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
|
||||
2. Token passed directly to Nextcloud via `get_client_from_context()`
|
||||
3. Nextcloud validates its own audience when receiving API calls
|
||||
|
||||
**Implementation:**
|
||||
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
|
||||
- `context.py:96-99` - Uses token directly in multi-audience mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[Background Job starts] │ │
|
||||
│── Get refresh token ──────▶│
|
||||
│ (from encrypted storage) │
|
||||
│ │
|
||||
│── Token refresh request ──▶│
|
||||
│ grant_type=refresh_token │
|
||||
│ scope=openid profile ... │
|
||||
│◀── New access + refresh ───│
|
||||
│ (rotation) │
|
||||
│ │
|
||||
│── Store rotated refresh ──▶│
|
||||
│ (encrypted) │
|
||||
│ │
|
||||
│── HTTP + Access Token ────▶│
|
||||
│ Authorization: Bearer │
|
||||
│◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
|
||||
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
|
||||
- `TokenBrokerService` handles token lifecycle
|
||||
- Per-user locking prevents race conditions during concurrent refresh
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
|
||||
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 4. OAuth Token Exchange (RFC 8693)
|
||||
|
||||
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
|
||||
|
||||
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: "mcp-server" │ │
|
||||
│ (MCP audience only) │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ │
|
||||
│ │── RFC 8693 Exchange ──────▶│
|
||||
│ │ grant_type= │
|
||||
│ │ urn:ietf:params:oauth: │
|
||||
│ │ grant-type:token-exchange
|
||||
│ │ subject_token=<mcp-token>│
|
||||
│ │ requested_audience= │
|
||||
│ │ "nextcloud" │
|
||||
│ │◀── Delegated Token ────────│
|
||||
│ │ aud: "nextcloud" │
|
||||
│ │ │
|
||||
│ │── HTTP + Delegated Token ─▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Strict audience separation: MCP token has `aud: "mcp-server"` only
|
||||
- Server exchanges for Nextcloud-audience token on each request
|
||||
- Ephemeral delegated tokens (not cached by default)
|
||||
- Strongest security boundary between MCP and Nextcloud access
|
||||
|
||||
**Token exchange details:**
|
||||
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
- Subject token: MCP access token
|
||||
- Requested audience: Nextcloud resource URI
|
||||
- Result: Short-lived token scoped for Nextcloud
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
|
||||
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
|
||||
- `context.py:88-94` - Routes to session client in exchange mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[User provisions access] │ │
|
||||
│── Flow 2 OAuth ───────────▶│
|
||||
│ client_id="mcp-server" │
|
||||
│ scope=offline_access ... │
|
||||
│◀── Refresh Token ──────────│
|
||||
│ (stored encrypted) │
|
||||
│ │
|
||||
[Background Job runs later] │ │
|
||||
│── Refresh for background ─▶│
|
||||
│ (same as single-audience)│
|
||||
```
|
||||
|
||||
**Key difference from interactive:**
|
||||
- Interactive: On-demand token exchange per request
|
||||
- Background: Uses pre-provisioned refresh tokens (Flow 2)
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 5. Smithery Stateless
|
||||
|
||||
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
|
||||
|
||||
Enabled by `SMITHERY_DEPLOYMENT=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── SSE Connect ─────────────▶│ │
|
||||
│ ?nextcloud_url=... │ │
|
||||
│ &username=... │ │
|
||||
│ &app_password=... │ │
|
||||
│ │── SmitheryConfigMiddleware │
|
||||
│ │ Extract URL params │
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no Authorization header) │ │
|
||||
│ │── Create per-request ─────▶│
|
||||
│ │ NextcloudClient │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (from session params) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Configuration passed via URL query parameters (Smithery `configSchema`)
|
||||
- No persistent state - client created fresh per request
|
||||
- No OAuth infrastructure
|
||||
- No background sync support (stateless)
|
||||
- No admin UI available
|
||||
|
||||
**Required session parameters:**
|
||||
- `nextcloud_url`: Nextcloud instance URL
|
||||
- `username`: Nextcloud username
|
||||
- `app_password`: Nextcloud app password
|
||||
|
||||
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Not supported. Smithery mode is fully stateless with no credential storage.
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable. Smithery deployments don't integrate with Astrolabe.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Quick Reference
|
||||
|
||||
### Single-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://localhost:8080
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
```
|
||||
|
||||
### Multi-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Single-Audience (Default)
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
# No username/password triggers OAuth mode
|
||||
|
||||
# Optional: Static client credentials (instead of DCR)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Token Exchange
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### Smithery Stateless
|
||||
```bash
|
||||
SMITHERY_DEPLOYMENT=true
|
||||
# All other config comes from session URL parameters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Authentication](authentication.md) - Configuration details and setup guides
|
||||
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
|
||||
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
|
||||
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
|
||||
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
|
||||
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
|
||||
@@ -223,6 +223,10 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
| Token Storage | None | Refresh tokens only | All tokens |
|
||||
| Deployment Complexity | Low | Medium | High |
|
||||
|
||||
### Astrolabe User Setup (Hybrid Mode)
|
||||
|
||||
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
|
||||
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# Webhook Management Guide
|
||||
|
||||
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
|
||||
|
||||
**Related ADRs:**
|
||||
- ADR-010: Webhook-Based Vector Sync
|
||||
- ADR-020: Deployment Modes and Configuration Validation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before enabling webhooks, ensure:
|
||||
|
||||
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
||||
2. **[Astrolabe app](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
|
||||
|
||||
## Webhook Architecture Overview
|
||||
|
||||
The webhook system has two components:
|
||||
|
||||
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
|
||||
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
|
||||
|
||||
Both must be configured for webhooks to function properly.
|
||||
|
||||
## Deployment Mode Specifics
|
||||
|
||||
### 1. Single-User BasicAuth
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://localhost:8080
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable Webhooks:**
|
||||
1. Register webhooks using occ commands (requires Nextcloud admin):
|
||||
```bash
|
||||
# Enable webhook_listeners app
|
||||
php occ app:enable webhook_listeners
|
||||
|
||||
# Register webhooks for vector sync
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8000/webhooks/nextcloud" \
|
||||
--method POST
|
||||
|
||||
# Repeat for other events (see Event Types below)
|
||||
```
|
||||
|
||||
2. Optionally reduce polling frequency:
|
||||
```bash
|
||||
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
```bash
|
||||
# List registered webhooks
|
||||
php occ webhook_listeners:list
|
||||
|
||||
# Remove specific webhook by ID
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Simplest mode - admin credentials used for all operations
|
||||
- No per-user provisioning required
|
||||
- Background sync runs as the configured admin user
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-User BasicAuth Pass-Through
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
# OAuth client for Astrolabe API access
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
```
|
||||
|
||||
**Credential Architecture:**
|
||||
This mode uses **two separate credential mechanisms**:
|
||||
|
||||
1. **OAuth Session** (for management API access, including webhooks):
|
||||
- Obtained via browser OAuth flow (`/oauth/login`)
|
||||
- Stores refresh token in MCP server's `tokens.db`
|
||||
- Used for webhook registration/management APIs
|
||||
|
||||
2. **App Password** (for background sync):
|
||||
- Generated in Nextcloud Security settings
|
||||
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
|
||||
- Used by background scanners to access Nextcloud APIs
|
||||
|
||||
**Enable Webhooks:**
|
||||
|
||||
#### Step 1: Complete OAuth Login (for Management API)
|
||||
Users must authorize the MCP server to access their Nextcloud:
|
||||
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
|
||||
2. Click **"Authorize via OAuth"** under "Option 1"
|
||||
3. Complete OAuth consent flow
|
||||
4. Verify the page shows "Background Sync Access: Active"
|
||||
|
||||
#### Step 2: Configure App Password (for Background Sync)
|
||||
Since OAuth refresh tokens have short expiry, users should also configure an app password:
|
||||
|
||||
1. Navigate to **Nextcloud Settings → Security**
|
||||
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
|
||||
3. Return to **Nextcloud Settings → Astrolabe**
|
||||
4. Under "Option 2: App Password", paste the app password
|
||||
5. Click **Save**
|
||||
|
||||
#### Step 3: Register Webhooks (Admin)
|
||||
Same as Single-User BasicAuth:
|
||||
```bash
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8003/webhooks/nextcloud" \
|
||||
--method POST
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
|
||||
*Per-User:*
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
|
||||
|
||||
*System-Wide:*
|
||||
```bash
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
If OAuth login fails with "Access forbidden - Your client is not authorized":
|
||||
1. Check if OAuth client is registered:
|
||||
```sql
|
||||
SELECT id, name, client_identifier FROM oc_oidc_clients
|
||||
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
|
||||
```
|
||||
2. Restart MCP server to trigger DCR re-registration
|
||||
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
|
||||
|
||||
If background sync fails with "User no longer provisioned":
|
||||
1. Verify app password is stored:
|
||||
```sql
|
||||
SELECT userid, configkey FROM oc_preferences
|
||||
WHERE appid = 'astrolabe' AND userid = 'username';
|
||||
```
|
||||
2. Ensure user completed **both** OAuth login AND app password setup
|
||||
|
||||
---
|
||||
|
||||
### 3. OAuth Single-Audience (Default OAuth Mode)
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
# No NEXTCLOUD_USERNAME/PASSWORD
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable Webhooks:**
|
||||
|
||||
#### Step 1: User Provisioning
|
||||
Users authorize via OAuth with `offline_access` scope:
|
||||
|
||||
1. MCP client initiates OAuth flow
|
||||
2. User consents to requested scopes including `offline_access`
|
||||
3. MCP server stores refresh token for background operations
|
||||
|
||||
Alternatively, via Astrolabe UI:
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||
2. Click **"Authorize via OAuth"**
|
||||
3. Complete consent flow
|
||||
|
||||
#### Step 2: Register Webhooks (Admin)
|
||||
```bash
|
||||
php occ webhook_listeners:add \
|
||||
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||
--uri "http://mcp-server:8001/webhooks/nextcloud" \
|
||||
--method POST
|
||||
```
|
||||
|
||||
**Disable Webhooks:**
|
||||
|
||||
*Per-User:*
|
||||
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
|
||||
- Via MCP tool: Use `revoke_nextcloud_access` if available
|
||||
|
||||
*System-Wide:*
|
||||
```bash
|
||||
php occ webhook_listeners:remove <webhook-id>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. OAuth Token Exchange (RFC 8693)
|
||||
|
||||
**Configuration:**
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
ENABLE_BACKGROUND_OPERATIONS=true
|
||||
TOKEN_ENCRYPTION_KEY=<key>
|
||||
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Enable/Disable Webhooks:**
|
||||
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
|
||||
|
||||
---
|
||||
|
||||
### 5. Smithery Stateless
|
||||
|
||||
**Configuration:**
|
||||
- Configuration from session URL params
|
||||
- `VECTOR_SYNC_ENABLED=false` (required)
|
||||
|
||||
**Webhooks:**
|
||||
**Not supported.** This mode is stateless with no persistent storage or background operations.
|
||||
|
||||
---
|
||||
|
||||
## Webhook Event Types
|
||||
|
||||
Register these webhook events for full vector sync coverage:
|
||||
|
||||
### File/Note Events
|
||||
```bash
|
||||
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
### Calendar Events
|
||||
```bash
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
### Tables Events
|
||||
```bash
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Webhook Authentication
|
||||
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
|
||||
|
||||
```bash
|
||||
# MCP Server
|
||||
WEBHOOK_SECRET=<generate-random-secret>
|
||||
|
||||
# Nextcloud webhook registration
|
||||
php occ webhook_listeners:add \
|
||||
--event "..." \
|
||||
--uri "$MCP_URL/webhooks/nextcloud" \
|
||||
--header "Authorization: Bearer <secret>"
|
||||
```
|
||||
|
||||
### Token Storage
|
||||
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
|
||||
- Store the key securely (environment variable, secrets manager)
|
||||
- Different users have isolated credential storage
|
||||
|
||||
## Monitoring
|
||||
|
||||
### MCP Server Logs
|
||||
```bash
|
||||
# Docker
|
||||
docker compose logs mcp-multi-user-basic | grep -i webhook
|
||||
|
||||
# Key log messages
|
||||
# - "Queued document from webhook: ..." - Success
|
||||
# - "Webhook authentication failed" - Auth error
|
||||
# - "User X no longer provisioned" - Missing credentials
|
||||
```
|
||||
|
||||
### Nextcloud Logs
|
||||
```bash
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | \
|
||||
jq 'select(.message | contains("webhook"))' | tail
|
||||
```
|
||||
|
||||
### Database Checks
|
||||
```sql
|
||||
-- Check registered webhooks
|
||||
SELECT * FROM oc_webhook_listeners;
|
||||
|
||||
-- Check OAuth clients
|
||||
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
|
||||
|
||||
-- Check user credentials stored by Astrolabe app
|
||||
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Access forbidden - Your client is not authorized to connect"
|
||||
**Cause:** OAuth client registration expired or not present in Nextcloud
|
||||
**Fix:** Restart MCP server to trigger DCR re-registration
|
||||
|
||||
### "User X no longer provisioned, stopping scanner"
|
||||
**Cause:** Background sync credentials missing or expired
|
||||
**Fix:** User must complete credential provisioning (see mode-specific steps)
|
||||
|
||||
### "Failed to fetch" in browser console during OAuth
|
||||
**Cause:** Network issue between browser and MCP server callback endpoint
|
||||
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
|
||||
|
||||
### Webhooks not firing
|
||||
**Causes:**
|
||||
1. `webhook_listeners` app not enabled
|
||||
2. Webhook not registered for the event type
|
||||
3. Background job workers not running
|
||||
**Fix:**
|
||||
```bash
|
||||
php occ app:enable webhook_listeners
|
||||
php occ background:cron # or configure systemd cron
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Add app_passwords table for multi-user BasicAuth mode
|
||||
|
||||
This migration adds support for storing app passwords that are provisioned
|
||||
via Astrolabe's personal settings. This enables background sync in
|
||||
multi-user BasicAuth mode without requiring OAuth.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-01-13 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "002"
|
||||
down_revision = "001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add app_passwords table for multi-user BasicAuth mode."""
|
||||
|
||||
# App passwords table for multi-user BasicAuth background sync
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app_passwords (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_password BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Index for efficient user lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
|
||||
ON app_passwords(updated_at)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop app_passwords table."""
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
|
||||
op.execute("DROP TABLE IF EXISTS app_passwords")
|
||||
@@ -3,4 +3,74 @@
|
||||
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
||||
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
||||
authentication via the UnifiedTokenVerifier.
|
||||
|
||||
This package is organized into modules by domain:
|
||||
- management.py: Server status, user sessions, shared helpers
|
||||
- passwords.py: App password provisioning for multi-user BasicAuth
|
||||
- webhooks.py: Webhook registration management
|
||||
- visualization.py: Search and PDF visualization endpoints
|
||||
"""
|
||||
|
||||
# Re-export all public functions for backward compatibility
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
__version__,
|
||||
_parse_float_param,
|
||||
_parse_int_param,
|
||||
_sanitize_error_for_client,
|
||||
_validate_query_string,
|
||||
extract_bearer_token,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
revoke_user_access,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
from nextcloud_mcp_server.api.passwords import (
|
||||
delete_app_password,
|
||||
get_app_password_status,
|
||||
provision_app_password,
|
||||
)
|
||||
from nextcloud_mcp_server.api.visualization import (
|
||||
get_chunk_context,
|
||||
get_pdf_preview,
|
||||
unified_search,
|
||||
vector_search,
|
||||
)
|
||||
from nextcloud_mcp_server.api.webhooks import (
|
||||
create_webhook,
|
||||
delete_webhook,
|
||||
get_installed_apps,
|
||||
list_webhooks,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Version
|
||||
"__version__",
|
||||
# Shared helpers (from management.py)
|
||||
"extract_bearer_token",
|
||||
"validate_token_and_get_user",
|
||||
"_sanitize_error_for_client",
|
||||
"_parse_int_param",
|
||||
"_parse_float_param",
|
||||
"_validate_query_string",
|
||||
# Status endpoints (from management.py)
|
||||
"get_server_status",
|
||||
"get_vector_sync_status",
|
||||
# Session endpoints (from management.py)
|
||||
"get_user_session",
|
||||
"revoke_user_access",
|
||||
# Password endpoints (from passwords.py)
|
||||
"provision_app_password",
|
||||
"get_app_password_status",
|
||||
"delete_app_password",
|
||||
# Webhook endpoints (from webhooks.py)
|
||||
"get_installed_apps",
|
||||
"list_webhooks",
|
||||
"create_webhook",
|
||||
"delete_webhook",
|
||||
# Visualization endpoints (from visualization.py)
|
||||
"unified_search",
|
||||
"vector_search",
|
||||
"get_chunk_context",
|
||||
"get_pdf_preview",
|
||||
]
|
||||
|
||||
@@ -4,10 +4,15 @@ ADR-018: Provides REST API endpoints for the Nextcloud PHP app to query:
|
||||
- Server status and version
|
||||
- User session information and background access status
|
||||
- Vector sync metrics
|
||||
- Vector search for visualization
|
||||
|
||||
All endpoints use OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
|
||||
|
||||
Shared helper functions for other API modules are also exported from here:
|
||||
- extract_bearer_token: Extract OAuth token from request
|
||||
- validate_token_and_get_user: Validate token and get user ID
|
||||
- _sanitize_error_for_client: Return safe error messages
|
||||
- _parse_int_param, _parse_float_param, _validate_query_string: Parameter validation
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -15,7 +20,6 @@ import time
|
||||
from importlib.metadata import version
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@@ -229,8 +233,13 @@ async def get_server_status(request: Request) -> JSONResponse:
|
||||
if mode == AuthMode.MULTI_USER_BASIC:
|
||||
response_data["supports_app_passwords"] = settings.enable_offline_access
|
||||
|
||||
# Include OIDC configuration if in OAuth mode
|
||||
if auth_mode == "oauth":
|
||||
# Include OIDC configuration if OAuth is available
|
||||
# This includes OAuth mode AND hybrid mode (multi_user_basic + offline_access)
|
||||
# Astrolabe needs OIDC config to discover IdP for OAuth flow in hybrid mode
|
||||
oauth_provisioning_available = auth_mode == "oauth" or (
|
||||
mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access
|
||||
)
|
||||
if oauth_provisioning_available:
|
||||
# Provide IdP discovery information for NC PHP app
|
||||
oidc_config = {}
|
||||
|
||||
@@ -508,909 +517,3 @@ async def revoke_user_access(request: Request) -> JSONResponse:
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||
|
||||
Returns a list of installed app IDs for filtering webhook presets.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Get installed apps using OCS API
|
||||
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
|
||||
# We check which ones are installed and enabled
|
||||
ocs_url = "/ocs/v1.php/cloud/apps"
|
||||
params = {"filter": "enabled"}
|
||||
|
||||
response = await client.get(
|
||||
ocs_url,
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"OCS API returned status {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
|
||||
|
||||
return JSONResponse({"apps": apps})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting installed apps for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def list_webhooks(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/webhooks - List all registered webhooks.
|
||||
|
||||
Returns list of webhook registrations for the authenticated user.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to list webhooks
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhooks = await webhooks_client.list_webhooks()
|
||||
|
||||
return JSONResponse({"webhooks": webhooks})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing webhooks for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def create_webhook(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/webhooks - Create a new webhook registration.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"uri": "http://mcp:8000/webhooks/nextcloud",
|
||||
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
|
||||
}
|
||||
|
||||
Returns the created webhook data including the webhook ID.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
event = body.get("event")
|
||||
uri = body.get("uri")
|
||||
# Accept both camelCase (eventFilter) and snake_case (event_filter)
|
||||
event_filter = body.get("eventFilter") or body.get("event_filter")
|
||||
|
||||
if not event or not uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Bad request",
|
||||
"message": "Missing required fields: event, uri",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to create webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhook_data = await webhooks_client.create_webhook(
|
||||
event=event, uri=uri, event_filter=event_filter
|
||||
)
|
||||
|
||||
return JSONResponse({"webhook": webhook_data})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_webhook(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
|
||||
|
||||
Returns success/failure status.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get webhook_id from path parameter
|
||||
webhook_id = request.path_params.get("webhook_id")
|
||||
if not webhook_id:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Missing webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
webhook_id = int(webhook_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Invalid webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to delete webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
await webhooks_client.delete_webhook(webhook_id=webhook_id)
|
||||
|
||||
return JSONResponse({"success": True, "message": "Webhook deleted"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def unified_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
||||
|
||||
Optimized search endpoint for the Nextcloud Unified Search provider
|
||||
and other PHP app integrations. Returns results with metadata needed
|
||||
for navigation to source documents.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 20, // max: 100
|
||||
"offset": 0, // pagination offset
|
||||
"include_pca": false, // optional PCA coordinates
|
||||
"include_chunks": true // include text snippets
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"results": [{
|
||||
"id": "doc123",
|
||||
"doc_type": "note",
|
||||
"title": "Document Title",
|
||||
"excerpt": "Matching text snippet...",
|
||||
"score": 0.85,
|
||||
"path": "/path/to/file.txt", // for files
|
||||
"board_id": 1, // for deck cards
|
||||
"card_id": 42
|
||||
}],
|
||||
"total_found": 150,
|
||||
"algorithm_used": "hybrid"
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
|
||||
# Validate and parse parameters
|
||||
try:
|
||||
query = body.get("query", "")
|
||||
_validate_query_string(query, max_length=10000)
|
||||
|
||||
limit = _parse_int_param(
|
||||
str(body.get("limit")) if body.get("limit") is not None else None,
|
||||
20,
|
||||
1,
|
||||
100,
|
||||
"limit",
|
||||
)
|
||||
|
||||
offset = _parse_int_param(
|
||||
str(body.get("offset")) if body.get("offset") is not None else None,
|
||||
0,
|
||||
0,
|
||||
1000000,
|
||||
"offset",
|
||||
)
|
||||
|
||||
score_threshold = _parse_float_param(
|
||||
body.get("score_threshold"),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
"score_threshold",
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
include_pca = body.get("include_pca", False)
|
||||
include_chunks = body.get("include_chunks", True)
|
||||
doc_types = body.get("doc_types") # Optional filter
|
||||
|
||||
if not query:
|
||||
return JSONResponse({"results": [], "total_found": 0})
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Request extra results to handle offset
|
||||
search_limit = limit + offset
|
||||
|
||||
# Execute search
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
for doc_type in doc_types:
|
||||
if doc_type:
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
else:
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
)
|
||||
|
||||
# Sort results by score (no deduplication - show all chunks)
|
||||
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Calculate total and apply pagination
|
||||
total_found = len(sorted_results)
|
||||
paginated_results = sorted_results[offset : offset + limit]
|
||||
|
||||
# Format results for Unified Search
|
||||
formatted_results = []
|
||||
for result in paginated_results:
|
||||
# Get document ID (prefer note_id for notes)
|
||||
doc_id = result.id
|
||||
if result.metadata and "note_id" in result.metadata:
|
||||
doc_id = result.metadata["note_id"]
|
||||
|
||||
result_data: dict[str, Any] = {
|
||||
"id": doc_id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"score": result.score,
|
||||
}
|
||||
|
||||
# Include excerpt/chunk if requested (full content, no truncation)
|
||||
if include_chunks and result.excerpt:
|
||||
result_data["excerpt"] = result.excerpt
|
||||
|
||||
# Include navigation metadata from result.metadata
|
||||
if result.metadata:
|
||||
# File path and mimetype for files
|
||||
if "path" in result.metadata:
|
||||
result_data["path"] = result.metadata["path"]
|
||||
if "mime_type" in result.metadata:
|
||||
result_data["mime_type"] = result.metadata["mime_type"]
|
||||
|
||||
# Deck card navigation
|
||||
if "board_id" in result.metadata:
|
||||
result_data["board_id"] = result.metadata["board_id"]
|
||||
if "card_id" in result.metadata:
|
||||
result_data["card_id"] = result.metadata["card_id"]
|
||||
|
||||
# Calendar event metadata
|
||||
if "calendar_id" in result.metadata:
|
||||
result_data["calendar_id"] = result.metadata["calendar_id"]
|
||||
if "event_uid" in result.metadata:
|
||||
result_data["event_uid"] = result.metadata["event_uid"]
|
||||
|
||||
# Add PDF page metadata
|
||||
if result.page_number is not None:
|
||||
result_data["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
result_data["page_count"] = result.page_count
|
||||
|
||||
# Add chunk metadata (always present, defaults to 0 and 1)
|
||||
result_data["chunk_index"] = result.chunk_index
|
||||
result_data["total_chunks"] = result.total_chunks
|
||||
|
||||
# Add chunk offsets for modal navigation
|
||||
if result.chunk_start_offset is not None:
|
||||
result_data["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
result_data["chunk_end_offset"] = result.chunk_end_offset
|
||||
|
||||
formatted_results.append(result_data)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"total_found": total_found,
|
||||
"algorithm_used": algorithm,
|
||||
}
|
||||
|
||||
# Optional PCA coordinates
|
||||
if include_pca and len(paginated_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(
|
||||
paginated_results, query_embedding
|
||||
)
|
||||
response_data["pca_data"] = pca_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA for unified search: {e}")
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in unified search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def vector_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/vector-viz/search - Vector search for visualization.
|
||||
|
||||
Executes semantic search and returns results with optional PCA coordinates
|
||||
for 2D visualization.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 10, // max: 50
|
||||
"include_pca": true, // whether to include 2D coordinates
|
||||
"doc_types": ["note", "file"] // optional filter by document types
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "vector_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
score_threshold = body.get("score_threshold", 0.0)
|
||||
limit = min(body.get("limit", 10), 50) # Enforce max limit
|
||||
include_pca = body.get("include_pca", True)
|
||||
doc_types = body.get("doc_types") # Optional list of document types
|
||||
|
||||
if not query:
|
||||
return JSONResponse(
|
||||
{"error": "Missing required parameter: query"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
|
||||
# which combines dense semantic and sparse BM25 vectors
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Execute search for each doc_type if specified, otherwise search all
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
# Search each doc_type separately and merge results
|
||||
for doc_type in doc_types:
|
||||
if doc_type: # Skip empty strings
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
# Sort merged results by score and limit
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
all_results = all_results[:limit]
|
||||
else:
|
||||
# Search all document types
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Format results for PHP client
|
||||
formatted_results = []
|
||||
for result in all_results:
|
||||
formatted_result = {
|
||||
"id": result.id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"excerpt": result.excerpt[:200] if result.excerpt else "",
|
||||
"score": result.score,
|
||||
"metadata": result.metadata,
|
||||
# Chunk information for context display
|
||||
"chunk_index": result.chunk_index,
|
||||
"total_chunks": result.total_chunks,
|
||||
}
|
||||
# Include optional fields if present
|
||||
if result.chunk_start_offset is not None:
|
||||
formatted_result["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
formatted_result["chunk_end_offset"] = result.chunk_end_offset
|
||||
if result.page_number is not None:
|
||||
formatted_result["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
formatted_result["page_count"] = result.page_count
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"algorithm_used": algorithm,
|
||||
"total_documents": len(formatted_results),
|
||||
}
|
||||
|
||||
# Compute PCA coordinates for visualization using shared function
|
||||
if include_pca and len(all_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
# Get query embedding from search algorithm or generate it
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(all_results, query_embedding)
|
||||
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
|
||||
response_data["query_coords"] = pca_data["query_coords"]
|
||||
if "pca_variance" in pca_data:
|
||||
response_data["pca_variance"] = pca_data["pca_variance"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA coordinates: {e}")
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
elif include_pca:
|
||||
# Not enough results for PCA
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "vector_search")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/chunk-context - Fetch chunk text with context.
|
||||
|
||||
Retrieves the matched chunk along with surrounding text and metadata.
|
||||
Used by clients to display chunk context and highlighted PDFs.
|
||||
|
||||
Query parameters:
|
||||
doc_type: Document type (e.g., "note")
|
||||
doc_id: Document ID
|
||||
start: Chunk start offset (character position)
|
||||
end: Chunk end offset (character position)
|
||||
context: Characters of context before/after (default: 500)
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_chunk_context"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get query parameters
|
||||
doc_type = request.query_params.get("doc_type")
|
||||
doc_id = request.query_params.get("doc_id")
|
||||
start_str = request.query_params.get("start")
|
||||
end_str = request.query_params.get("end")
|
||||
|
||||
# Validate required parameters
|
||||
if not all([doc_type, doc_id, start_str, end_str]):
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing required parameters: doc_type, doc_id, start, end",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Type narrowing: we already checked these are not None above
|
||||
assert start_str is not None
|
||||
assert end_str is not None
|
||||
assert doc_id is not None
|
||||
assert doc_type is not None
|
||||
|
||||
# Parse and validate integer parameters with bounds checking
|
||||
try:
|
||||
context_chars = _parse_int_param(
|
||||
request.query_params.get("context"),
|
||||
500,
|
||||
0,
|
||||
10000,
|
||||
"context_chars",
|
||||
)
|
||||
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
|
||||
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
|
||||
if end <= start:
|
||||
raise ValueError("end must be greater than start")
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
# Convert doc_id to int if possible (most IDs are int)
|
||||
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
|
||||
|
||||
# Get bearer token for client initialization
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Initialize authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=user_id,
|
||||
doc_id=doc_id_val,
|
||||
doc_type=doc_type,
|
||||
chunk_start=start,
|
||||
chunk_end=end,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
if chunk_context is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant if available
|
||||
# This is useful for clients that want to show a pre-rendered image
|
||||
highlighted_page_image = None
|
||||
page_number = chunk_context.page_number
|
||||
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(
|
||||
key="doc_id", match=MatchValue(value=doc_id_val)
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=user_id)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
)
|
||||
|
||||
if points_response[0]:
|
||||
payload = points_response[0][0].payload
|
||||
if payload:
|
||||
highlighted_page_image = payload.get("highlighted_page_image")
|
||||
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
|
||||
if payload.get("page_number") is not None:
|
||||
page_number = payload.get("page_number")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
"success": True,
|
||||
"chunk_text": chunk_context.chunk_text,
|
||||
"before_context": chunk_context.before_context,
|
||||
"after_context": chunk_context.after_context,
|
||||
"has_more_before": chunk_context.has_before_truncation,
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
"page_number": page_number,
|
||||
"chunk_index": chunk_context.chunk_index,
|
||||
"total_chunks": chunk_context.total_chunks,
|
||||
}
|
||||
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""App password management API endpoints.
|
||||
|
||||
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
|
||||
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||
- Store app passwords for background sync operations
|
||||
- Check app password status
|
||||
- Delete stored app passwords
|
||||
|
||||
Authentication is via BasicAuth with the user's Nextcloud credentials.
|
||||
Passwords are validated against Nextcloud before being stored.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
APP_PASSWORD_PATTERN = re.compile(
|
||||
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
|
||||
)
|
||||
|
||||
# Timeout for Nextcloud API validation requests (seconds)
|
||||
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
|
||||
|
||||
# Rate limiting configuration for app password provisioning
|
||||
# Limits: 5 attempts per user per hour
|
||||
RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
|
||||
|
||||
# In-memory rate limiter storage
|
||||
# Structure: {user_id: [(timestamp, success), ...]}
|
||||
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
|
||||
"""Check if user is rate limited for app password operations.
|
||||
|
||||
Implements a sliding window rate limiter to prevent brute-force attacks
|
||||
on the app password provisioning endpoint.
|
||||
|
||||
Args:
|
||||
user_id: User identifier to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_allowed, seconds_until_retry)
|
||||
- is_allowed: True if request should be allowed
|
||||
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
|
||||
"""
|
||||
current_time = time.time()
|
||||
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
|
||||
|
||||
# Clean up old attempts outside the window
|
||||
_rate_limit_attempts[user_id] = [
|
||||
(ts, success)
|
||||
for ts, success in _rate_limit_attempts[user_id]
|
||||
if ts > window_start
|
||||
]
|
||||
|
||||
# Count recent attempts (both successful and failed)
|
||||
recent_attempts = len(_rate_limit_attempts[user_id])
|
||||
|
||||
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||
# Find when the oldest attempt in the window will expire
|
||||
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
|
||||
seconds_until_retry = int(
|
||||
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
|
||||
)
|
||||
return False, max(1, seconds_until_retry)
|
||||
|
||||
return True, 0
|
||||
|
||||
|
||||
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
|
||||
"""Record an app password provisioning attempt for rate limiting.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
success: Whether the attempt was successful
|
||||
"""
|
||||
_rate_limit_attempts[user_id].append((time.time(), success))
|
||||
|
||||
|
||||
def _extract_basic_auth(
|
||||
request: Request, path_user_id: str
|
||||
) -> tuple[str, str, JSONResponse | None]:
|
||||
"""Extract and validate BasicAuth credentials from request.
|
||||
|
||||
Validates:
|
||||
1. Authorization header is present and valid BasicAuth format
|
||||
2. Username in credentials matches the path user_id
|
||||
|
||||
Args:
|
||||
request: Starlette request with Authorization header
|
||||
path_user_id: User ID from the URL path to verify against
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password, error_response)
|
||||
- If successful: (username, password, None)
|
||||
- If failed: ("", "", JSONResponse with error)
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
||||
if not auth_header or not auth_header.startswith("Basic "):
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Missing BasicAuth credentials"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Decode BasicAuth
|
||||
encoded = auth_header.split(" ", 1)[1]
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
except Exception:
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Invalid BasicAuth format"},
|
||||
status_code=401,
|
||||
),
|
||||
)
|
||||
|
||||
# Verify username matches path user_id
|
||||
if username != path_user_id:
|
||||
logger.warning(
|
||||
f"Username mismatch in app password operation for path user {path_user_id}"
|
||||
)
|
||||
return (
|
||||
"",
|
||||
"",
|
||||
JSONResponse(
|
||||
{"success": False, "error": "Username does not match path user_id"},
|
||||
status_code=403,
|
||||
),
|
||||
)
|
||||
|
||||
return username, password, None
|
||||
|
||||
|
||||
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||
|
||||
Checks app.state.storage first, then falls back to creating from environment.
|
||||
This helper avoids repeated storage initialization logic across endpoints.
|
||||
|
||||
Args:
|
||||
request: Starlette request with app state
|
||||
|
||||
Returns:
|
||||
Initialized RefreshTokenStorage instance
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = getattr(request.app.state, "storage", None)
|
||||
|
||||
if not storage:
|
||||
# Multi-user BasicAuth mode may not have oauth_context
|
||||
# Initialize storage from environment
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
return storage
|
||||
|
||||
|
||||
async def provision_app_password(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
|
||||
|
||||
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
|
||||
for multi-user BasicAuth mode background sync.
|
||||
|
||||
The request must include BasicAuth credentials where:
|
||||
- username: Nextcloud user ID (must match path user_id)
|
||||
- password: The app password being provisioned
|
||||
|
||||
The MCP server validates the app password against Nextcloud before storing it.
|
||||
This proves the user owns the password and has access to Nextcloud.
|
||||
|
||||
Security model:
|
||||
- User identity is verified via BasicAuth against Nextcloud
|
||||
- App password is encrypted before storage
|
||||
- Only the user who owns the password can provision it
|
||||
- Rate limited to prevent brute-force attacks
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Check rate limit before processing
|
||||
is_allowed, retry_after = _check_rate_limit(path_user_id)
|
||||
if not is_allowed:
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for app password provisioning: {path_user_id}"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||
},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return error_response
|
||||
|
||||
# Validate app password format
|
||||
if not APP_PASSWORD_PATTERN.match(app_password):
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password format"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Nextcloud host from settings
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
if not nextcloud_host:
|
||||
logger.error("NEXTCLOUD_HOST not configured")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Server not configured"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Validate app password against Nextcloud
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
# Use OCS API to verify credentials
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, app_password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"App password validation failed for user: HTTP {response.status_code}"
|
||||
)
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid app password"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Verify the user ID from response matches
|
||||
data = response.json()
|
||||
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
|
||||
if ocs_user_id != username:
|
||||
logger.warning("User ID mismatch in OCS response")
|
||||
_record_rate_limit_attempt(path_user_id, success=False)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "User ID mismatch"},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate app password: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Store the validated app password
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
await storage.store_app_password(username, app_password)
|
||||
|
||||
_record_rate_limit_attempt(path_user_id, success=True)
|
||||
logger.info(f"Provisioned app password for user: {username}")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password stored for {username}",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "provision_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_app_password_status(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
|
||||
|
||||
Returns status of background sync access for multi-user BasicAuth mode.
|
||||
|
||||
Requires BasicAuth with the user's app password for authentication.
|
||||
"""
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
app_password = await storage.get_app_password(username)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": username,
|
||||
"has_app_password": app_password is not None,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_app_password(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
|
||||
|
||||
Removes the user's app password from MCP server storage.
|
||||
|
||||
Requires BasicAuth with the user's credentials.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing user_id in path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract and validate BasicAuth credentials
|
||||
username, password, error_response = _extract_basic_auth(request, path_user_id)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
# Validate credentials against Nextcloud
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
auth=(username, password),
|
||||
params={"format": "json"},
|
||||
headers={"OCS-APIRequest": "true"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid credentials"},
|
||||
status_code=401,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to validate credentials: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Failed to validate credentials"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
try:
|
||||
storage = await _get_app_password_storage(request)
|
||||
deleted = await storage.delete_app_password(username)
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted app password for user: {username}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password deleted for {username}",
|
||||
}
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": "No app password found to delete",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "delete_app_password")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -0,0 +1,813 @@
|
||||
"""Visualization API endpoints for search and PDF preview.
|
||||
|
||||
ADR-018: Provides REST API endpoints for the Nextcloud PHP app (Astrolabe) to:
|
||||
- Execute unified search with semantic/BM25/hybrid algorithms
|
||||
- Execute vector search with PCA visualization coordinates
|
||||
- Fetch chunk context with surrounding text
|
||||
- Render PDF pages server-side (avoiding CSP/worker issues)
|
||||
|
||||
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pymupdf
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
_parse_float_param,
|
||||
_parse_int_param,
|
||||
_sanitize_error_for_client,
|
||||
_validate_query_string,
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def unified_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
|
||||
|
||||
Optimized search endpoint for the Nextcloud Unified Search provider
|
||||
and other PHP app integrations. Returns results with metadata needed
|
||||
for navigation to source documents.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 20, // max: 100
|
||||
"offset": 0, // pagination offset
|
||||
"include_pca": false, // optional PCA coordinates
|
||||
"include_chunks": true // include text snippets
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"results": [{
|
||||
"id": "doc123",
|
||||
"doc_type": "note",
|
||||
"title": "Document Title",
|
||||
"excerpt": "Matching text snippet...",
|
||||
"score": 0.85,
|
||||
"path": "/path/to/file.txt", // for files
|
||||
"board_id": 1, // for deck cards
|
||||
"card_id": 42
|
||||
}],
|
||||
"total_found": 150,
|
||||
"algorithm_used": "hybrid"
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
|
||||
# Validate and parse parameters
|
||||
try:
|
||||
query = body.get("query", "")
|
||||
_validate_query_string(query, max_length=10000)
|
||||
|
||||
limit = _parse_int_param(
|
||||
str(body.get("limit")) if body.get("limit") is not None else None,
|
||||
20,
|
||||
1,
|
||||
100,
|
||||
"limit",
|
||||
)
|
||||
|
||||
offset = _parse_int_param(
|
||||
str(body.get("offset")) if body.get("offset") is not None else None,
|
||||
0,
|
||||
0,
|
||||
1000000,
|
||||
"offset",
|
||||
)
|
||||
|
||||
score_threshold = _parse_float_param(
|
||||
body.get("score_threshold"),
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
"score_threshold",
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
include_pca = body.get("include_pca", False)
|
||||
include_chunks = body.get("include_chunks", True)
|
||||
doc_types = body.get("doc_types") # Optional filter
|
||||
|
||||
if not query:
|
||||
return JSONResponse({"results": [], "total_found": 0})
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Request extra results to handle offset
|
||||
search_limit = limit + offset
|
||||
|
||||
# Execute search
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
for doc_type in doc_types:
|
||||
if doc_type:
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
else:
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=search_limit,
|
||||
)
|
||||
|
||||
# Sort results by score (no deduplication - show all chunks)
|
||||
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Calculate total and apply pagination
|
||||
total_found = len(sorted_results)
|
||||
paginated_results = sorted_results[offset : offset + limit]
|
||||
|
||||
# Format results for Unified Search
|
||||
formatted_results = []
|
||||
for result in paginated_results:
|
||||
# Get document ID (prefer note_id for notes)
|
||||
doc_id = result.id
|
||||
if result.metadata and "note_id" in result.metadata:
|
||||
doc_id = result.metadata["note_id"]
|
||||
|
||||
result_data: dict[str, Any] = {
|
||||
"id": doc_id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"score": result.score,
|
||||
}
|
||||
|
||||
# Include excerpt/chunk if requested (full content, no truncation)
|
||||
if include_chunks and result.excerpt:
|
||||
result_data["excerpt"] = result.excerpt
|
||||
|
||||
# Include navigation metadata from result.metadata
|
||||
if result.metadata:
|
||||
# File path and mimetype for files
|
||||
if "path" in result.metadata:
|
||||
result_data["path"] = result.metadata["path"]
|
||||
if "mime_type" in result.metadata:
|
||||
result_data["mime_type"] = result.metadata["mime_type"]
|
||||
|
||||
# Deck card navigation
|
||||
if "board_id" in result.metadata:
|
||||
result_data["board_id"] = result.metadata["board_id"]
|
||||
if "card_id" in result.metadata:
|
||||
result_data["card_id"] = result.metadata["card_id"]
|
||||
|
||||
# Calendar event metadata
|
||||
if "calendar_id" in result.metadata:
|
||||
result_data["calendar_id"] = result.metadata["calendar_id"]
|
||||
if "event_uid" in result.metadata:
|
||||
result_data["event_uid"] = result.metadata["event_uid"]
|
||||
|
||||
# Add PDF page metadata
|
||||
if result.page_number is not None:
|
||||
result_data["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
result_data["page_count"] = result.page_count
|
||||
|
||||
# Add chunk metadata (always present, defaults to 0 and 1)
|
||||
result_data["chunk_index"] = result.chunk_index
|
||||
result_data["total_chunks"] = result.total_chunks
|
||||
|
||||
# Add chunk offsets for modal navigation
|
||||
if result.chunk_start_offset is not None:
|
||||
result_data["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
result_data["chunk_end_offset"] = result.chunk_end_offset
|
||||
|
||||
formatted_results.append(result_data)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"total_found": total_found,
|
||||
"algorithm_used": algorithm,
|
||||
}
|
||||
|
||||
# Optional PCA coordinates
|
||||
if include_pca and len(paginated_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(
|
||||
paginated_results, query_embedding
|
||||
)
|
||||
response_data["pca_data"] = pca_data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA for unified search: {e}")
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in unified search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "unified_search"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def vector_search(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/vector-viz/search - Vector search for visualization.
|
||||
|
||||
Executes semantic search and returns results with optional PCA coordinates
|
||||
for 2D visualization.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"query": "search query",
|
||||
"algorithm": "semantic|bm25|hybrid", // default: hybrid
|
||||
"limit": 10, // max: 50
|
||||
"include_pca": true, // whether to include 2D coordinates
|
||||
"doc_types": ["note", "file"] // optional filter by document types
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
{"error": "Vector sync is disabled on this server"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Validate OAuth token and extract user
|
||||
try:
|
||||
user_id, _validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "vector_search"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
query = body.get("query", "")
|
||||
algorithm = body.get("algorithm", "hybrid")
|
||||
fusion = body.get("fusion", "rrf")
|
||||
score_threshold = body.get("score_threshold", 0.0)
|
||||
limit = min(body.get("limit", 10), 50) # Enforce max limit
|
||||
include_pca = body.get("include_pca", True)
|
||||
doc_types = body.get("doc_types") # Optional list of document types
|
||||
|
||||
if not query:
|
||||
return JSONResponse(
|
||||
{"error": "Missing required parameter: query"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate algorithm
|
||||
valid_algorithms = {"semantic", "bm25", "hybrid"}
|
||||
if algorithm not in valid_algorithms:
|
||||
algorithm = "hybrid"
|
||||
|
||||
# Validate fusion method
|
||||
valid_fusions = {"rrf", "dbsf"}
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
else:
|
||||
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
|
||||
# which combines dense semantic and sparse BM25 vectors
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
|
||||
# Execute search for each doc_type if specified, otherwise search all
|
||||
all_results = []
|
||||
if doc_types and isinstance(doc_types, list):
|
||||
# Search each doc_type separately and merge results
|
||||
for doc_type in doc_types:
|
||||
if doc_type: # Skip empty strings
|
||||
results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
doc_type=doc_type,
|
||||
)
|
||||
all_results.extend(results)
|
||||
# Sort merged results by score and limit
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
all_results = all_results[:limit]
|
||||
else:
|
||||
# Search all document types
|
||||
all_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Format results for PHP client
|
||||
formatted_results = []
|
||||
for result in all_results:
|
||||
formatted_result = {
|
||||
"id": result.id,
|
||||
"doc_type": result.doc_type,
|
||||
"title": result.title,
|
||||
"excerpt": result.excerpt[:200] if result.excerpt else "",
|
||||
"score": result.score,
|
||||
"metadata": result.metadata,
|
||||
# Chunk information for context display
|
||||
"chunk_index": result.chunk_index,
|
||||
"total_chunks": result.total_chunks,
|
||||
}
|
||||
# Include optional fields if present
|
||||
if result.chunk_start_offset is not None:
|
||||
formatted_result["chunk_start_offset"] = result.chunk_start_offset
|
||||
if result.chunk_end_offset is not None:
|
||||
formatted_result["chunk_end_offset"] = result.chunk_end_offset
|
||||
if result.page_number is not None:
|
||||
formatted_result["page_number"] = result.page_number
|
||||
if result.page_count is not None:
|
||||
formatted_result["page_count"] = result.page_count
|
||||
formatted_results.append(formatted_result)
|
||||
|
||||
response_data: dict[str, Any] = {
|
||||
"results": formatted_results,
|
||||
"algorithm_used": algorithm,
|
||||
"total_documents": len(formatted_results),
|
||||
}
|
||||
|
||||
# Compute PCA coordinates for visualization using shared function
|
||||
if include_pca and len(all_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
# Get query embedding from search algorithm or generate it
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
pca_data = await compute_pca_coordinates(all_results, query_embedding)
|
||||
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
|
||||
response_data["query_coords"] = pca_data["query_coords"]
|
||||
if "pca_variance" in pca_data:
|
||||
response_data["pca_variance"] = pca_data["pca_variance"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compute PCA coordinates: {e}")
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
elif include_pca:
|
||||
# Not enough results for PCA
|
||||
response_data["coordinates_3d"] = []
|
||||
response_data["query_coords"] = []
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "vector_search")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/chunk-context - Fetch chunk text with context.
|
||||
|
||||
Retrieves the matched chunk along with surrounding text and metadata.
|
||||
Used by clients to display chunk context and highlighted PDFs.
|
||||
|
||||
Query parameters:
|
||||
doc_type: Document type (e.g., "note")
|
||||
doc_id: Document ID
|
||||
start: Chunk start offset (character position)
|
||||
end: Chunk end offset (character position)
|
||||
context: Characters of context before/after (default: 500)
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_chunk_context"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get query parameters
|
||||
doc_type = request.query_params.get("doc_type")
|
||||
doc_id = request.query_params.get("doc_id")
|
||||
start_str = request.query_params.get("start")
|
||||
end_str = request.query_params.get("end")
|
||||
|
||||
# Validate required parameters
|
||||
if not all([doc_type, doc_id, start_str, end_str]):
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing required parameters: doc_type, doc_id, start, end",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Type narrowing: we already checked these are not None above
|
||||
assert start_str is not None
|
||||
assert end_str is not None
|
||||
assert doc_id is not None
|
||||
assert doc_type is not None
|
||||
|
||||
# Parse and validate integer parameters with bounds checking
|
||||
try:
|
||||
context_chars = _parse_int_param(
|
||||
request.query_params.get("context"),
|
||||
500,
|
||||
0,
|
||||
10000,
|
||||
"context_chars",
|
||||
)
|
||||
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
|
||||
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
|
||||
if end <= start:
|
||||
raise ValueError("end must be greater than start")
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
# Convert doc_id to int if possible (most IDs are int)
|
||||
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
|
||||
|
||||
# Get bearer token for client initialization
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Initialize authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=user_id,
|
||||
doc_id=doc_id_val,
|
||||
doc_type=doc_type,
|
||||
chunk_start=start,
|
||||
chunk_end=end,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
if chunk_context is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant if available
|
||||
# This is useful for clients that want to show a pre-rendered image
|
||||
highlighted_page_image = None
|
||||
page_number = chunk_context.page_number
|
||||
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(
|
||||
key="doc_id", match=MatchValue(value=doc_id_val)
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=user_id)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
)
|
||||
|
||||
if points_response[0]:
|
||||
payload = points_response[0][0].payload
|
||||
if payload:
|
||||
highlighted_page_image = payload.get("highlighted_page_image")
|
||||
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
|
||||
if payload.get("page_number") is not None:
|
||||
page_number = payload.get("page_number")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
|
||||
# Build response
|
||||
response_data = {
|
||||
"success": True,
|
||||
"chunk_text": chunk_context.chunk_text,
|
||||
"before_context": chunk_context.before_context,
|
||||
"after_context": chunk_context.after_context,
|
||||
"has_more_before": chunk_context.has_before_truncation,
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
"page_number": page_number,
|
||||
"chunk_index": chunk_context.chunk_index,
|
||||
"total_chunks": chunk_context.total_chunks,
|
||||
}
|
||||
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
|
||||
return JSONResponse(
|
||||
{"error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def get_pdf_preview(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/pdf-preview - Render PDF page to PNG image.
|
||||
|
||||
Server-side PDF rendering using PyMuPDF. This endpoint allows Astrolabe
|
||||
to display PDF pages without requiring client-side PDF.js, avoiding CSP
|
||||
worker restrictions and ES private field issues in Chromium.
|
||||
|
||||
Query parameters:
|
||||
file_path: WebDAV path to PDF file (e.g., "/Documents/report.pdf")
|
||||
page: Page number (1-indexed, default: 1)
|
||||
scale: Zoom factor for rendering (default: 2.0 = 144 DPI)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"image": "<base64-encoded-png>",
|
||||
"page_number": 1,
|
||||
"total_pages": 10
|
||||
}
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
# Log incoming request
|
||||
file_path_param = request.query_params.get("file_path", "<not provided>")
|
||||
page_param = request.query_params.get("page", "1")
|
||||
logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}")
|
||||
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
logger.info(f"PDF preview authenticated for user: {user_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_pdf_preview"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse and validate parameters
|
||||
file_path = request.query_params.get("file_path")
|
||||
if not file_path:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Missing required parameter: file_path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate no path traversal sequences
|
||||
if ".." in file_path:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid file path"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
page_num = _parse_int_param(
|
||||
request.query_params.get("page"), 1, 1, 10000, "page"
|
||||
)
|
||||
scale = _parse_float_param(
|
||||
request.query_params.get("scale"), 2.0, 0.5, 5.0, "scale"
|
||||
)
|
||||
except ValueError as e:
|
||||
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
||||
|
||||
# Get bearer token for WebDAV authentication
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing token")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Download PDF via WebDAV using user's token
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
pdf_bytes, _ = await nc_client.webdav.read_file(file_path)
|
||||
|
||||
# Check file size limit (50 MB)
|
||||
max_pdf_size = 50 * 1024 * 1024
|
||||
if len(pdf_bytes) > max_pdf_size:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"PDF file exceeds maximum size limit ({max_pdf_size // (1024 * 1024)} MB)",
|
||||
},
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
# Render page with PyMuPDF
|
||||
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
|
||||
try:
|
||||
total_pages = doc.page_count
|
||||
|
||||
# Validate page number
|
||||
if page_num > total_pages:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Page {page_num} does not exist (document has {total_pages} pages)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
page = doc[page_num - 1] # 0-indexed
|
||||
mat = pymupdf.Matrix(scale, scale)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
png_bytes = pix.tobytes("png")
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
# Encode as base64
|
||||
image_b64 = base64.b64encode(png_bytes).decode("ascii")
|
||||
|
||||
logger.info(
|
||||
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, "
|
||||
f"{len(png_bytes):,} bytes"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"image": image_b64,
|
||||
"page_number": page_num,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"PDF file not found: {file_path_param}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "PDF file not found"},
|
||||
status_code=404,
|
||||
)
|
||||
except (pymupdf.FileDataError, pymupdf.EmptyFileError):
|
||||
logger.warning(f"Invalid or corrupted PDF file: {file_path_param}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": "Invalid or corrupted PDF file"},
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"PDF preview error: {e}", exc_info=True)
|
||||
error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": error_msg},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Webhook management API endpoints.
|
||||
|
||||
Provides REST API endpoints for managing webhook registrations with Nextcloud.
|
||||
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||
- List installed Nextcloud apps
|
||||
- Create, list, and delete webhook registrations
|
||||
|
||||
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
_sanitize_error_for_client,
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
|
||||
|
||||
Returns a list of installed app IDs for filtering webhook presets.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Get installed apps using OCS API
|
||||
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
|
||||
# We check which ones are installed and enabled
|
||||
ocs_url = "/ocs/v1.php/cloud/apps"
|
||||
params = {"filter": "enabled"}
|
||||
|
||||
response = await client.get(
|
||||
ocs_url,
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"OCS API returned status {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
|
||||
|
||||
return JSONResponse({"apps": apps})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting installed apps for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "get_installed_apps"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def list_webhooks(request: Request) -> JSONResponse:
|
||||
"""GET /api/v1/webhooks - List all registered webhooks.
|
||||
|
||||
Returns list of webhook registrations for the authenticated user.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to list webhooks
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhooks = await webhooks_client.list_webhooks()
|
||||
|
||||
return JSONResponse({"webhooks": webhooks})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing webhooks for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "list_webhooks"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def create_webhook(request: Request) -> JSONResponse:
|
||||
"""POST /api/v1/webhooks - Create a new webhook registration.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"uri": "http://mcp:8000/webhooks/nextcloud",
|
||||
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
|
||||
}
|
||||
|
||||
Returns the created webhook data including the webhook ID.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
event = body.get("event")
|
||||
uri = body.get("uri")
|
||||
# Accept both camelCase (eventFilter) and snake_case (event_filter)
|
||||
event_filter = body.get("eventFilter") or body.get("event_filter")
|
||||
|
||||
if not event or not uri:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Bad request",
|
||||
"message": "Missing required fields: event, uri",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to create webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
webhook_data = await webhooks_client.create_webhook(
|
||||
event=event, uri=uri, event_filter=event_filter
|
||||
)
|
||||
|
||||
return JSONResponse({"webhook": webhook_data})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "create_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def delete_webhook(request: Request) -> JSONResponse:
|
||||
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
|
||||
|
||||
Returns success/failure status.
|
||||
|
||||
Requires OAuth bearer token for authentication.
|
||||
"""
|
||||
try:
|
||||
# Validate OAuth token and extract user
|
||||
user_id, validated = await validate_token_and_get_user(request)
|
||||
except Exception as e:
|
||||
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get webhook_id from path parameter
|
||||
webhook_id = request.path_params.get("webhook_id")
|
||||
if not webhook_id:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Missing webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
webhook_id = int(webhook_id)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"error": "Bad request", "message": "Invalid webhook_id"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
raise ValueError("Missing Authorization header")
|
||||
|
||||
# Get Nextcloud host from OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
) as client:
|
||||
# Use WebhooksClient to delete webhook
|
||||
webhooks_client = WebhooksClient(client, user_id)
|
||||
await webhooks_client.delete_webhook(webhook_id=webhook_id)
|
||||
|
||||
return JSONResponse({"success": True, "message": "Webhook deleted"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting webhook for user {user_id}: {e}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Internal error",
|
||||
"message": _sanitize_error_for_client(e, "delete_webhook"),
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
@@ -2012,7 +2012,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
checks["auth_mode"] = "multi_user_basic"
|
||||
checks["auth_configured"] = "ok"
|
||||
# Indicate if app passwords are supported (when offline_access enabled)
|
||||
checks["supports_app_passwords"] = settings.enable_offline_access
|
||||
checks["supports_app_passwords"] = get_settings().enable_offline_access
|
||||
elif mode == AuthMode.SINGLE_USER_BASIC:
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
@@ -2029,9 +2029,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
|
||||
# Check Qdrant status if using network mode (external Qdrant service)
|
||||
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||
settings = get_settings()
|
||||
vector_sync_enabled = settings.vector_sync_enabled
|
||||
qdrant_url = os.getenv("QDRANT_URL") # Only set in network mode
|
||||
|
||||
if vector_sync_enabled and qdrant_url:
|
||||
@@ -2112,15 +2112,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||
)
|
||||
if enable_management_apis:
|
||||
from nextcloud_mcp_server.api.management import (
|
||||
from nextcloud_mcp_server.api import (
|
||||
create_webhook,
|
||||
delete_app_password,
|
||||
delete_webhook,
|
||||
get_app_password_status,
|
||||
get_chunk_context,
|
||||
get_installed_apps,
|
||||
get_pdf_preview,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
list_webhooks,
|
||||
provision_app_password,
|
||||
revoke_user_access,
|
||||
unified_search,
|
||||
vector_search,
|
||||
@@ -2148,12 +2152,36 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
# App password endpoints for multi-user BasicAuth mode
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
)
|
||||
)
|
||||
routes.append(
|
||||
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
|
||||
)
|
||||
routes.append(
|
||||
Route("/api/v1/chunk-context", get_chunk_context, methods=["GET"])
|
||||
)
|
||||
# PDF preview endpoint for Astrolabe (server-side rendering)
|
||||
routes.append(Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]))
|
||||
# ADR-018: Unified search endpoint for Nextcloud PHP app integration
|
||||
routes.append(Route("/api/v1/search", unified_search, methods=["POST"]))
|
||||
routes.append(Route("/api/v1/apps", get_installed_apps, methods=["GET"]))
|
||||
@@ -2166,8 +2194,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
logger.info(
|
||||
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
||||
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
||||
"/api/v1/users/{user_id}/app-password, "
|
||||
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||
"/api/v1/webhooks"
|
||||
"/api/v1/webhooks, /api/v1/pdf-preview"
|
||||
)
|
||||
|
||||
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||
|
||||
@@ -1240,6 +1240,180 @@ class RefreshTokenStorage:
|
||||
|
||||
return deleted
|
||||
|
||||
# ============================================================================
|
||||
# App Password Storage (multi-user BasicAuth mode)
|
||||
# ============================================================================
|
||||
|
||||
async def store_app_password(
|
||||
self,
|
||||
user_id: str,
|
||||
app_password: str,
|
||||
) -> None:
|
||||
"""
|
||||
Store encrypted app password for background sync (multi-user BasicAuth mode).
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
app_password: Nextcloud app password to store
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for app password storage."
|
||||
)
|
||||
|
||||
encrypted_password = self.cipher.encrypt(app_password.encode())
|
||||
now = int(time.time())
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO app_passwords
|
||||
(user_id, encrypted_password, created_at, updated_at)
|
||||
VALUES (
|
||||
?,
|
||||
?,
|
||||
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
|
||||
?
|
||||
)
|
||||
""",
|
||||
(user_id, encrypted_password, user_id, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "success")
|
||||
logger.info(f"Stored app password for user {user_id}")
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "insert", duration, "error")
|
||||
raise
|
||||
|
||||
# Audit log
|
||||
await self._audit_log(
|
||||
event="store_app_password",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
|
||||
async def get_app_password(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve and decrypt app password for a user.
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
|
||||
Returns:
|
||||
Decrypted app password, or None if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
if not self.cipher:
|
||||
raise RuntimeError(
|
||||
"Encryption key not configured. "
|
||||
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||
(user_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"No app password found for user {user_id}")
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
return None
|
||||
|
||||
encrypted_password = row[0]
|
||||
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "success")
|
||||
logger.debug(f"Retrieved app password for user {user_id}")
|
||||
|
||||
return decrypted_password
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def delete_app_password(self, user_id: str) -> bool:
|
||||
"""
|
||||
Delete app password for a user.
|
||||
|
||||
Args:
|
||||
user_id: Nextcloud user ID
|
||||
|
||||
Returns:
|
||||
True if password was deleted, False if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM app_passwords WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "success")
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Deleted app password for user {user_id}")
|
||||
await self._audit_log(
|
||||
event="delete_app_password",
|
||||
user_id=user_id,
|
||||
auth_method="app_password",
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No app password to delete for user {user_id}")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "error")
|
||||
raise
|
||||
|
||||
async def get_all_app_password_user_ids(self) -> list[str]:
|
||||
"""
|
||||
Get list of all user IDs with stored app passwords.
|
||||
|
||||
Returns:
|
||||
List of user IDs
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
user_ids = [row[0] for row in rows]
|
||||
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||
return user_ids
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,7 @@ from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,9 +107,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
"status": str, # "syncing" or "idle"
|
||||
}
|
||||
"""
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
if not vector_sync_enabled:
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -127,10 +128,8 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection
|
||||
@@ -634,7 +633,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled (needed for Welcome tab)
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||
settings = get_settings()
|
||||
vector_sync_enabled = settings.vector_sync_enabled
|
||||
|
||||
# Render template
|
||||
template = _jinja_env.get_template("user_info.html")
|
||||
|
||||
@@ -255,18 +255,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 +291,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 +651,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 +701,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 +812,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 +1089,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
|
||||
|
||||
@@ -386,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> None:
|
||||
# Use the non-API route /cards/{cardId}/reorder which correctly reads
|
||||
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
|
||||
# has a parameter conflict where URL stackId overrides body stackId.
|
||||
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
json_data = {"order": order, "stackId": target_stack_id}
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
||||
f"/apps/deck/cards/{card_id}/reorder",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Labels
|
||||
|
||||
@@ -637,7 +637,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Remove Label from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
@@ -692,7 +694,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Unassign User from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Semantic search MCP tools using vector database."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import anyio
|
||||
from httpx import RequestError
|
||||
@@ -658,12 +657,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
after creating or updating content across all indexed apps.
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
if not vector_sync_enabled:
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return VectorSyncStatusResponse(
|
||||
indexed_count=0,
|
||||
pending_count=0,
|
||||
|
||||
@@ -8,8 +8,8 @@ Manages background vector sync for multi-user deployments:
|
||||
Authentication strategies are mutually exclusive by deployment mode:
|
||||
|
||||
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
|
||||
- Uses app passwords obtained via Astrolabe Management API
|
||||
- Users provision via Astrolabe personal settings
|
||||
- Uses app passwords stored locally in MCP server's database
|
||||
- Users provision via Astrolabe personal settings, which sends to MCP API
|
||||
- OAuth is NOT used
|
||||
|
||||
OAuth mode (with external IdP like Keycloak):
|
||||
@@ -33,7 +33,6 @@ from anyio.streams.memory import (
|
||||
)
|
||||
from httpx import BasicAuth
|
||||
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||
@@ -71,15 +70,18 @@ class UserSyncState:
|
||||
async def get_user_client_basic_auth(
|
||||
user_id: str,
|
||||
nextcloud_host: str,
|
||||
storage: "RefreshTokenStorage | None" = None,
|
||||
) -> NextcloudClient:
|
||||
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
|
||||
|
||||
For multi-user BasicAuth deployments where users provision app passwords
|
||||
via Astrolabe personal settings. OAuth is NOT used in this mode.
|
||||
via Astrolabe personal settings. The app password is stored locally in the
|
||||
MCP server's database after being provisioned through the management API.
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
nextcloud_host: Nextcloud base URL
|
||||
storage: Optional RefreshTokenStorage instance (created from env if not provided)
|
||||
|
||||
Returns:
|
||||
Authenticated NextcloudClient with BasicAuth
|
||||
@@ -87,21 +89,15 @@ async def get_user_client_basic_auth(
|
||||
Raises:
|
||||
NotProvisionedError: If user has not provisioned an app password
|
||||
"""
|
||||
settings = get_settings()
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
raise NotProvisionedError(
|
||||
"Astrolabe client credentials not configured. "
|
||||
"Set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET for app password retrieval."
|
||||
)
|
||||
# Get or create storage instance
|
||||
if storage is None:
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
astrolabe = AstrolabeClient(
|
||||
nextcloud_host=nextcloud_host,
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
)
|
||||
|
||||
app_password = await astrolabe.get_user_app_password(user_id)
|
||||
# Retrieve app password from local storage
|
||||
app_password = await storage.get_app_password(user_id)
|
||||
|
||||
if not app_password:
|
||||
raise NotProvisionedError(
|
||||
@@ -419,8 +415,15 @@ async def user_manager_task(
|
||||
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Get current provisioned users
|
||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||
# Get current provisioned users based on mode
|
||||
if use_basic_auth:
|
||||
# BasicAuth mode: query app_passwords table
|
||||
provisioned_users = set(
|
||||
await refresh_token_storage.get_all_app_password_user_ids()
|
||||
)
|
||||
else:
|
||||
# OAuth mode: query refresh_tokens table
|
||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||
active_users = set(user_states.keys())
|
||||
|
||||
# Start scanners for new users
|
||||
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.60.2"
|
||||
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 ../..
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database query helper for development.
|
||||
|
||||
Wraps `docker compose exec db mariadb` to execute SQL statements against
|
||||
the Nextcloud MariaDB database.
|
||||
|
||||
Usage:
|
||||
./scripts/dbquery.py "SELECT * FROM oc_notes LIMIT 5"
|
||||
./scripts/dbquery.py -u root -p password "SHOW TABLES"
|
||||
./scripts/dbquery.py --json "SELECT * FROM oc_oidc_clients"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_compose_dir() -> Path:
|
||||
"""Find the directory containing docker-compose.yml."""
|
||||
current = Path(__file__).resolve().parent
|
||||
while current != current.parent:
|
||||
if (current / "docker-compose.yml").exists():
|
||||
return current
|
||||
if (current / "compose.yml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Default to script's parent directory
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run_query(
|
||||
sql: str,
|
||||
user: str = "root",
|
||||
password: str = "password",
|
||||
database: str = "nextcloud",
|
||||
vertical: bool = False,
|
||||
json_output: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""
|
||||
Execute SQL via docker compose exec.
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
compose_dir = find_compose_dir()
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T", # Disable pseudo-TTY allocation
|
||||
"db",
|
||||
"mariadb",
|
||||
f"-u{user}",
|
||||
f"-p{password}",
|
||||
database,
|
||||
"-e",
|
||||
sql,
|
||||
]
|
||||
|
||||
if vertical:
|
||||
cmd.insert(-2, "-E") # Vertical output format
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=compose_dir,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute SQL queries against the Nextcloud MariaDB database",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s "SELECT COUNT(*) FROM oc_notes"
|
||||
%(prog)s "SELECT id, name FROM oc_oidc_clients"
|
||||
%(prog)s -E "SELECT * FROM oc_users LIMIT 1"
|
||||
%(prog)s --user nextcloud --password nextcloud "SHOW TABLES"
|
||||
""",
|
||||
)
|
||||
parser.add_argument("sql", help="SQL statement to execute")
|
||||
parser.add_argument(
|
||||
"-u", "--user", default="root", help="Database user (default: root)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--password",
|
||||
default="password",
|
||||
help="Database password (default: password)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--database",
|
||||
default="nextcloud",
|
||||
help="Database name (default: nextcloud)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-E",
|
||||
"--vertical",
|
||||
action="store_true",
|
||||
help="Print output vertically (one column per line)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json_output",
|
||||
help="Request JSON output (if supported)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
returncode, stdout, stderr = run_query(
|
||||
sql=args.sql,
|
||||
user=args.user,
|
||||
password=args.password,
|
||||
database=args.database,
|
||||
vertical=args.vertical,
|
||||
json_output=args.json_output,
|
||||
)
|
||||
|
||||
if stdout:
|
||||
print(stdout, end="")
|
||||
if stderr:
|
||||
# Filter out the password warning
|
||||
filtered_stderr = "\n".join(
|
||||
line
|
||||
for line in stderr.splitlines()
|
||||
if "Using a password on the command line interface can be insecure"
|
||||
not in line
|
||||
)
|
||||
if filtered_stderr:
|
||||
print(filtered_stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite database query helper for MCP service development.
|
||||
|
||||
Wraps `docker compose exec <service> sqlite3` to execute SQL statements
|
||||
against the token storage database in any MCP service container.
|
||||
|
||||
Usage:
|
||||
./scripts/sqlitequery.py ".tables"
|
||||
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||
./scripts/sqlitequery.py -s keycloak --headers "SELECT * FROM oauth_clients"
|
||||
./scripts/sqlitequery.py --json "SELECT * FROM audit_logs LIMIT 5"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Service name aliases for convenience
|
||||
SERVICE_ALIASES = {
|
||||
"mcp": "mcp",
|
||||
"oauth": "mcp-oauth",
|
||||
"mcp-oauth": "mcp-oauth",
|
||||
"keycloak": "mcp-keycloak",
|
||||
"mcp-keycloak": "mcp-keycloak",
|
||||
"basic": "mcp-multi-user-basic",
|
||||
"multi-user-basic": "mcp-multi-user-basic",
|
||||
"mcp-multi-user-basic": "mcp-multi-user-basic",
|
||||
}
|
||||
|
||||
|
||||
def find_compose_dir() -> Path:
|
||||
"""Find the directory containing docker-compose.yml."""
|
||||
current = Path(__file__).resolve().parent
|
||||
while current != current.parent:
|
||||
if (current / "docker-compose.yml").exists():
|
||||
return current
|
||||
if (current / "compose.yml").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
# Default to script's parent directory
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def resolve_service(service: str) -> str:
|
||||
"""Resolve service alias to container name."""
|
||||
resolved = SERVICE_ALIASES.get(service.lower())
|
||||
if resolved is None:
|
||||
# Not a known alias, use as-is (might be a custom service)
|
||||
return service
|
||||
return resolved
|
||||
|
||||
|
||||
def run_query(
|
||||
sql: str,
|
||||
service: str = "mcp",
|
||||
database: str = "/app/data/tokens.db",
|
||||
headers: bool = False,
|
||||
json_output: bool = False,
|
||||
column_mode: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""
|
||||
Execute SQL via docker compose exec.
|
||||
|
||||
Returns:
|
||||
Tuple of (return_code, stdout, stderr)
|
||||
"""
|
||||
compose_dir = find_compose_dir()
|
||||
container = resolve_service(service)
|
||||
|
||||
# Build sqlite3 command with options
|
||||
sqlite_args = []
|
||||
|
||||
# Set output mode
|
||||
if json_output:
|
||||
sqlite_args.extend(["-json"])
|
||||
elif column_mode:
|
||||
sqlite_args.extend(["-column"])
|
||||
|
||||
# Enable headers
|
||||
if headers or column_mode:
|
||||
sqlite_args.extend(["-header"])
|
||||
|
||||
cmd = [
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T", # Disable pseudo-TTY allocation
|
||||
container,
|
||||
"sqlite3",
|
||||
*sqlite_args,
|
||||
database,
|
||||
sql,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=compose_dir,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute SQL queries against SQLite databases in MCP service containers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Services:
|
||||
mcp Single-user BasicAuth mode (default)
|
||||
oauth Nextcloud OAuth mode (mcp-oauth)
|
||||
keycloak Keycloak OAuth mode (mcp-keycloak)
|
||||
basic Multi-user BasicAuth mode (mcp-multi-user-basic)
|
||||
|
||||
Examples:
|
||||
%(prog)s ".tables"
|
||||
%(prog)s -s oauth "SELECT user_id FROM refresh_tokens"
|
||||
%(prog)s -s keycloak ".schema oauth_clients"
|
||||
%(prog)s --headers "SELECT * FROM audit_logs LIMIT 5"
|
||||
%(prog)s --json "SELECT * FROM oauth_sessions"
|
||||
""",
|
||||
)
|
||||
parser.add_argument("sql", help="SQL statement or SQLite command to execute")
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--service",
|
||||
default="mcp",
|
||||
help="Target service (mcp, oauth, keycloak, basic) (default: mcp)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--database",
|
||||
default="/app/data/tokens.db",
|
||||
help="Database path inside container (default: /app/data/tokens.db)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headers",
|
||||
action="store_true",
|
||||
help="Show column headers",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
dest="json_output",
|
||||
help="Output in JSON format",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--column",
|
||||
action="store_true",
|
||||
dest="column_mode",
|
||||
help="Output in column format with headers",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
returncode, stdout, stderr = run_query(
|
||||
sql=args.sql,
|
||||
service=args.service,
|
||||
database=args.database,
|
||||
headers=args.headers,
|
||||
json_output=args.json_output,
|
||||
column_mode=args.column_mode,
|
||||
)
|
||||
|
||||
if stdout:
|
||||
print(stdout, end="")
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
+31
-22
@@ -2351,32 +2351,41 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
||||
except Exception as e:
|
||||
logger.warning(f"Error creating editors group (may already exist): {e}")
|
||||
|
||||
# Create each test user
|
||||
# Create each test user (idempotent - check if exists first)
|
||||
for username, config in test_user_configs.items():
|
||||
# Check if user already exists
|
||||
user_exists = False
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=username,
|
||||
password=config["password"],
|
||||
display_name=config["display_name"],
|
||||
email=config["email"],
|
||||
)
|
||||
logger.info(f"Created test user: {username}")
|
||||
created_users.append(username)
|
||||
await nc_client.users.get_user_details(username)
|
||||
user_exists = True
|
||||
logger.info(f"Test user {username} already exists, skipping creation")
|
||||
except Exception:
|
||||
# User doesn't exist, proceed with creation
|
||||
pass
|
||||
|
||||
# Add user to groups if specified
|
||||
for group in config["groups"]:
|
||||
try:
|
||||
await nc_client.users.add_user_to_group(username, group)
|
||||
logger.info(f"Added {username} to group {group}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error adding {username} to group {group}: {e}")
|
||||
if not user_exists:
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=username,
|
||||
password=config["password"],
|
||||
display_name=config["display_name"],
|
||||
email=config["email"],
|
||||
)
|
||||
logger.info(f"Created test user: {username}")
|
||||
created_users.append(username) # Only track users WE created
|
||||
|
||||
except Exception as e:
|
||||
# User might already exist, that's okay
|
||||
logger.warning(
|
||||
f"Could not create user {username} (may already exist): {e}"
|
||||
)
|
||||
created_users.append(username) # Add to list anyway for cleanup
|
||||
# Add user to groups if specified
|
||||
for group in config["groups"]:
|
||||
try:
|
||||
await nc_client.users.add_user_to_group(username, group)
|
||||
logger.info(f"Added {username} to group {group}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error adding {username} to group {group}: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create user {username}: {e}")
|
||||
|
||||
logger.info(f"Test users setup complete: {created_users}")
|
||||
yield test_user_configs
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Integration tests for app password provisioning via Astrolabe.
|
||||
"""Integration tests for app password provisioning via management API.
|
||||
|
||||
Tests the complete flow for multi-user BasicAuth mode:
|
||||
1. User stores app password via Astrolabe API
|
||||
2. MCP server retrieves it via OAuth client credentials
|
||||
3. Background sync uses it to access Nextcloud (NOT OAuth refresh tokens)
|
||||
1. User stores app password via management API endpoint
|
||||
2. MCP server stores it locally (encrypted)
|
||||
3. Background sync uses locally stored password to access Nextcloud
|
||||
|
||||
These tests verify that BasicAuth and OAuth are completely separate concerns
|
||||
with no fallback between them.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
NotProvisionedError,
|
||||
get_user_client,
|
||||
@@ -21,140 +24,60 @@ from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_astrolabe_client_initialization():
|
||||
"""Test AstrolabeClient can be instantiated."""
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
)
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
assert client is not None
|
||||
assert client.nextcloud_host == "http://localhost:8080"
|
||||
assert client.client_id == "test-client"
|
||||
assert client.client_secret == "test-secret"
|
||||
assert client._token_cache is None
|
||||
|
||||
@pytest.fixture
|
||||
async def temp_storage(encryption_key):
|
||||
"""Create temporary storage instance with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_provisioning.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_astrolabe_client_get_access_token_requires_oidc():
|
||||
"""Test that getting access token requires OIDC discovery endpoint."""
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
client_id="test-client",
|
||||
client_secret="test-secret",
|
||||
)
|
||||
async def test_basic_auth_mode_uses_local_storage(temp_storage, mocker):
|
||||
"""Test that BasicAuth mode uses locally stored app passwords.
|
||||
|
||||
# This will fail without proper OIDC setup, which is expected
|
||||
# The test verifies the client follows the OAuth client credentials flow
|
||||
try:
|
||||
token = await client.get_access_token()
|
||||
# If we get here, OIDC is configured
|
||||
assert token is not None
|
||||
except Exception as e:
|
||||
# Expected if OIDC not fully configured for test client
|
||||
# 400/401/403/404 all indicate the flow is working but credentials are invalid
|
||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_app_password_returns_none_for_unconfigured_user():
|
||||
"""Test that get_user_app_password returns None for users without app passwords."""
|
||||
# This requires valid OAuth client credentials
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
pytest.skip("OAuth client credentials not configured")
|
||||
|
||||
client = AstrolabeClient(
|
||||
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
)
|
||||
|
||||
# Try to get app password for a user that hasn't provisioned one
|
||||
try:
|
||||
app_password = await client.get_user_app_password("nonexistent_user")
|
||||
# Should return None for unconfigured user (404 response)
|
||||
assert app_password is None
|
||||
except Exception as e:
|
||||
# May fail with auth error if OAuth not fully configured
|
||||
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_basic_auth_mode_uses_app_password_only(mocker):
|
||||
"""Test that BasicAuth mode uses ONLY app passwords, NOT OAuth tokens.
|
||||
|
||||
In multi-user BasicAuth mode, OAuth refresh tokens are NOT used.
|
||||
This is a complete separation of concerns.
|
||||
In multi-user BasicAuth mode, app passwords are stored locally
|
||||
in the MCP server's database after being provisioned via the API.
|
||||
"""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
# Store an app password in local storage
|
||||
await temp_storage.store_app_password("test_user", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||
|
||||
# Mock AstrolabeClient to return an app password
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call get_user_client in BasicAuth mode
|
||||
_client = await get_user_client(
|
||||
# Call get_user_client_basic_auth with local storage
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id="test_user",
|
||||
token_broker=None, # No token broker needed for BasicAuth mode
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
# Verify app password was requested
|
||||
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
||||
|
||||
# Verify client was created successfully with correct username
|
||||
assert _client is not None
|
||||
assert _client.username == "test_user"
|
||||
# Verify client was created with correct credentials
|
||||
assert client is not None
|
||||
assert client.username == "test_user"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
||||
async def test_basic_auth_mode_raises_error_without_app_password(temp_storage):
|
||||
"""Test that BasicAuth mode raises NotProvisionedError if no app password.
|
||||
|
||||
There is NO fallback to OAuth - if no app password, user must provision one.
|
||||
"""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
# Don't store any app password
|
||||
|
||||
# Mock AstrolabeClient to return None (no app password)
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = None
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call get_user_client in BasicAuth mode - should raise NotProvisionedError
|
||||
# Call get_user_client_basic_auth - should raise NotProvisionedError
|
||||
with pytest.raises(NotProvisionedError) as exc_info:
|
||||
await get_user_client(
|
||||
await get_user_client_basic_auth(
|
||||
user_id="test_user",
|
||||
token_broker=None,
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
# Verify error message mentions app password provisioning
|
||||
@@ -162,6 +85,33 @@ async def test_basic_auth_mode_raises_error_without_app_password(mocker):
|
||||
assert "test_user" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_dispatches_to_basic_auth(temp_storage, mocker):
|
||||
"""Test that get_user_client dispatches to BasicAuth mode correctly."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("alice", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
# Mock RefreshTokenStorage.from_env at the source module
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.auth.storage.RefreshTokenStorage.from_env",
|
||||
return_value=temp_storage,
|
||||
)
|
||||
# Also mock initialize since from_env returns an uninitialized instance
|
||||
mocker.patch.object(temp_storage, "initialize", return_value=None)
|
||||
|
||||
# Call get_user_client in BasicAuth mode
|
||||
client = await get_user_client(
|
||||
user_id="alice",
|
||||
token_broker=None, # No token broker needed for BasicAuth mode
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=True,
|
||||
)
|
||||
|
||||
# Verify client was created successfully
|
||||
assert client is not None
|
||||
assert client.username == "alice"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||
"""Test that OAuth mode uses ONLY refresh tokens, NOT app passwords.
|
||||
@@ -183,7 +133,7 @@ async def test_oauth_mode_uses_refresh_token_only(mocker):
|
||||
use_basic_auth=False, # OAuth mode
|
||||
)
|
||||
|
||||
# Verify token broker was called (NOT Astrolabe)
|
||||
# Verify token broker was called
|
||||
mock_token_broker.get_background_token.assert_called_once()
|
||||
|
||||
|
||||
@@ -213,38 +163,6 @@ async def test_oauth_mode_raises_error_without_token(mocker):
|
||||
assert "test_user" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_basic_auth_function(mocker):
|
||||
"""Test the dedicated get_user_client_basic_auth function."""
|
||||
# Mock settings to have client credentials
|
||||
mock_settings = mocker.MagicMock()
|
||||
mock_settings.oidc_client_id = "test-client-id"
|
||||
mock_settings.oidc_client_secret = "test-client-secret"
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.get_settings",
|
||||
return_value=mock_settings,
|
||||
)
|
||||
|
||||
# Mock AstrolabeClient
|
||||
mock_astrolabe = mocker.AsyncMock()
|
||||
mock_astrolabe.get_user_app_password.return_value = "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||
return_value=mock_astrolabe,
|
||||
)
|
||||
|
||||
# Call dedicated function
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id="alice",
|
||||
nextcloud_host="http://localhost:8080",
|
||||
)
|
||||
|
||||
assert client is not None
|
||||
assert client.username == "alice"
|
||||
mock_astrolabe.get_user_app_password.assert_called_once_with("alice")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_user_client_oauth_function(mocker):
|
||||
"""Test the dedicated get_user_client_oauth function."""
|
||||
@@ -276,3 +194,69 @@ async def test_oauth_mode_requires_token_broker():
|
||||
nextcloud_host="http://localhost:8080",
|
||||
use_basic_auth=False, # OAuth mode
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_multiple_users_basic_auth_mode(temp_storage, mocker):
|
||||
"""Test that multiple users can be provisioned independently."""
|
||||
# Store app passwords for multiple users
|
||||
users = {
|
||||
"alice": "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa",
|
||||
"bob": "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb",
|
||||
"charlie": "ccccc-ccccc-ccccc-ccccc-ccccc",
|
||||
}
|
||||
|
||||
for user_id, password in users.items():
|
||||
await temp_storage.store_app_password(user_id, password)
|
||||
|
||||
# Verify each user can get a client
|
||||
for user_id in users.keys():
|
||||
client = await get_user_client_basic_auth(
|
||||
user_id=user_id,
|
||||
nextcloud_host="http://localhost:8080",
|
||||
storage=temp_storage,
|
||||
)
|
||||
assert client is not None
|
||||
assert client.username == user_id
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_get_all_provisioned_users(temp_storage):
|
||||
"""Test that we can list all provisioned users for BasicAuth mode."""
|
||||
# Store app passwords for multiple users
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||
|
||||
# Get all provisioned users
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
|
||||
assert len(user_ids) == 2
|
||||
assert "alice" in user_ids
|
||||
assert "bob" in user_ids
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_revoke_app_password(temp_storage):
|
||||
"""Test that deleting app password revokes background access."""
|
||||
# Provision user
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
|
||||
# Verify user is provisioned
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert "alice" in user_ids
|
||||
|
||||
# Revoke access
|
||||
deleted = await temp_storage.delete_app_password("alice")
|
||||
assert deleted is True
|
||||
|
||||
# Verify user is no longer provisioned
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert "alice" not in user_ids
|
||||
|
||||
# Verify get_user_client now raises NotProvisionedError
|
||||
with pytest.raises(NotProvisionedError):
|
||||
await get_user_client_basic_auth(
|
||||
user_id="alice",
|
||||
nextcloud_host="http://localhost:8080",
|
||||
storage=temp_storage,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -43,8 +48,19 @@ async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
|
||||
# Submit form
|
||||
await page.click('button[type="submit"]')
|
||||
# Submit form - use force=True to bypass stability check (CSS transitions)
|
||||
submit_button = page.locator('button[type="submit"]')
|
||||
try:
|
||||
await submit_button.click(force=True, timeout=10000)
|
||||
except Exception:
|
||||
# Fallback: JavaScript click
|
||||
logger.info("Using JavaScript click for login button...")
|
||||
await page.evaluate(
|
||||
"""
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
"""
|
||||
)
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
# Verify logged in (should redirect away from login page)
|
||||
@@ -75,6 +91,289 @@ async def navigate_to_astrolabe_settings(page: Page):
|
||||
logger.info("✓ Successfully loaded Astrolabe settings page")
|
||||
|
||||
|
||||
async def authorize_search_access(page: Page, username: str) -> bool:
|
||||
"""Complete Step 1: OAuth Authorization for Astrolabe.
|
||||
|
||||
Handles the OAuth flow:
|
||||
1. Check if already authorized (Step 1 shows "Complete")
|
||||
2. Click "Authorize" link
|
||||
3. Handle Nextcloud OIDC consent screen
|
||||
4. Wait for redirect back to Astrolabe settings
|
||||
5. Verify "Complete" badge appears on Step 1
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be on Astrolabe settings page)
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if authorization completed successfully
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Authorizing search access (Step 1) for {username}...")
|
||||
|
||||
# Check if already on Astrolabe settings page, if not navigate there
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Wait for page to fully render
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already authorized (either "Active" badge or Step 1 "Complete" badge)
|
||||
try:
|
||||
# Check for "Active" badge (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ Already fully authorized for {username} (Active badge)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
# Look for "Complete" text in the Step 1 section's parent
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 1 already complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find and click the "Authorize" button
|
||||
authorize_button = page.locator('a.button.primary:has-text("Authorize")')
|
||||
|
||||
try:
|
||||
await authorize_button.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"Found Authorize button for {username}")
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_no_authorize_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Could not find Authorize button for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"Authorize button not found for {username}")
|
||||
|
||||
# Click the Authorize button - this will redirect to OAuth provider
|
||||
# Use force=True to bypass stability check which can timeout due to CSS transitions
|
||||
await authorize_button.click(force=True)
|
||||
logger.info(f"Clicked Authorize button for {username}")
|
||||
|
||||
# Wait for OAuth redirect to complete
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
logger.info(f"After networkidle, current URL: {page.url}")
|
||||
|
||||
# Take screenshot to see current state
|
||||
await page.screenshot(path=f"/tmp/astrolabe_after_authorize_{username}.png")
|
||||
logger.info(f"Screenshot saved: /tmp/astrolabe_after_authorize_{username}.png")
|
||||
|
||||
# Handle OIDC consent screen if present
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
if consent_handled:
|
||||
logger.info(f"✓ OAuth consent granted for {username}")
|
||||
else:
|
||||
logger.info(
|
||||
f"No consent screen required for {username} (may be previously authorized)"
|
||||
)
|
||||
|
||||
# Wait for redirect back to Astrolabe settings
|
||||
# The OAuth callback will redirect back to /settings/user/astrolabe
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
f"**{nextcloud_url}/settings/user/astrolabe**", timeout=30000
|
||||
)
|
||||
logger.info(f"Redirected back to Astrolabe settings for {username}")
|
||||
except Exception:
|
||||
# Check if we're already on settings page
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
logger.warning(
|
||||
f"Not redirected to Astrolabe settings, current URL: {page.url}"
|
||||
)
|
||||
# Navigate manually
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
|
||||
# Wait for page to reload and render
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Verify authorization completed - check for various success indicators
|
||||
# When fully configured, shows "Active" badge; when only Step 1 done, shows "Complete"
|
||||
try:
|
||||
# First check if "Active" badge is shown (fully configured state)
|
||||
active_badge = page.get_by_text("Active", exact=True)
|
||||
if await active_badge.count() > 0 and await active_badge.is_visible():
|
||||
logger.info(f"✓ OAuth authorization complete for {username} (Active badge)")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 1 "Complete" badge (partial configuration)
|
||||
step1_section = page.locator('h4:has-text("Step 1")')
|
||||
if await step1_section.count() > 0:
|
||||
step1_parent = step1_section.locator("..")
|
||||
complete_badge = step1_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Step 1 OAuth authorization complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neither badge found - authorization failed
|
||||
screenshot_path = f"/tmp/astrolabe_step1_not_complete_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Authorization badge not visible for {username}. Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"OAuth authorization did not complete for {username}")
|
||||
|
||||
|
||||
async def _handle_oauth_consent_screen(page: Page, username: str) -> bool:
|
||||
"""Handle the OIDC consent screen during OAuth flow.
|
||||
|
||||
Reuses the proven pattern from tests/conftest.py.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username for logging
|
||||
|
||||
Returns:
|
||||
True if consent was handled, False if no consent screen was found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Checking for consent screen at URL: {page.url}")
|
||||
|
||||
# Check if consent screen is present - try multiple selectors
|
||||
# The consent screen may be #oidc-consent or use a different format
|
||||
consent_div = await page.query_selector("#oidc-consent")
|
||||
|
||||
if consent_div:
|
||||
logger.info(f"Consent screen detected via #oidc-consent for {username}")
|
||||
# Get consent screen data attributes for logging
|
||||
client_name = await consent_div.get_attribute("data-client-name")
|
||||
scopes_attr = await consent_div.get_attribute("data-scopes")
|
||||
logger.info(f" Client: {client_name}")
|
||||
logger.info(f" Requested scopes: {scopes_attr}")
|
||||
else:
|
||||
# Check for Allow button directly (different consent screen format)
|
||||
allow_button = page.locator('button:has-text("Allow")')
|
||||
if await allow_button.count() > 0:
|
||||
logger.info(f"Consent screen detected via Allow button for {username}")
|
||||
else:
|
||||
logger.info(f"No consent screen found for {username} at {page.url}")
|
||||
await page.screenshot(path=f"/tmp/no_consent_screen_{username}.png")
|
||||
logger.info(f"Screenshot: /tmp/no_consent_screen_{username}.png")
|
||||
return False
|
||||
|
||||
# Wait for Vue.js to render the Allow button
|
||||
try:
|
||||
await page.wait_for_selector('button:has-text("Allow")', timeout=10000)
|
||||
logger.info(" Allow button rendered by Vue.js")
|
||||
except Exception as e:
|
||||
screenshot_path = f"/tmp/consent_no_allow_button_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f" Timeout waiting for Allow button: {e}")
|
||||
raise
|
||||
|
||||
# Check all scope checkboxes
|
||||
scope_checkboxes = await page.query_selector_all('input[type="checkbox"]')
|
||||
if scope_checkboxes:
|
||||
logger.info(f" Found {len(scope_checkboxes)} scope checkboxes")
|
||||
for i, checkbox in enumerate(scope_checkboxes):
|
||||
is_checked = await checkbox.is_checked()
|
||||
is_disabled = await checkbox.is_disabled()
|
||||
if not is_checked and not is_disabled:
|
||||
await checkbox.check()
|
||||
logger.info(f" ✓ Checked scope checkbox {i + 1}")
|
||||
|
||||
# Click the Allow button using JavaScript (handles viewport issues)
|
||||
allow_button_locator = page.locator('button:has-text("Allow")')
|
||||
|
||||
# Debug: take screenshot before clicking Allow
|
||||
await page.screenshot(path=f"/tmp/consent_before_allow_{username}.png")
|
||||
logger.info(
|
||||
f" Screenshot before Allow: /tmp/consent_before_allow_{username}.png"
|
||||
)
|
||||
|
||||
button_count = await allow_button_locator.count()
|
||||
logger.info(f" Found {button_count} Allow button(s)")
|
||||
|
||||
if button_count > 0:
|
||||
current_url = page.url
|
||||
logger.info(f" Current URL: {current_url}")
|
||||
logger.info(f" Clicking Allow button for {username}...")
|
||||
|
||||
# Use JavaScript click to handle consent buttons (proven pattern from conftest.py)
|
||||
# This is more reliable than Playwright's click for Vue.js rendered buttons
|
||||
await page.evaluate(
|
||||
"""
|
||||
const buttons = document.querySelectorAll('button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.textContent.trim() === 'Allow') {
|
||||
btn.click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
# Wait for URL to change (Vue.js uses window.location.href after fetch)
|
||||
# networkidle doesn't detect fetch-based redirects
|
||||
try:
|
||||
await page.wait_for_url(
|
||||
lambda url: url != current_url,
|
||||
timeout=30000,
|
||||
)
|
||||
logger.info(f" URL changed to: {page.url}")
|
||||
except Exception as wait_error:
|
||||
# If URL didn't change, check console for errors
|
||||
logger.warning(f" URL didn't change after click: {wait_error}")
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
|
||||
# Try alternative: manually POST consent and navigate
|
||||
logger.info(" Trying manual consent submission...")
|
||||
try:
|
||||
redirect_url = await page.evaluate(
|
||||
"""
|
||||
async () => {
|
||||
const selectedScopes = Array.from(document.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value).join(' ');
|
||||
|
||||
const response = await fetch('/index.php/apps/oidc/consent/grant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'requesttoken': OC.requestToken,
|
||||
},
|
||||
body: 'scopes=' + encodeURIComponent(selectedScopes),
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
return response.url || '/index.php/apps/oidc/authorize';
|
||||
}
|
||||
"""
|
||||
)
|
||||
logger.info(f" Manual consent returned URL: {redirect_url}")
|
||||
await page.goto(redirect_url, wait_until="networkidle")
|
||||
except Exception as manual_error:
|
||||
logger.error(f" Manual consent also failed: {manual_error}")
|
||||
raise
|
||||
|
||||
await page.screenshot(path=f"/tmp/consent_after_allow_{username}.png")
|
||||
logger.info(f" Consent granted for {username}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f" Allow button not found for {username}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling consent screen for {username}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
page: Page, username: str, app_name: str = "Astrolabe Background Sync"
|
||||
) -> str:
|
||||
@@ -105,16 +404,32 @@ async def generate_app_password(
|
||||
await anyio.sleep(1.0)
|
||||
logger.info("Waited for Vue.js to process input and enable button")
|
||||
|
||||
# Click the create button
|
||||
# Click the create button - use force=True to bypass stability check (CSS transitions)
|
||||
create_button = page.locator(
|
||||
'button[type="submit"]:has-text("Create new app password")'
|
||||
)
|
||||
await create_button.click()
|
||||
try:
|
||||
await create_button.click(force=True, timeout=10000)
|
||||
except Exception:
|
||||
# Fallback: JavaScript click
|
||||
logger.info("Using JavaScript click for create button...")
|
||||
await page.evaluate(
|
||||
"""
|
||||
const btn = document.querySelector('button[type="submit"]');
|
||||
if (btn) btn.click();
|
||||
"""
|
||||
)
|
||||
logger.info("Clicked create app password button")
|
||||
|
||||
# Wait for app password to be generated and displayed in the dialog
|
||||
await anyio.sleep(3) # Give it more time to generate and display
|
||||
|
||||
# Debug screenshot after clicking create
|
||||
await page.screenshot(path=f"/tmp/app_password_after_create_{username}.png")
|
||||
logger.info(
|
||||
f"Screenshot after create: /tmp/app_password_after_create_{username}.png"
|
||||
)
|
||||
|
||||
# Find the Login input field which should have the username value
|
||||
# Then find the Password input field which is in the same form
|
||||
app_password = None
|
||||
@@ -172,11 +487,11 @@ async def generate_app_password(
|
||||
f"✓ Generated app password for {username}: {app_password[:10]}... (validated)"
|
||||
)
|
||||
|
||||
# Close the dialog by clicking the Close button
|
||||
close_button = page.get_by_role("button", name="Close")
|
||||
await close_button.click()
|
||||
# Close dialog with Escape key (bypasses CSS layout issues with h2 intercepting clicks)
|
||||
logger.info("Closing app password dialog with Escape key...")
|
||||
await page.keyboard.press("Escape")
|
||||
await anyio.sleep(0.5) # Wait for dialog close animation
|
||||
logger.info("Closed app password dialog")
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
return app_password
|
||||
|
||||
@@ -226,9 +541,9 @@ async def enable_background_sync_via_app_password(
|
||||
# Wait for page to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if already active (look for "Active" text in the Background Sync Access section)
|
||||
# Check if already complete (look for Step 2 "Complete" badge or overall "Active" state)
|
||||
try:
|
||||
# The "Active" badge appears as a <span> with text "Active"
|
||||
# First check for overall "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.is_visible(timeout=2000):
|
||||
logger.info(f"✓ Background sync already active for {username}")
|
||||
@@ -236,6 +551,18 @@ async def enable_background_sync_via_app_password(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge (app password already set)
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 (app password) already complete for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find the app password input field using the placeholder text
|
||||
# Based on manual testing: textbox with placeholder "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||
@@ -319,21 +646,120 @@ async def enable_background_sync_via_app_password(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify "Active" text appears after reload
|
||||
# Verify Step 2 "Complete" badge or overall "Active" badge appears after reload
|
||||
try:
|
||||
# First try to find "Active" badge (both steps complete)
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
if await active_text.count() > 0:
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Background sync enabled for {username} - Active badge visible"
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Check for Step 2 "Complete" badge
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
await complete_badge.wait_for(timeout=5000, state="visible")
|
||||
logger.info(
|
||||
f"✓ Step 2 (app password) enabled for {username} - Complete badge visible"
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If neither badge found, raise error
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Neither Active nor Complete badge appeared for {username}. "
|
||||
f"Screenshot: {screenshot_path}"
|
||||
)
|
||||
raise ValueError(f"Background sync setup did not complete for {username}")
|
||||
|
||||
|
||||
async def complete_astrolabe_authorization(
|
||||
page: Page, username: str, password: str
|
||||
) -> dict:
|
||||
"""Complete full Astrolabe two-step authorization.
|
||||
|
||||
Performs the complete authorization flow:
|
||||
1. Navigate to Astrolabe settings
|
||||
2. OAuth authorization (Step 1) if needed
|
||||
3. Generate app password in Security settings
|
||||
4. App password entry (Step 2) if needed
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be logged in)
|
||||
username: Nextcloud username
|
||||
password: Nextcloud password (for reference, not used directly)
|
||||
|
||||
Returns:
|
||||
Dict with {"step1": bool, "step2": bool, "app_password": str | None}
|
||||
"""
|
||||
logger.info(f"Starting full Astrolabe authorization for {username}...")
|
||||
|
||||
result = {"step1": False, "step2": False, "app_password": None}
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Step 1: OAuth authorization
|
||||
try:
|
||||
result["step1"] = await authorize_search_access(page, username)
|
||||
logger.info(f"✓ Step 1 complete for {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Step 1 failed for {username}: {e}")
|
||||
raise
|
||||
|
||||
# Navigate back to settings if needed (OAuth might have redirected elsewhere)
|
||||
if "/settings/user/astrolabe" not in page.url:
|
||||
await navigate_to_astrolabe_settings(page)
|
||||
|
||||
# Check if Step 2 is already complete
|
||||
try:
|
||||
step2_section = page.locator('h4:has-text("Step 2")')
|
||||
if await step2_section.count() > 0:
|
||||
step2_parent = step2_section.locator("..")
|
||||
complete_badge = step2_parent.get_by_text("Complete", exact=True)
|
||||
if await complete_badge.count() > 0 and await complete_badge.is_visible():
|
||||
logger.info(f"✓ Step 2 already complete for {username}")
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also check for overall "Active" badge
|
||||
try:
|
||||
active_text = page.get_by_text("Active", exact=True)
|
||||
await active_text.wait_for(timeout=5000, state="visible")
|
||||
logger.info(f"✓ Background sync enabled for {username} - Active badge visible")
|
||||
return True
|
||||
if await active_text.count() > 0 and await active_text.is_visible():
|
||||
logger.info(f"✓ Authorization already fully active for {username}")
|
||||
result["step2"] = True
|
||||
return result
|
||||
except Exception:
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/astrolabe_after_password_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(
|
||||
f"Active badge did not appear for {username}. Screenshot: {screenshot_path}"
|
||||
pass
|
||||
|
||||
# Step 2: Generate app password and enter it
|
||||
app_password = await generate_app_password(page, username)
|
||||
result["app_password"] = app_password
|
||||
|
||||
try:
|
||||
result["step2"] = await enable_background_sync_via_app_password(
|
||||
page, username, app_password
|
||||
)
|
||||
logger.info(f"✓ Step 2 complete for {username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Step 2 failed for {username}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"✓ Full Astrolabe authorization complete for {username}")
|
||||
return result
|
||||
|
||||
|
||||
async def verify_app_password_created(username: str) -> bool:
|
||||
"""Verify that background sync app password was stored for the user.
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""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
|
||||
3. Semantic search via Astrolabe UI returns results
|
||||
4. Plotly 3D visualization container renders correctly
|
||||
|
||||
Requires:
|
||||
- docker-compose up -d app db mcp-multi-user-basic
|
||||
- ENABLE_SEMANTIC_SEARCH=true on the mcp-multi-user-basic container
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from playwright.async_api import Page
|
||||
|
||||
# Import helper functions from existing test
|
||||
from tests.conftest import create_mcp_client_session
|
||||
from tests.integration.test_astrolabe_multi_user_background_sync import (
|
||||
complete_astrolabe_authorization,
|
||||
login_to_nextcloud,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def wait_for_vector_sync(
|
||||
mcp_client, initial_indexed_count: int, timeout_seconds: int = 60
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""Wait for vector sync to index new content.
|
||||
|
||||
Args:
|
||||
mcp_client: MCP client session
|
||||
initial_indexed_count: Initial indexed document count before creating content
|
||||
timeout_seconds: Maximum time to wait for sync
|
||||
|
||||
Returns:
|
||||
Tuple of (success, status_data)
|
||||
"""
|
||||
wait_interval = 2
|
||||
waited = 0
|
||||
status_data = None
|
||||
|
||||
while waited < timeout_seconds:
|
||||
sync_status = await mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||
if sync_status.isError:
|
||||
logger.warning(f"Vector sync status error: {sync_status}")
|
||||
return False, None
|
||||
|
||||
status_data = json.loads(sync_status.content[0].text)
|
||||
indexed_count = status_data.get("indexed_count", 0)
|
||||
pending_count = status_data.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Sync status at {waited}s: indexed={indexed_count}, "
|
||||
f"pending={pending_count}, status={status_data.get('status')}"
|
||||
)
|
||||
|
||||
if indexed_count > initial_indexed_count and pending_count == 0:
|
||||
logger.info(
|
||||
f"✓ Sync complete: {indexed_count} documents indexed "
|
||||
f"(was {initial_indexed_count})"
|
||||
)
|
||||
return True, status_data
|
||||
|
||||
await anyio.sleep(wait_interval)
|
||||
waited += wait_interval
|
||||
|
||||
return False, status_data
|
||||
|
||||
|
||||
async def navigate_to_astrolabe_main(page: Page):
|
||||
"""Navigate to Astrolabe main app page (Semantic Search section).
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info("Navigating to Astrolabe main app...")
|
||||
await page.goto(f"{nextcloud_url}/apps/astrolabe", wait_until="networkidle")
|
||||
|
||||
# Wait for the app to load
|
||||
await anyio.sleep(1)
|
||||
|
||||
logger.info("✓ Successfully loaded Astrolabe main app")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
@pytest.mark.timeout(
|
||||
300
|
||||
) # 5 minutes - this test involves OAuth, app password, and vector sync
|
||||
async def test_astrolabe_plotly_visualization_with_basic_auth(
|
||||
browser,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test Plotly 3D visualization in Astrolabe with multi-user BasicAuth mode.
|
||||
|
||||
This test:
|
||||
1. Configures Astrolabe for the mcp-multi-user-basic service
|
||||
2. Provisions background sync access for alice via app password
|
||||
3. Creates a note with unique searchable content (as alice)
|
||||
4. Waits for vector sync to index the note
|
||||
5. Performs semantic search in Astrolabe UI
|
||||
6. Verifies the Plotly visualization renders and results are displayed
|
||||
"""
|
||||
# Phase 1: Configure Astrolabe for mcp-multi-user-basic
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
username = "alice"
|
||||
password = test_users_setup[username]["password"]
|
||||
note_id = None
|
||||
unique_term = None
|
||||
|
||||
# Create MCP client with alice's credentials for the multi-user BasicAuth server
|
||||
credentials = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8")
|
||||
auth_header = f"Basic {credentials}"
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Phase 2: Complete full Astrolabe authorization (OAuth + app password)
|
||||
await login_to_nextcloud(page, username, password)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
logger.info(f"Authorization result: {auth_result}")
|
||||
|
||||
# Create MCP client session as alice - all MCP operations inside this block
|
||||
async for alice_mcp_client in create_mcp_client_session(
|
||||
url="http://localhost:8003/mcp",
|
||||
headers={"Authorization": auth_header},
|
||||
client_name="Alice BasicAuth MCP",
|
||||
):
|
||||
# Phase 3: Get initial indexed count
|
||||
initial_sync = await alice_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", {}
|
||||
)
|
||||
|
||||
if initial_sync.isError:
|
||||
pytest.skip("Vector sync not enabled on mcp-multi-user-basic")
|
||||
|
||||
initial_data = json.loads(initial_sync.content[0].text)
|
||||
initial_count = initial_data.get("indexed_count", 0)
|
||||
logger.info(f"Initial indexed count: {initial_count}")
|
||||
|
||||
# Create note with unique searchable term
|
||||
unique_term = f"plotly_viz_test_{uuid.uuid4().hex[:8]}"
|
||||
note_response = await alice_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
{
|
||||
"title": f"Visualization Test Note {unique_term}",
|
||||
"content": f"""# Testing Plotly Visualization
|
||||
|
||||
This note contains the unique term: {unique_term}
|
||||
|
||||
It is used to test the 3D vector space visualization in the Astrolabe app.
|
||||
The visualization should show this document as a point in PCA-reduced space.
|
||||
|
||||
## Key Features
|
||||
- Semantic search with embeddings
|
||||
- PCA dimension reduction to 3D
|
||||
- Interactive Plotly scatter3d plot
|
||||
""",
|
||||
"category": "Test",
|
||||
},
|
||||
)
|
||||
|
||||
if note_response.isError:
|
||||
pytest.fail(f"Failed to create test note: {note_response}")
|
||||
|
||||
note_data = json.loads(note_response.content[0].text)
|
||||
note_id = note_data.get("id")
|
||||
logger.info(f"Created test note ID: {note_id}")
|
||||
|
||||
# Phase 4: Wait for vector indexing
|
||||
sync_complete, status = await wait_for_vector_sync(
|
||||
alice_mcp_client, initial_count, timeout_seconds=90
|
||||
)
|
||||
assert sync_complete, f"Vector sync did not complete in time: {status}"
|
||||
|
||||
# Phase 5: Navigate to Astrolabe and perform search
|
||||
await navigate_to_astrolabe_main(page)
|
||||
|
||||
# Fill search query - find the Astrolabe search input specifically
|
||||
# The NcTextField component wraps the input in a div with class mcp-search-input
|
||||
search_input = page.locator(".mcp-search-input input")
|
||||
await search_input.wait_for(timeout=10000, state="visible")
|
||||
await search_input.fill(unique_term)
|
||||
logger.info(f"Entered search query: {unique_term}")
|
||||
|
||||
# Trigger search by pressing Enter on the input field
|
||||
# This is wired to performSearch via @keyup.enter in the Vue component
|
||||
await search_input.press("Enter")
|
||||
logger.info("Pressed Enter to trigger search")
|
||||
|
||||
# Wait for loading to complete - watch for loading indicator to disappear
|
||||
loading_indicator = page.locator(".mcp-loading")
|
||||
try:
|
||||
# If loading indicator appears, wait for it to disappear
|
||||
if await loading_indicator.count() > 0:
|
||||
await loading_indicator.wait_for(state="hidden", timeout=30000)
|
||||
logger.info("Loading completed")
|
||||
except Exception:
|
||||
# Loading might be too fast to catch
|
||||
pass
|
||||
|
||||
# Brief wait for UI to settle
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Take diagnostic screenshot
|
||||
await page.screenshot(path="/tmp/astrolabe_search_after_click.png")
|
||||
logger.info(
|
||||
"Took diagnostic screenshot: /tmp/astrolabe_search_after_click.png"
|
||||
)
|
||||
|
||||
# Wait for search results using text-based detection
|
||||
# This is more reliable than class-based selectors
|
||||
# The UI shows "N results" when search completes successfully
|
||||
results_text_pattern = page.get_by_text(re.compile(r"\d+ results?"))
|
||||
no_results_text = page.get_by_text("No results found")
|
||||
error_note = page.locator(".mcp-error")
|
||||
|
||||
# Wait for one of: results count, no results message, or error
|
||||
try:
|
||||
# Poll for results or error states (don't rely on Nextcloud core CSS classes)
|
||||
found_state = False
|
||||
for attempt in range(60): # 60 attempts, 500ms each = 30s total
|
||||
if await error_note.count() > 0:
|
||||
error_text = await error_note.text_content()
|
||||
logger.error(f"Search error: {error_text}")
|
||||
pytest.fail(f"Search failed with error: {error_text}")
|
||||
|
||||
if await no_results_text.count() > 0:
|
||||
logger.warning(
|
||||
"No results found - vector sync may not have completed"
|
||||
)
|
||||
await page.screenshot(path="/tmp/astrolabe_no_results.png")
|
||||
pytest.fail(
|
||||
f"Search returned no results for '{unique_term}'. "
|
||||
"Check if vector sync completed for alice's content."
|
||||
)
|
||||
|
||||
if await results_text_pattern.count() > 0:
|
||||
results_text = await results_text_pattern.first.text_content()
|
||||
logger.info(f"Found results: {results_text}")
|
||||
found_state = True
|
||||
break
|
||||
|
||||
if attempt % 10 == 0:
|
||||
logger.info(
|
||||
f"Waiting for results... (attempt {attempt + 1}/60)"
|
||||
)
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
if not found_state:
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
raise AssertionError("Search did not complete within timeout")
|
||||
|
||||
except AssertionError:
|
||||
raise # Re-raise AssertionError as-is
|
||||
except Exception as e:
|
||||
# Take another screenshot and get page content for debugging
|
||||
await page.screenshot(path="/tmp/astrolabe_search_timeout.png")
|
||||
page_content = await page.content()
|
||||
logger.error(f"Search state not resolved. Page URL: {page.url}")
|
||||
logger.error(f"Page content snippet: {page_content[:2000]}")
|
||||
raise AssertionError(f"Search did not complete: {e}")
|
||||
|
||||
logger.info("Results loaded")
|
||||
|
||||
# Phase 6: Verify visualization
|
||||
# Check Plotly container is visible
|
||||
viz_plot = page.locator("#viz-plot")
|
||||
await viz_plot.wait_for(timeout=15000, state="visible")
|
||||
logger.info("Plotly container is visible")
|
||||
|
||||
# Verify Plotly has rendered content (SVG/canvas elements inside)
|
||||
has_viz_content = await page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const plot = document.getElementById('viz-plot');
|
||||
if (!plot) return false;
|
||||
// Plotly creates .plotly class, canvas, or svg elements
|
||||
return plot.children.length > 0 ||
|
||||
plot.querySelector('.plotly, canvas, svg, .main-svg') !== null;
|
||||
}
|
||||
"""
|
||||
)
|
||||
assert has_viz_content, "Plotly visualization did not render any content"
|
||||
logger.info("✓ Plotly visualization rendered content")
|
||||
|
||||
# Verify results are displayed
|
||||
result_items = page.locator(".mcp-result-item")
|
||||
result_count = await result_items.count()
|
||||
assert result_count > 0, "No search results displayed"
|
||||
logger.info(f"✓ Found {result_count} search result(s)")
|
||||
|
||||
# Verify our note appears in results
|
||||
found_note = False
|
||||
for i in range(result_count):
|
||||
item = result_items.nth(i)
|
||||
title_elem = item.locator(".mcp-result-title")
|
||||
title_text = await title_elem.text_content()
|
||||
if title_text and unique_term in title_text:
|
||||
found_note = True
|
||||
logger.info(f"✓ Found test note in results: {title_text}")
|
||||
break
|
||||
|
||||
assert found_note, f"Created note with '{unique_term}' not found in results"
|
||||
|
||||
# Optional: Take screenshot for verification
|
||||
await page.screenshot(path="/tmp/astrolabe_plotly_test_success.png")
|
||||
logger.info("✓ All Plotly visualization assertions passed")
|
||||
|
||||
# Cleanup: delete the created note (inside the MCP client context)
|
||||
if note_id:
|
||||
try:
|
||||
delete_response = await alice_mcp_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id}")
|
||||
note_id = None # Mark as cleaned
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
|
||||
finally:
|
||||
# Cleanup note if not already cleaned (create new client for cleanup)
|
||||
if note_id:
|
||||
try:
|
||||
async for cleanup_client in create_mcp_client_session(
|
||||
url="http://localhost:8003/mcp",
|
||||
headers={"Authorization": auth_header},
|
||||
client_name="Cleanup MCP",
|
||||
):
|
||||
delete_response = await cleanup_client.call_tool(
|
||||
"nc_notes_delete_note", {"note_id": note_id}
|
||||
)
|
||||
if not delete_response.isError:
|
||||
logger.info(f"✓ Cleaned up test note {note_id} (finally)")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to delete note {note_id}: {delete_response}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for note {note_id}: {e}")
|
||||
|
||||
# Close browser context
|
||||
await context.close()
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
"""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.
|
||||
|
||||
This test verifies:
|
||||
1. User provisions access via Astrolabe personal settings
|
||||
2. Token is stored encrypted in Nextcloud database
|
||||
3. Token expires (simulated via database manipulation)
|
||||
4. MCP server requests new token via refresh
|
||||
5. Astrolabe refreshes token with IdP
|
||||
6. New token is stored and used successfully
|
||||
|
||||
Note: The mcp-multi-user-basic deployment uses "hybrid mode" which requires
|
||||
BOTH OAuth authorization AND app password for full configuration. These tests
|
||||
focus on the app password/credential storage aspects and verify database state
|
||||
directly rather than relying on UI elements that require both steps.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from playwright.async_api import Page
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def login_to_nextcloud(page: Page, username: str, password: str):
|
||||
"""Helper function to login to Nextcloud via Playwright.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Nextcloud username
|
||||
password: Nextcloud password
|
||||
"""
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
logger.info(f"Logging in to Nextcloud as {username}...")
|
||||
await page.goto(f"{nextcloud_url}/login", wait_until="networkidle")
|
||||
|
||||
# Fill in login form
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
|
||||
# Submit form
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=30000)
|
||||
|
||||
# Verify logged in (should redirect away from login page)
|
||||
current_url = page.url
|
||||
assert "/login" not in current_url, (
|
||||
f"Login failed for {username}, still on login page"
|
||||
)
|
||||
logger.info(f"✓ Successfully logged in as {username}")
|
||||
|
||||
|
||||
async def generate_app_password(
|
||||
page: Page, username: str, app_name: str = "Astrolabe Test"
|
||||
) -> str:
|
||||
"""Generate an app password in Nextcloud Security settings.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance (must be authenticated)
|
||||
username: Username (for logging)
|
||||
app_name: Name for the app password
|
||||
|
||||
Returns:
|
||||
The generated app password string
|
||||
"""
|
||||
logger.info(f"Generating app password for {username}...")
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
# Navigate to Security settings
|
||||
await page.goto(f"{nextcloud_url}/settings/user/security", wait_until="networkidle")
|
||||
logger.info("Navigated to Security settings")
|
||||
|
||||
# Fill the app password input field
|
||||
app_password_input = page.locator('input[placeholder="App name"]')
|
||||
await app_password_input.fill(app_name)
|
||||
logger.info(f"Entered app name: {app_name}")
|
||||
|
||||
# Wait for Vue.js to react and enable the button
|
||||
await anyio.sleep(1.0)
|
||||
|
||||
# Click the create button
|
||||
create_button = page.locator(
|
||||
'button[type="submit"]:has-text("Create new app password")'
|
||||
)
|
||||
await create_button.click()
|
||||
logger.info("Clicked create app password button")
|
||||
|
||||
# Wait for app password to be generated
|
||||
await anyio.sleep(3)
|
||||
|
||||
# Find the generated app password
|
||||
app_password = None
|
||||
try:
|
||||
await page.wait_for_selector('text="New app password"', timeout=10000)
|
||||
logger.info("App password dialog appeared")
|
||||
|
||||
all_inputs = await page.locator('input[type="text"]').all()
|
||||
for idx, input_elem in enumerate(all_inputs):
|
||||
try:
|
||||
value = await input_elem.input_value()
|
||||
if value and "-" in value and len(value) > 20:
|
||||
app_password = value.strip()
|
||||
logger.info(f"Found app password in input {idx}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find app password dialog: {e}")
|
||||
|
||||
if not app_password:
|
||||
screenshot_path = f"/tmp/app_password_generation_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"Could not find generated app password. Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
# Validate password format
|
||||
if not re.match(
|
||||
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$",
|
||||
app_password,
|
||||
):
|
||||
raise ValueError(f"App password format validation failed: {app_password}")
|
||||
|
||||
logger.info(f"✓ Generated app password for {username}")
|
||||
|
||||
# Close the dialog
|
||||
close_button = page.get_by_role("button", name="Close")
|
||||
await close_button.click()
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
return app_password
|
||||
|
||||
|
||||
async def save_app_password_in_astrolabe(
|
||||
page: Page, username: str, app_password: str
|
||||
) -> bool:
|
||||
"""Save app password in Astrolabe settings (Step 2 of hybrid mode).
|
||||
|
||||
This function only saves the app password - it does NOT verify the "Active"
|
||||
badge since that requires both OAuth and app password in hybrid mode.
|
||||
|
||||
Args:
|
||||
page: Playwright page instance
|
||||
username: Username (for logging)
|
||||
app_password: App password to enter
|
||||
|
||||
Returns:
|
||||
True if the password was saved successfully (based on network response)
|
||||
"""
|
||||
logger.info(f"Saving app password in Astrolabe for {username}...")
|
||||
|
||||
nextcloud_url = "http://localhost:8080"
|
||||
|
||||
# Track network responses
|
||||
credentials_response_status = None
|
||||
|
||||
def capture_response(resp):
|
||||
nonlocal credentials_response_status
|
||||
if "background-sync/credentials" in resp.url or "storeAppPassword" in resp.url:
|
||||
credentials_response_status = resp.status
|
||||
logger.info(f"Credentials endpoint response: {resp.status} {resp.url}")
|
||||
|
||||
page.on("response", capture_response)
|
||||
|
||||
# Navigate to Astrolabe settings
|
||||
await page.goto(
|
||||
f"{nextcloud_url}/settings/user/astrolabe", wait_until="networkidle"
|
||||
)
|
||||
await anyio.sleep(1)
|
||||
|
||||
# Check if Step 2 already shows "Complete"
|
||||
try:
|
||||
complete_badge = page.locator('text="Complete"').first
|
||||
if await complete_badge.is_visible(timeout=2000):
|
||||
logger.info(f"✓ App password already configured for {username}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find the app password input field
|
||||
app_password_input = page.get_by_placeholder("xxxxx-xxxxx-xxxxx-xxxxx-xxxxx")
|
||||
|
||||
try:
|
||||
await app_password_input.wait_for(timeout=5000, state="visible")
|
||||
logger.info("Found app password input field")
|
||||
except Exception:
|
||||
screenshot_path = f"/tmp/astrolabe_no_password_field_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
raise ValueError(
|
||||
f"Could not find app password input field. Screenshot: {screenshot_path}"
|
||||
)
|
||||
|
||||
# Enter the app password
|
||||
await app_password_input.fill(app_password)
|
||||
logger.info(f"Entered app password for {username}")
|
||||
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Click Save button
|
||||
save_button = page.get_by_role("button", name="Save")
|
||||
await save_button.click()
|
||||
logger.info("Clicked Save button")
|
||||
|
||||
# Wait for the request to complete and page to reload
|
||||
await page.wait_for_load_state("networkidle", timeout=15000)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# Verify the save was successful by checking network response
|
||||
if credentials_response_status == 200:
|
||||
logger.info(f"✓ App password saved successfully for {username}")
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"App password save failed for {username}, status: {credentials_response_status}"
|
||||
)
|
||||
screenshot_path = f"/tmp/astrolabe_save_failed_{username}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
return False
|
||||
|
||||
|
||||
def get_background_sync_credentials(username: str) -> dict | None:
|
||||
"""Get background sync credentials for a user from the database.
|
||||
|
||||
Args:
|
||||
username: Nextcloud username
|
||||
|
||||
Returns:
|
||||
Dict with credential details, or None if not found
|
||||
"""
|
||||
query = f"""
|
||||
SELECT configkey, configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey IN ('background_sync_password', 'background_sync_type', 'background_sync_provisioned_at')
|
||||
ORDER BY configkey;
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
output = result.stdout
|
||||
if "background_sync_type" in output:
|
||||
return {
|
||||
"has_password": "background_sync_password" in output,
|
||||
"has_type": "background_sync_type" in output,
|
||||
"has_timestamp": "background_sync_provisioned_at" in output,
|
||||
"is_app_password": "app_password" in output,
|
||||
}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting credentials for {username}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def delete_user_credentials(username: str) -> bool:
|
||||
"""Delete all stored credentials for a user (for cleanup).
|
||||
|
||||
Args:
|
||||
username: Nextcloud username
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
query = f"""
|
||||
DELETE FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey IN ('oauth_tokens', 'background_sync_password', 'background_sync_type', 'background_sync_provisioned_at');
|
||||
"""
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
logger.info(f"Deleted credentials for {username}")
|
||||
return result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting credentials for {username}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_app_password_storage_and_cleanup(
|
||||
browser,
|
||||
nc_client,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that app passwords are stored and cleaned up correctly.
|
||||
|
||||
This test verifies:
|
||||
1. User can save app password in Astrolabe settings
|
||||
2. Password is stored encrypted in the database
|
||||
3. Credentials can be revoked and are deleted from database
|
||||
|
||||
Note: In hybrid mode (mcp-multi-user-basic), this only tests Step 2
|
||||
(app password storage). The "Active" badge requires both OAuth and
|
||||
app password, which is tested separately.
|
||||
"""
|
||||
# Configure Astrolabe for mcp-multi-user-basic
|
||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
username = "alice"
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
|
||||
# Cleanup any existing credentials
|
||||
delete_user_credentials(username)
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Step 1: Login
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# Step 2: Verify no credentials exist initially
|
||||
initial_creds = get_background_sync_credentials(username)
|
||||
assert initial_creds is None, f"Expected no credentials, found: {initial_creds}"
|
||||
logger.info("✓ Verified no initial credentials")
|
||||
|
||||
# Step 3: Generate app password
|
||||
app_password = await generate_app_password(page, username)
|
||||
assert app_password, "Failed to generate app password"
|
||||
|
||||
# Step 4: Save app password in Astrolabe
|
||||
save_success = await save_app_password_in_astrolabe(
|
||||
page, username, app_password
|
||||
)
|
||||
assert save_success, "Failed to save app password"
|
||||
|
||||
# Step 5: Verify credentials are stored in database
|
||||
stored_creds = get_background_sync_credentials(username)
|
||||
assert stored_creds is not None, "Expected credentials to be stored"
|
||||
assert stored_creds["has_password"], "Expected password to be stored"
|
||||
assert stored_creds["has_type"], "Expected type to be stored"
|
||||
assert stored_creds["is_app_password"], "Expected type to be 'app_password'"
|
||||
logger.info("✓ Verified credentials stored in database")
|
||||
|
||||
# Step 6: Verify password is encrypted (not plaintext)
|
||||
query = f"""
|
||||
SELECT configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
encrypted_value = result.stdout.strip()
|
||||
assert app_password not in encrypted_value, "Password appears in plaintext!"
|
||||
assert len(encrypted_value) > len(app_password), (
|
||||
"Encrypted value should be longer"
|
||||
)
|
||||
logger.info("✓ Verified password is encrypted")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
# Cleanup
|
||||
delete_user_credentials(username)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_credential_isolation_between_users(
|
||||
browser,
|
||||
nc_client,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that credentials are properly isolated between users.
|
||||
|
||||
This test verifies:
|
||||
1. Multiple users can provision credentials independently
|
||||
2. Each user's encrypted credentials are unique
|
||||
3. Deleting one user's credentials doesn't affect others
|
||||
"""
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
test_users = ["alice", "bob"]
|
||||
user_passwords = {}
|
||||
|
||||
# Cleanup all users first
|
||||
for username in test_users:
|
||||
delete_user_credentials(username)
|
||||
|
||||
# Provision each user
|
||||
for username in test_users:
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await login_to_nextcloud(page, username, password)
|
||||
app_password = await generate_app_password(
|
||||
page, username, f"Test {username}"
|
||||
)
|
||||
save_success = await save_app_password_in_astrolabe(
|
||||
page, username, app_password
|
||||
)
|
||||
|
||||
assert save_success, f"Failed to save app password for {username}"
|
||||
user_passwords[username] = app_password
|
||||
|
||||
# Verify stored
|
||||
creds = get_background_sync_credentials(username)
|
||||
assert creds is not None, f"Credentials not stored for {username}"
|
||||
logger.info(f"✓ Credentials provisioned for {username}")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Verify isolation - get encrypted values
|
||||
encrypted_values = {}
|
||||
for username in test_users:
|
||||
query = f"""
|
||||
SELECT configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
encrypted_values[username] = result.stdout.strip()
|
||||
|
||||
# Different users should have different encrypted values
|
||||
assert encrypted_values["alice"] != encrypted_values["bob"], (
|
||||
"Different users should have different encrypted values"
|
||||
)
|
||||
logger.info("✓ Verified credentials are unique per user")
|
||||
|
||||
# Delete alice's credentials and verify bob's are unaffected
|
||||
delete_user_credentials("alice")
|
||||
|
||||
alice_creds = get_background_sync_credentials("alice")
|
||||
bob_creds = get_background_sync_credentials("bob")
|
||||
|
||||
assert alice_creds is None, "Alice's credentials should be deleted"
|
||||
assert bob_creds is not None, "Bob's credentials should still exist"
|
||||
logger.info("✓ Verified credential deletion is isolated")
|
||||
|
||||
# Cleanup
|
||||
for username in test_users:
|
||||
delete_user_credentials(username)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_credential_revoke_and_reprovision(
|
||||
browser,
|
||||
nc_client,
|
||||
test_users_setup,
|
||||
configure_astrolabe_for_mcp_server,
|
||||
):
|
||||
"""Test that credentials can be revoked and reprovisioned.
|
||||
|
||||
This test verifies:
|
||||
1. User provisions credentials
|
||||
2. User revokes credentials (deletes from database)
|
||||
3. User provisions again with new app password
|
||||
4. New credentials are stored correctly
|
||||
|
||||
Note: The UI prevents overwriting credentials directly - users must
|
||||
revoke first before provisioning new credentials.
|
||||
"""
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
mcp_server_internal_url="http://mcp-multi-user-basic:8000",
|
||||
mcp_server_public_url="http://localhost:8003",
|
||||
)
|
||||
|
||||
username = "alice"
|
||||
user_config = test_users_setup[username]
|
||||
password = user_config["password"]
|
||||
|
||||
delete_user_credentials(username)
|
||||
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# First provisioning
|
||||
app_password_1 = await generate_app_password(page, username, "First Password")
|
||||
await save_app_password_in_astrolabe(page, username, app_password_1)
|
||||
|
||||
# Get first encrypted value
|
||||
query = f"""
|
||||
SELECT configvalue
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
result1 = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
first_encrypted = result1.stdout.strip()
|
||||
assert first_encrypted, "First credential should be stored"
|
||||
logger.info("✓ First credential stored")
|
||||
|
||||
# Revoke credentials (simulating user clicking "Revoke Access")
|
||||
delete_user_credentials(username)
|
||||
logger.info("✓ Credentials revoked")
|
||||
|
||||
# Verify credentials are gone
|
||||
creds_after_revoke = get_background_sync_credentials(username)
|
||||
assert creds_after_revoke is None, "Credentials should be deleted after revoke"
|
||||
|
||||
# Second provisioning with different password
|
||||
app_password_2 = await generate_app_password(page, username, "Second Password")
|
||||
await save_app_password_in_astrolabe(page, username, app_password_2)
|
||||
|
||||
result2 = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
second_encrypted = result2.stdout.strip()
|
||||
assert second_encrypted, "Second credential should be stored"
|
||||
logger.info("✓ Second credential stored")
|
||||
|
||||
# Verify the encrypted values are different (different passwords)
|
||||
assert first_encrypted != second_encrypted, (
|
||||
"Different passwords should produce different encrypted values"
|
||||
)
|
||||
|
||||
# Verify only one row exists
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM oc_preferences
|
||||
WHERE userid = '{username}'
|
||||
AND appid = 'astrolabe'
|
||||
AND configkey = 'background_sync_password';
|
||||
"""
|
||||
|
||||
count_result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-N",
|
||||
"-e",
|
||||
count_query,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
count = int(count_result.stdout.strip())
|
||||
assert count == 1, f"Expected 1 credential row, found {count}"
|
||||
logger.info("✓ Verified clean reprovision after revoke")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
delete_user_credentials(username)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Integration tests for Deck card reorder functionality.
|
||||
|
||||
Tests issue #469: Moving Deck card from one column (stack) to another not working.
|
||||
https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def board_with_two_stacks(nc_client: NextcloudClient):
|
||||
"""Create a temporary board with two stacks for testing card movement.
|
||||
|
||||
Yields:
|
||||
tuple: (board_data, source_stack_data, target_stack_data)
|
||||
"""
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
board_title = f"Reorder Test Board {unique_suffix}"
|
||||
board = None
|
||||
|
||||
logger.info(f"Creating board with two stacks: {board_title}")
|
||||
try:
|
||||
board = await nc_client.deck.create_board(board_title, "0000FF")
|
||||
board_id = board.id
|
||||
|
||||
# Create source stack (stack 1)
|
||||
source_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Source Stack {unique_suffix}", order=1
|
||||
)
|
||||
source_stack_data = {
|
||||
"id": source_stack.id,
|
||||
"title": source_stack.title,
|
||||
"order": source_stack.order,
|
||||
}
|
||||
logger.info(f"Created source stack with ID: {source_stack.id}")
|
||||
|
||||
# Create target stack (stack 2)
|
||||
target_stack = await nc_client.deck.create_stack(
|
||||
board_id, f"Target Stack {unique_suffix}", order=2
|
||||
)
|
||||
target_stack_data = {
|
||||
"id": target_stack.id,
|
||||
"title": target_stack.title,
|
||||
"order": target_stack.order,
|
||||
}
|
||||
logger.info(f"Created target stack with ID: {target_stack.id}")
|
||||
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
"title": board.title,
|
||||
"color": board.color,
|
||||
}
|
||||
|
||||
yield (board_data, source_stack_data, target_stack_data)
|
||||
|
||||
finally:
|
||||
if board:
|
||||
logger.info(f"Cleaning up board ID: {board.id}")
|
||||
try:
|
||||
await nc_client.deck.delete_board(board.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up board: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_move_to_different_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test moving a card from one stack to another (issue #469).
|
||||
|
||||
This test reproduces the bug where the reorder_card API reports success
|
||||
but the card doesn't actually move to the target stack.
|
||||
"""
|
||||
board_data, source_stack_data, target_stack_data = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
target_stack_id = target_stack_data["id"]
|
||||
|
||||
# Create a card in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card_title = f"Test Card {unique_suffix}"
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, card_title, description="Card to be moved"
|
||||
)
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id} in source stack ID: {source_stack_id}")
|
||||
|
||||
try:
|
||||
# Verify card is in source stack
|
||||
card_before = await nc_client.deck.get_card(board_id, source_stack_id, card_id)
|
||||
assert card_before.stackId == source_stack_id, (
|
||||
f"Card should start in source stack {source_stack_id}, "
|
||||
f"but is in {card_before.stackId}"
|
||||
)
|
||||
logger.info(f"Verified card is in source stack: {source_stack_id}")
|
||||
|
||||
# Move card to target stack
|
||||
logger.info(
|
||||
f"Moving card {card_id} from stack {source_stack_id} "
|
||||
f"to stack {target_stack_id}"
|
||||
)
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card_id,
|
||||
order=0,
|
||||
target_stack_id=target_stack_id,
|
||||
)
|
||||
logger.info("reorder_card API call completed")
|
||||
|
||||
# Verify card moved to target stack
|
||||
# Note: After moving, the card should be accessible from the target stack
|
||||
card_after = await nc_client.deck.get_card(board_id, target_stack_id, card_id)
|
||||
assert card_after.stackId == target_stack_id, (
|
||||
f"Card should have moved to target stack {target_stack_id}, "
|
||||
f"but is in {card_after.stackId}"
|
||||
)
|
||||
logger.info(f"SUCCESS: Card moved to target stack {target_stack_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - try to delete from target stack first, then source
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, target_stack_id, card_id)
|
||||
except Exception:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up card: {e}")
|
||||
|
||||
|
||||
async def test_reorder_card_within_same_stack(
|
||||
nc_client: NextcloudClient, board_with_two_stacks: tuple
|
||||
):
|
||||
"""Test reordering a card within the same stack (should work)."""
|
||||
board_data, source_stack_data, _ = board_with_two_stacks
|
||||
board_id = board_data["id"]
|
||||
source_stack_id = source_stack_data["id"]
|
||||
|
||||
# Create two cards in the source stack
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
card1 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 1 {unique_suffix}", order=0
|
||||
)
|
||||
card2 = await nc_client.deck.create_card(
|
||||
board_id, source_stack_id, f"Card 2 {unique_suffix}", order=1
|
||||
)
|
||||
logger.info(f"Created cards {card1.id} (order 0) and {card2.id} (order 1)")
|
||||
|
||||
try:
|
||||
# Reorder card1 to position after card2
|
||||
await nc_client.deck.reorder_card(
|
||||
board_id=board_id,
|
||||
stack_id=source_stack_id,
|
||||
card_id=card1.id,
|
||||
order=2, # Move to position 2
|
||||
target_stack_id=source_stack_id, # Same stack
|
||||
)
|
||||
logger.info(f"Reordered card {card1.id} to order 2")
|
||||
|
||||
# Verify card is still in the same stack
|
||||
card_after = await nc_client.deck.get_card(board_id, source_stack_id, card1.id)
|
||||
assert card_after.stackId == source_stack_id
|
||||
logger.info("Card reorder within same stack succeeded")
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card1.id)
|
||||
await nc_client.deck.delete_card(board_id, source_stack_id, card2.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up cards: {e}")
|
||||
@@ -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.).
|
||||
|
||||
|
||||
@@ -89,8 +89,13 @@ async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
|
||||
"""Verify create operations are marked as non-idempotent."""
|
||||
tools = await nc_mcp_client.list_tools()
|
||||
|
||||
# Exceptions: operations that are actually idempotent
|
||||
# - calendar_create_meeting: creates or returns existing meeting
|
||||
# - nc_webdav_create_directory: MKCOL returns 405 if exists (same end state)
|
||||
idempotent_exceptions = {"calendar_create_meeting", "nc_webdav_create_directory"}
|
||||
|
||||
for tool in tools.tools:
|
||||
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
|
||||
if "create" in tool.name.lower() and tool.name not in idempotent_exceptions:
|
||||
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
|
||||
assert tool.annotations.idempotentHint is not True, (
|
||||
f"Create tool {tool.name} should not be idempotent (creates new resources)"
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Unit tests for App Password Storage functionality.
|
||||
|
||||
Tests the app password methods in RefreshTokenStorage for multi-user
|
||||
BasicAuth mode background sync.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temp_storage(encryption_key):
|
||||
"""Create temporary storage instance with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_app_passwords.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
async def test_store_app_password(temp_storage):
|
||||
"""Test storing an app password."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||
)
|
||||
|
||||
# Verify it can be retrieved
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved == "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||
|
||||
|
||||
async def test_store_app_password_replaces_existing(temp_storage):
|
||||
"""Test that storing a new app password replaces the existing one."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
)
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="fffff-ggggg-hhhhh-iiiii-jjjjj",
|
||||
)
|
||||
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved == "fffff-ggggg-hhhhh-iiiii-jjjjj"
|
||||
|
||||
|
||||
async def test_get_app_password_nonexistent(temp_storage):
|
||||
"""Test retrieving app password for non-existent user."""
|
||||
retrieved = await temp_storage.get_app_password("nonexistent")
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
async def test_delete_app_password(temp_storage):
|
||||
"""Test deleting an app password."""
|
||||
await temp_storage.store_app_password(
|
||||
user_id="testuser",
|
||||
app_password="JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB",
|
||||
)
|
||||
|
||||
deleted = await temp_storage.delete_app_password("testuser")
|
||||
assert deleted is True
|
||||
|
||||
# Verify it's gone
|
||||
retrieved = await temp_storage.get_app_password("testuser")
|
||||
assert retrieved is None
|
||||
|
||||
|
||||
async def test_delete_app_password_nonexistent(temp_storage):
|
||||
"""Test deleting non-existent app password."""
|
||||
deleted = await temp_storage.delete_app_password("nonexistent")
|
||||
assert deleted is False
|
||||
|
||||
|
||||
async def test_get_all_app_password_user_ids(temp_storage):
|
||||
"""Test listing all users with app passwords."""
|
||||
await temp_storage.store_app_password("alice", "aaaaa-aaaaa-aaaaa-aaaaa-aaaaa")
|
||||
await temp_storage.store_app_password("bob", "bbbbb-bbbbb-bbbbb-bbbbb-bbbbb")
|
||||
await temp_storage.store_app_password("charlie", "ccccc-ccccc-ccccc-ccccc-ccccc")
|
||||
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert len(user_ids) == 3
|
||||
assert "alice" in user_ids
|
||||
assert "bob" in user_ids
|
||||
assert "charlie" in user_ids
|
||||
|
||||
|
||||
async def test_get_all_app_password_user_ids_empty(temp_storage):
|
||||
"""Test listing users when none have app passwords."""
|
||||
user_ids = await temp_storage.get_all_app_password_user_ids()
|
||||
assert len(user_ids) == 0
|
||||
|
||||
|
||||
async def test_app_password_encryption(encryption_key):
|
||||
"""Test that app passwords are encrypted at rest."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_encryption.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
|
||||
# Store a password
|
||||
test_password = "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB"
|
||||
await storage.store_app_password("testuser", test_password)
|
||||
|
||||
# Read directly from database to verify encryption
|
||||
import aiosqlite
|
||||
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
async with db.execute(
|
||||
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||
("testuser",),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
# The stored value should be encrypted (not plain text)
|
||||
encrypted_bytes = row[0]
|
||||
assert encrypted_bytes != test_password.encode()
|
||||
# Encrypted data should be longer due to Fernet overhead
|
||||
assert len(encrypted_bytes) > len(test_password)
|
||||
|
||||
|
||||
async def test_app_password_requires_encryption_key():
|
||||
"""Test that app password operations require encryption key."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_no_key.db"
|
||||
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
|
||||
await storage.initialize()
|
||||
|
||||
# Storing should fail without encryption key
|
||||
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||
await storage.store_app_password(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
|
||||
# Getting should also fail without encryption key
|
||||
with pytest.raises(RuntimeError, match="Encryption key not configured"):
|
||||
await storage.get_app_password("testuser")
|
||||
|
||||
|
||||
async def test_multiple_users_independence(temp_storage):
|
||||
"""Test that different users maintain independent app passwords."""
|
||||
users = ["alice", "bob", "charlie", "diana"]
|
||||
|
||||
# Store unique passwords for each user
|
||||
for i, user in enumerate(users):
|
||||
password = (
|
||||
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||
)
|
||||
await temp_storage.store_app_password(user, password)
|
||||
|
||||
# Verify each user has their correct password
|
||||
for user in users:
|
||||
expected = (
|
||||
f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}-" * 4
|
||||
+ f"{user[0]}{user[0]}{user[0]}{user[0]}{user[0]}"
|
||||
)
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved == expected
|
||||
|
||||
# Delete one user's password
|
||||
await temp_storage.delete_app_password("bob")
|
||||
|
||||
# Verify other users unchanged
|
||||
for user in ["alice", "charlie", "diana"]:
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved is not None
|
||||
|
||||
# Verify bob's password is gone
|
||||
assert await temp_storage.get_app_password("bob") is None
|
||||
|
||||
|
||||
async def test_app_password_with_special_characters(temp_storage):
|
||||
"""Test storing passwords with various alphanumeric patterns."""
|
||||
# Nextcloud app passwords use alphanumeric characters
|
||||
passwords = [
|
||||
"AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", # uppercase
|
||||
"aaaaa-bbbbb-ccccc-ddddd-eeeee", # lowercase
|
||||
"12345-67890-12345-67890-12345", # numbers
|
||||
"aB1cD-eF2gH-iJ3kL-mN4oP-qR5sT", # mixed
|
||||
]
|
||||
|
||||
for i, password in enumerate(passwords):
|
||||
user = f"user{i}"
|
||||
await temp_storage.store_app_password(user, password)
|
||||
retrieved = await temp_storage.get_app_password(user)
|
||||
assert retrieved == password
|
||||
|
||||
|
||||
async def test_decryption_with_wrong_key(encryption_key):
|
||||
"""Test that decryption fails with wrong key."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_wrong_key.db"
|
||||
|
||||
# Store with original key
|
||||
storage1 = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage1.initialize()
|
||||
await storage1.store_app_password("testuser", "JHWzB-ZYgLZ-3qBDj-ZQe5o-LdKpB")
|
||||
|
||||
# Try to read with different key
|
||||
wrong_key = Fernet.generate_key().decode()
|
||||
storage2 = RefreshTokenStorage(db_path=str(db_path), encryption_key=wrong_key)
|
||||
await storage2.initialize()
|
||||
|
||||
# Decryption should fail and return None (graceful handling)
|
||||
retrieved = await storage2.get_app_password("testuser")
|
||||
assert retrieved is None
|
||||
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
Unit tests for Management API app password endpoints.
|
||||
|
||||
Tests the REST API endpoints for multi-user BasicAuth mode app password management:
|
||||
- POST /api/v1/users/{user_id}/app-password - Provision app password
|
||||
- GET /api/v1/users/{user_id}/app-password - Check status
|
||||
- DELETE /api/v1/users/{user_id}/app-password - Delete app password
|
||||
"""
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api import passwords
|
||||
from nextcloud_mcp_server.api.passwords import (
|
||||
delete_app_password,
|
||||
get_app_password_status,
|
||||
provision_app_password,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
"""Clear rate limit state before each test."""
|
||||
passwords._rate_limit_attempts.clear()
|
||||
yield
|
||||
passwords._rate_limit_attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def encryption_key():
|
||||
"""Generate a test encryption key."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temp_storage(encryption_key):
|
||||
"""Create temporary storage instance with encryption for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test_management.db"
|
||||
storage = RefreshTokenStorage(
|
||||
db_path=str(db_path), encryption_key=encryption_key
|
||||
)
|
||||
await storage.initialize()
|
||||
yield storage
|
||||
|
||||
|
||||
def create_basic_auth_header(username: str, password: str) -> str:
|
||||
"""Create BasicAuth header value."""
|
||||
credentials = f"{username}:{password}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
return f"Basic {encoded}"
|
||||
|
||||
|
||||
def create_test_app(storage):
|
||||
"""Create a test Starlette app with the management endpoints."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
app.state.storage = storage
|
||||
return app
|
||||
|
||||
|
||||
async def test_provision_app_password_missing_auth():
|
||||
"""Test that missing auth returns 401."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/api/v1/users/testuser/app-password")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Missing BasicAuth" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_invalid_auth_format():
|
||||
"""Test that invalid auth format returns 401."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={"Authorization": "Basic invalid-not-base64!!!"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid BasicAuth" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_username_mismatch():
|
||||
"""Test that username mismatch returns 403."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
# Try to provision for "testuser" but auth as "otheruser"
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "does not match" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_invalid_format():
|
||||
"""Test that invalid app password format returns 400."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
# Use invalid password format (not xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header("testuser", "invalid-password")
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Invalid app password format" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_provision_app_password_success(temp_storage, mocker):
|
||||
"""Test successful app password provisioning."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"ocs": {"data": {"id": "testuser"}}}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
# Create app with storage
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "stored" in data["message"].lower()
|
||||
|
||||
# Verify password was stored
|
||||
stored_password = await temp_storage.get_app_password("testuser")
|
||||
assert stored_password == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
|
||||
|
||||
async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
"""Test that failed Nextcloud validation returns 401."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid app password" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_get_app_password_status_provisioned(temp_storage, mocker):
|
||||
"""Test checking status when app password is provisioned."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["user_id"] == "testuser"
|
||||
assert data["has_app_password"] is True
|
||||
|
||||
|
||||
async def test_get_app_password_status_not_provisioned(temp_storage, mocker):
|
||||
"""Test checking status when app password is not provisioned."""
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["user_id"] == "testuser"
|
||||
assert data["has_app_password"] is False
|
||||
|
||||
|
||||
async def test_get_app_password_status_username_mismatch():
|
||||
"""Test that username mismatch returns 403 for status check."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
get_app_password_status,
|
||||
methods=["GET"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_delete_app_password_success(temp_storage, mocker):
|
||||
"""Test successful app password deletion."""
|
||||
# Store an app password
|
||||
await temp_storage.store_app_password("testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee")
|
||||
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "deleted" in data["message"].lower()
|
||||
|
||||
# Verify password was removed
|
||||
stored_password = await temp_storage.get_app_password("testuser")
|
||||
assert stored_password is None
|
||||
|
||||
|
||||
async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
"""Test deleting non-existent app password."""
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = create_test_app(temp_storage)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "no app password found" in data["message"].lower()
|
||||
|
||||
|
||||
async def test_delete_app_password_invalid_credentials(mocker):
|
||||
"""Test that invalid credentials returns 401 for deletion."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "wrong-password-xxxxx"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in response.json()["error"]
|
||||
|
||||
|
||||
async def test_delete_app_password_username_mismatch():
|
||||
"""Test that username mismatch returns 403 for deletion."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
delete_app_password,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.delete(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"otheruser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_provision_app_password_rate_limiting(mocker):
|
||||
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401 (failed validation)
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Make 5 failed attempts (should all return 401)
|
||||
for i in range(5):
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401, f"Attempt {i + 1} should return 401"
|
||||
|
||||
# 6th attempt should be rate limited (429)
|
||||
response = client.post(
|
||||
"/api/v1/users/testuser/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"testuser", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
assert "Rate limit exceeded" in response.json()["error"]
|
||||
assert "Retry-After" in response.headers
|
||||
|
||||
|
||||
async def test_rate_limiting_is_per_user(mocker):
|
||||
"""Test that rate limiting is applied per user, not globally."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.api.passwords.httpx.AsyncClient",
|
||||
return_value=mock_client,
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route(
|
||||
"/api/v1/users/{user_id}/app-password",
|
||||
provision_app_password,
|
||||
methods=["POST"],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Make 5 failed attempts for user1 (hits rate limit)
|
||||
for _ in range(5):
|
||||
client.post(
|
||||
"/api/v1/users/user1/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# user1 should be rate limited
|
||||
response = client.post(
|
||||
"/api/v1/users/user1/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user1", "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
# user2 should NOT be rate limited (different user)
|
||||
response = client.post(
|
||||
"/api/v1/users/user2/app-password",
|
||||
headers={
|
||||
"Authorization": create_basic_auth_header(
|
||||
"user2", "bbbbb-ccccc-ddddd-eeeee-fffff"
|
||||
)
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401 # Fails validation, but not rate limited
|
||||
@@ -0,0 +1,716 @@
|
||||
"""
|
||||
Unit tests for Management API PDF preview endpoint.
|
||||
|
||||
Tests the /api/v1/pdf-preview endpoint focusing on:
|
||||
- Parameter validation (file_path, page, scale)
|
||||
- OAuth token validation
|
||||
- PDF rendering with PyMuPDF
|
||||
- Error handling (file not found, invalid page, etc.)
|
||||
"""
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api.visualization import get_pdf_preview
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def create_test_app():
|
||||
"""Create a test Starlette app with the PDF preview endpoint."""
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/api/v1/pdf-preview", get_pdf_preview, methods=["GET"]),
|
||||
]
|
||||
)
|
||||
# Set up OAuth context (required by endpoint)
|
||||
app.state.oauth_context = {"config": {"nextcloud_host": "http://localhost:8080"}}
|
||||
return app
|
||||
|
||||
|
||||
def create_mock_pdf_bytes():
|
||||
"""Create a minimal valid PDF for testing."""
|
||||
# Minimal PDF structure that PyMuPDF can parse
|
||||
# This is a 1-page PDF with a blank page
|
||||
pdf_content = b"""%PDF-1.4
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
trailer
|
||||
<< /Size 4 /Root 1 0 R >>
|
||||
startxref
|
||||
196
|
||||
%%EOF"""
|
||||
return pdf_content
|
||||
|
||||
|
||||
class TestPdfPreviewParameterValidation:
|
||||
"""Tests for parameter validation in PDF preview endpoint."""
|
||||
|
||||
def test_missing_file_path_returns_400(self):
|
||||
"""Test that missing file_path parameter returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "file_path" in data["error"].lower()
|
||||
|
||||
def test_invalid_page_number_returns_400(self):
|
||||
"""Test that invalid page number (0 or negative) returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test page=0
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "page" in data["error"].lower()
|
||||
|
||||
# Test negative page
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=-1",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_invalid_scale_returns_400(self):
|
||||
"""Test that scale outside valid range returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test scale too small
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.1",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "scale" in data["error"].lower()
|
||||
|
||||
# Test scale too large
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=10.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_non_numeric_page_returns_400(self):
|
||||
"""Test that non-numeric page parameter returns 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=abc",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestPdfPreviewAuthentication:
|
||||
"""Tests for authentication in PDF preview endpoint."""
|
||||
|
||||
def test_unauthorized_without_token_returns_401(self):
|
||||
"""Test that request without token returns 401."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Invalid token"),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/pdf-preview?file_path=/test.pdf")
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_unauthorized_with_invalid_token_returns_401(self):
|
||||
"""Test that request with invalid token returns 401."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=Exception("Token expired"),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
class TestPdfPreviewRendering:
|
||||
"""Tests for PDF rendering functionality."""
|
||||
|
||||
def test_successful_pdf_render(self):
|
||||
"""Test successful PDF page rendering."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
# Mock the WebDAV client
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=1&scale=1.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "image" in data
|
||||
assert data["page_number"] == 1
|
||||
assert data["total_pages"] == 1
|
||||
|
||||
# Verify image is valid base64
|
||||
try:
|
||||
decoded = base64.b64decode(data["image"])
|
||||
# PNG magic bytes
|
||||
assert decoded[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
except Exception as e:
|
||||
pytest.fail(f"Image is not valid base64-encoded PNG: {e}")
|
||||
|
||||
def test_page_out_of_range_returns_400(self):
|
||||
"""Test that requesting page beyond total pages returns 400."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&page=999",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "page" in data["error"].lower()
|
||||
assert "999" in data["error"]
|
||||
|
||||
def test_file_not_found_returns_404(self):
|
||||
"""Test that non-existent file returns 404."""
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
side_effect=FileNotFoundError("File not found")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/nonexistent.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "not found" in data["error"].lower()
|
||||
|
||||
def test_default_parameters(self):
|
||||
"""Test that default parameters (page=1, scale=2.0) are used."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
# Only file_path, no page or scale
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["page_number"] == 1 # Default page
|
||||
|
||||
|
||||
class TestPdfPreviewEdgeCases:
|
||||
"""Tests for edge cases in PDF preview endpoint."""
|
||||
|
||||
def test_url_encoded_file_path(self):
|
||||
"""Test that URL-encoded file paths are handled correctly."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
# URL-encoded path with spaces
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/Documents/My%20File.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Verify the path was passed correctly to WebDAV
|
||||
mock_webdav.read_file.assert_called_once()
|
||||
call_args = mock_webdav.read_file.call_args[0]
|
||||
assert "My File.pdf" in call_args[0]
|
||||
|
||||
def test_missing_nextcloud_host_config(self):
|
||||
"""Test handling when Nextcloud host is not configured."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
# Override with empty config
|
||||
app.state.oauth_context = {"config": {"nextcloud_host": ""}}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
|
||||
def test_corrupted_pdf_returns_400(self):
|
||||
"""Test that corrupted PDF data returns 400 with specific error."""
|
||||
mock_webdav = AsyncMock()
|
||||
# Return invalid PDF bytes
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(b"not a valid pdf", "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert (
|
||||
"corrupted" in data["error"].lower()
|
||||
or "invalid" in data["error"].lower()
|
||||
)
|
||||
|
||||
def test_boundary_scale_values(self):
|
||||
"""Test boundary scale values (min and max)."""
|
||||
pdf_bytes = create_mock_pdf_bytes()
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(return_value=(pdf_bytes, "application/pdf"))
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test minimum valid scale (0.5)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=0.5",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test maximum valid scale (5.0)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/test.pdf&scale=5.0",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestPdfPreviewSecurityValidation:
|
||||
"""Tests for security validations in PDF preview endpoint."""
|
||||
|
||||
def test_path_traversal_returns_400(self):
|
||||
"""Test that path traversal attempts are blocked with 400."""
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test various path traversal patterns
|
||||
traversal_paths = [
|
||||
"/Documents/../../../etc/passwd",
|
||||
"/../secret.pdf",
|
||||
"/folder/..%2F..%2Fetc/passwd", # URL-encoded
|
||||
"/test/../secret.pdf",
|
||||
]
|
||||
|
||||
for path in traversal_paths:
|
||||
response = client.get(
|
||||
f"/api/v1/pdf-preview?file_path={path}",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert response.status_code == 400, (
|
||||
f"Path traversal not blocked: {path}"
|
||||
)
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "invalid file path" in data["error"].lower()
|
||||
|
||||
def test_file_size_limit_exceeded_returns_413(self):
|
||||
"""Test that files exceeding 50MB limit return 413."""
|
||||
# Create bytes larger than 50MB limit
|
||||
large_pdf_bytes = b"x" * (51 * 1024 * 1024) # 51 MB
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(large_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/large.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 413
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "size limit" in data["error"].lower()
|
||||
|
||||
def test_corrupted_pdf_returns_400(self):
|
||||
"""Test that corrupted PDF returns 400 with specific error message."""
|
||||
# Invalid PDF content that PyMuPDF cannot parse
|
||||
corrupted_pdf_bytes = b"not a valid PDF file content"
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(corrupted_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/corrupted.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert (
|
||||
"corrupted" in data["error"].lower()
|
||||
or "invalid" in data["error"].lower()
|
||||
)
|
||||
|
||||
def test_empty_pdf_returns_400(self):
|
||||
"""Test that empty PDF file returns 400."""
|
||||
empty_pdf_bytes = b""
|
||||
|
||||
mock_webdav = AsyncMock()
|
||||
mock_webdav.read_file = AsyncMock(
|
||||
return_value=(empty_pdf_bytes, "application/pdf")
|
||||
)
|
||||
|
||||
mock_nc_client = MagicMock()
|
||||
mock_nc_client.webdav = mock_webdav
|
||||
mock_nc_client.__aenter__ = AsyncMock(return_value=mock_nc_client)
|
||||
mock_nc_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.validate_token_and_get_user",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("testuser", True),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.api.visualization.extract_bearer_token",
|
||||
return_value="test-token",
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.client.NextcloudClient.from_token",
|
||||
return_value=mock_nc_client,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/pdf-preview?file_path=/empty.pdf",
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Unit tests for Management API status endpoint.
|
||||
|
||||
Tests the /api/v1/status endpoint focusing on:
|
||||
- OIDC config availability in different auth modes
|
||||
- Hybrid mode (multi_user_basic + enable_offline_access) returning OIDC config
|
||||
- OAuth mode returning OIDC config
|
||||
- Non-OAuth modes NOT returning OIDC config
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from nextcloud_mcp_server.api.management import get_server_status
|
||||
from nextcloud_mcp_server.config_validators import AuthMode
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def create_test_app():
|
||||
"""Create a test Starlette app with the status endpoint."""
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/api/v1/status", get_server_status, methods=["GET"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_mock_settings(
|
||||
enable_multi_user_basic: bool = False,
|
||||
enable_offline_access: bool = False,
|
||||
oidc_discovery_url: str | None = None,
|
||||
oidc_issuer: str | None = None,
|
||||
vector_sync_enabled: bool = False,
|
||||
nextcloud_url: str = "http://localhost",
|
||||
enable_token_exchange: bool = False,
|
||||
mcp_client_id: str | None = None,
|
||||
mcp_client_secret: str | None = None,
|
||||
):
|
||||
"""Create mock settings with specified auth configuration."""
|
||||
settings = MagicMock()
|
||||
settings.enable_multi_user_basic_auth = enable_multi_user_basic
|
||||
settings.enable_offline_access = enable_offline_access
|
||||
settings.oidc_discovery_url = oidc_discovery_url
|
||||
settings.oidc_issuer = oidc_issuer
|
||||
settings.vector_sync_enabled = vector_sync_enabled
|
||||
settings.nextcloud_url = nextcloud_url
|
||||
settings.enable_token_exchange = enable_token_exchange
|
||||
settings.mcp_client_id = mcp_client_id
|
||||
settings.mcp_client_secret = mcp_client_secret
|
||||
return settings
|
||||
|
||||
|
||||
class TestStatusEndpointOidcConfig:
|
||||
"""Tests for OIDC configuration in status endpoint."""
|
||||
|
||||
def test_hybrid_mode_returns_oidc_config(self):
|
||||
"""Test that hybrid mode (multi_user_basic + offline_access) returns OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
# get_settings and detect_auth_mode are imported inside the function
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "multi_user_basic"
|
||||
assert data["supports_app_passwords"] is True
|
||||
|
||||
# Verify OIDC config is present (key feature for hybrid mode)
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "http://keycloak/.well-known/openid-configuration"
|
||||
)
|
||||
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
|
||||
|
||||
def test_hybrid_mode_without_oidc_settings_no_oidc_key(self):
|
||||
"""Test that hybrid mode without OIDC settings doesn't include empty oidc key."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url=None,
|
||||
oidc_issuer=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# OIDC key should NOT be present if no OIDC settings configured
|
||||
assert "oidc" not in data
|
||||
|
||||
def test_multi_user_basic_without_offline_access_no_oidc(self):
|
||||
"""Test that multi_user_basic WITHOUT offline_access doesn't return OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=False, # Key difference: no offline access
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "multi_user_basic"
|
||||
assert data["supports_app_passwords"] is False
|
||||
|
||||
# OIDC config should NOT be present (not hybrid mode)
|
||||
assert "oidc" not in data
|
||||
|
||||
def test_oauth_mode_returns_oidc_config(self):
|
||||
"""Test that OAuth mode returns OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=False,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url="http://nextcloud/.well-known/openid-configuration",
|
||||
oidc_issuer="http://nextcloud",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "oauth"
|
||||
|
||||
# Verify OIDC config is present
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "http://nextcloud/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
def test_single_user_basic_no_oidc(self):
|
||||
"""Test that single-user BasicAuth mode doesn't return OIDC config."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=False,
|
||||
enable_offline_access=False,
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Verify auth mode
|
||||
assert data["auth_mode"] == "basic"
|
||||
|
||||
# OIDC config should NOT be present
|
||||
assert "oidc" not in data
|
||||
# supports_app_passwords should NOT be present (only for multi_user_basic)
|
||||
assert "supports_app_passwords" not in data
|
||||
|
||||
def test_oidc_partial_config_only_discovery_url(self):
|
||||
"""Test OIDC config with only discovery URL set."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url="http://keycloak/.well-known/openid-configuration",
|
||||
oidc_issuer=None, # Only discovery URL
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" in data
|
||||
assert (
|
||||
data["oidc"]["discovery_url"]
|
||||
== "http://keycloak/.well-known/openid-configuration"
|
||||
)
|
||||
assert "issuer" not in data["oidc"]
|
||||
|
||||
def test_oidc_partial_config_only_issuer(self):
|
||||
"""Test OIDC config with only issuer set."""
|
||||
mock_settings = create_mock_settings(
|
||||
enable_multi_user_basic=True,
|
||||
enable_offline_access=True,
|
||||
oidc_discovery_url=None, # Only issuer
|
||||
oidc_issuer="http://keycloak/realms/test",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.MULTI_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "oidc" in data
|
||||
assert "discovery_url" not in data["oidc"]
|
||||
assert data["oidc"]["issuer"] == "http://keycloak/realms/test"
|
||||
|
||||
|
||||
class TestStatusEndpointBasicResponse:
|
||||
"""Tests for basic status endpoint response fields."""
|
||||
|
||||
def test_status_includes_version(self):
|
||||
"""Test that status endpoint includes version."""
|
||||
mock_settings = create_mock_settings()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "version" in data
|
||||
assert "uptime_seconds" in data
|
||||
assert "management_api_version" in data
|
||||
assert data["management_api_version"] == "1.0"
|
||||
|
||||
def test_status_includes_vector_sync_enabled(self):
|
||||
"""Test that status endpoint includes vector_sync_enabled."""
|
||||
mock_settings = create_mock_settings(vector_sync_enabled=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.config.get_settings", return_value=mock_settings
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.config_validators.detect_auth_mode",
|
||||
return_value=AuthMode.SINGLE_USER_BASIC,
|
||||
),
|
||||
):
|
||||
app = create_test_app()
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["vector_sync_enabled"] is True
|
||||
+1
Submodule third_party/astrolabe added at c079a70af8
Vendored
-25
@@ -1,25 +0,0 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.7.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',
|
||||
},
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: composer
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/cs-fixer"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/openapi-extractor"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/phpunit"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: composer
|
||||
directory: "/vendor-bin/psalm"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: saturday
|
||||
time: "03:00"
|
||||
timezone: Europe/Paris
|
||||
open-pull-requests-limit: 10
|
||||
@@ -1,36 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Block unconventional commits
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: block-unconventional-commits-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
block-unconventional-commits:
|
||||
name: Block unconventional commits
|
||||
|
||||
runs-on: ubuntu-latest-low
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: webiny/action-conventional-commits@8bc41ff4e7d423d56fa4905f6ff79209a78776c7 # v1.3.0
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,36 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Block fixup and squash commits
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: fixup-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
commit-message-check:
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
name: Block fixup and squash commits
|
||||
|
||||
runs-on: ubuntu-latest-low
|
||||
|
||||
steps:
|
||||
- name: Run check
|
||||
uses: skjnldsv/block-fixup-merge-action@c138ea99e45e186567b64cf065ce90f7158c236a # v2
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,100 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint eslint
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-eslint-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest-low
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
outputs:
|
||||
src: ${{ steps.changes.outputs.src}}
|
||||
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- '.github/workflows/**'
|
||||
- 'src/**'
|
||||
- 'appinfo/info.xml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- '.eslintrc.*'
|
||||
- '.eslintignore'
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.vue'
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: changes
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
name: NPM lint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: [changes, lint]
|
||||
|
||||
if: always()
|
||||
|
||||
# This is the summary, we just avoid to rename it so that branch protection rules still match
|
||||
name: eslint
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi
|
||||
@@ -1,38 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint info.xml
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-info-xml-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
xml-linters:
|
||||
runs-on: ubuntu-latest-low
|
||||
|
||||
name: info.xml lint
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download schema
|
||||
run: wget https://raw.githubusercontent.com/nextcloud/appstore/master/nextcloudappstore/api/v1/release/info.xsd
|
||||
|
||||
- name: Lint info.xml
|
||||
uses: ChristophWurst/xmllint-action@36f2a302f84f8c83fceea0b9c59e1eb4a616d3c1 # v1.2
|
||||
with:
|
||||
xml-file: ./appinfo/info.xml
|
||||
xml-schema-file: ./info.xsd
|
||||
@@ -1,52 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint php-cs
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-php-cs-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: php-cs
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev
|
||||
composer i
|
||||
|
||||
- name: Lint
|
||||
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
|
||||
@@ -1,75 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint php
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-php-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
runs-on: ubuntu-latest-low
|
||||
outputs:
|
||||
php-versions: ${{ steps.versions.outputs.php-versions }}
|
||||
steps:
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version matrix
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
|
||||
|
||||
php-lint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: matrix
|
||||
strategy:
|
||||
matrix:
|
||||
php-versions: ${{fromJson(needs.matrix.outputs.php-versions)}}
|
||||
|
||||
name: php-lint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up php ${{ matrix.php-versions }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Lint
|
||||
run: composer run lint
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: php-lint
|
||||
|
||||
if: always()
|
||||
|
||||
name: php-lint-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi
|
||||
@@ -1,53 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Lint stylelint
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: lint-stylelint-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: stylelint
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run stylelint
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Node
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: node-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest-low
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
outputs:
|
||||
src: ${{ steps.changes.outputs.src}}
|
||||
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- '.github/workflows/**'
|
||||
- 'src/**'
|
||||
- 'appinfo/info.xml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.vue'
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: changes
|
||||
if: needs.changes.outputs.src != 'false'
|
||||
|
||||
name: NPM build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies & build
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Check webpack build changes
|
||||
run: |
|
||||
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets, see the section \"Show changes on failure\" for details' && exit 1)"
|
||||
|
||||
- name: Show changes on failure
|
||||
if: failure()
|
||||
run: |
|
||||
git status
|
||||
git --no-pager diff
|
||||
exit 1 # make it red to grab attention
|
||||
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: [changes, build]
|
||||
|
||||
if: always()
|
||||
|
||||
# This is the summary, we just avoid to rename it so that branch protection rules still match
|
||||
name: node
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.changes.outputs.src != 'false' && needs.build.result != 'success' }}; then exit 1; fi
|
||||
@@ -1,81 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Npm audit fix and compile
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# At 2:30 on Sundays
|
||||
- cron: '30 2 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branches: ['main', 'master', 'stable31', 'stable30']
|
||||
|
||||
name: npm-audit-fix-${{ matrix.branches }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ matrix.branches }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Fix npm audit
|
||||
id: npm-audit
|
||||
uses: nextcloud-libraries/npm-audit-action@1b1728b2b4a7a78d69de65608efcf4db0e3e42d0 # v0.2.0
|
||||
|
||||
- name: Run npm ci and npm run build
|
||||
if: steps.checkout.outcome == 'success'
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.checkout.outcome == 'success'
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'fix(deps): Fix npm audit'
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
|
||||
signoff: true
|
||||
branch: automated/noid/${{ matrix.branches }}-fix-npm-audit
|
||||
title: '[${{ matrix.branches }}] Fix npm audit'
|
||||
body: ${{ steps.npm-audit.outputs.markdown }}
|
||||
labels: |
|
||||
dependencies
|
||||
3. to review
|
||||
@@ -1,96 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-FileCopyrightText: 2024 Arthur Schiwon <blizzz@arthur-schiwon.de>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: OpenAPI
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: openapi-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
openapi:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
if: ${{ github.repository_owner != 'nextcloud-gmbh' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get php version
|
||||
id: php_versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Set up php
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.php_versions.outputs.php-available }}
|
||||
extensions: xml
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check Typescript OpenApi types
|
||||
id: check_typescript_openapi
|
||||
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
|
||||
with:
|
||||
files: "src/types/openapi/openapi*.ts"
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
if: steps.check_typescript_openapi.outputs.files_exists == 'true'
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: node_versions
|
||||
# Continue if no package.json
|
||||
continue-on-error: true
|
||||
with:
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
if: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.node_versions.outputs.npmVersion }}
|
||||
if: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ steps.node_versions.outputs.nodeVersion }}
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Set up dependencies
|
||||
run: composer i
|
||||
|
||||
- name: Regenerate OpenAPI
|
||||
run: composer run openapi
|
||||
|
||||
- name: Check openapi*.json and typescript changes
|
||||
run: |
|
||||
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and (if applicable) src/types/openapi/openapi*.ts, see the section \"Show changes on failure\" for details' && exit 1)"
|
||||
|
||||
- name: Show changes on failure
|
||||
if: failure()
|
||||
run: |
|
||||
git status
|
||||
git --no-pager diff
|
||||
exit 1 # make it red to grab attention
|
||||
@@ -1,87 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Static analysis
|
||||
|
||||
on: pull_request
|
||||
|
||||
concurrency:
|
||||
group: psalm-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
runs-on: ubuntu-latest-low
|
||||
outputs:
|
||||
ocp-matrix: ${{ steps.versions.outputs.ocp-matrix }}
|
||||
steps:
|
||||
- name: Checkout app
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version matrix
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
|
||||
- name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml
|
||||
run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml
|
||||
|
||||
static-analysis:
|
||||
runs-on: ubuntu-latest
|
||||
needs: matrix
|
||||
strategy:
|
||||
# do not stop on another job's failure
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.ocp-matrix) }}
|
||||
|
||||
name: static-psalm-analysis ${{ matrix.ocp-version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up php${{ matrix.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
# Temporary workaround for missing pcntl_* in PHP 8.3
|
||||
ini-values: disable_functions=
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev
|
||||
composer i
|
||||
|
||||
|
||||
- name: Install dependencies # zizmor: ignore[template-injection]
|
||||
run: composer require --dev 'nextcloud/ocp:${{ matrix.ocp-version }}' --ignore-platform-reqs --with-dependencies
|
||||
|
||||
- name: Run coding standards check
|
||||
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
|
||||
|
||||
summary:
|
||||
runs-on: ubuntu-latest-low
|
||||
needs: static-analysis
|
||||
|
||||
if: always()
|
||||
|
||||
name: static-psalm-analysis-summary
|
||||
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: if ${{ needs.static-analysis.result != 'success' }}; then exit 1; fi
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Auto approve nextcloud/ocp
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- stable*
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-nextcloud-ocp-approve-merge-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
auto-approve-merge:
|
||||
if: github.actor == 'nextcloud-command'
|
||||
runs-on: ubuntu-latest-low
|
||||
permissions:
|
||||
# for hmarr/auto-approve-action to approve PRs
|
||||
pull-requests: write
|
||||
# for alexwilson/enable-github-automerge-action to approve PRs
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Disabled on forks
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
run: |
|
||||
echo 'Can not approve PRs from forks'
|
||||
exit 1
|
||||
|
||||
- uses: mdecoleman/pr-branch-name@55795d86b4566d300d237883103f052125cc7508 # v3.0.0
|
||||
id: branchname
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# GitHub actions bot approve
|
||||
- uses: hmarr/auto-approve-action@b40d6c9ed2fa10c9a2749eca7eb004418a705501 # v2
|
||||
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Enable GitHub auto merge
|
||||
- name: Auto merge
|
||||
uses: alexwilson/enable-github-automerge-action@56e3117d1ae1540309dc8f7a9f2825bc3c5f06ff # v2.0.0
|
||||
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,101 +0,0 @@
|
||||
# This workflow is provided via the organization template repository
|
||||
#
|
||||
# https://github.com/nextcloud/.github
|
||||
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Update nextcloud/ocp
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '5 2 * * 0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-nextcloud-ocp:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branches: ['master']
|
||||
target: ['stable30']
|
||||
|
||||
name: update-nextcloud-ocp-${{ matrix.branches }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ matrix.branches }}
|
||||
submodules: true
|
||||
|
||||
- name: Set up php8.2
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: 8.2
|
||||
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Read codeowners
|
||||
id: codeowners
|
||||
run: |
|
||||
grep '/appinfo/info.xml' .github/CODEOWNERS | cut -f 2- -d ' ' | xargs | awk '{ print "codeowners="$0 }' >> $GITHUB_OUTPUT
|
||||
continue-on-error: true
|
||||
|
||||
- name: Composer install
|
||||
run: composer install
|
||||
|
||||
- name: Composer update nextcloud/ocp
|
||||
id: update_branch
|
||||
run: composer require --dev nextcloud/ocp:dev-${{ matrix.target }}
|
||||
|
||||
- name: Raise on issue on failure
|
||||
uses: dacbd/create-issue-action@cdb57ab6ff8862aa09fee2be6ba77a59581921c2 # v2.0.0
|
||||
if: ${{ failure() && steps.update_branch.conclusion == 'failure' }}
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
title: 'Failed to update nextcloud/ocp package'
|
||||
body: 'Please check the output of the GitHub action and manually resolve the issues<br>${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}<br>${{ steps.codeowners.outputs.codeowners }}'
|
||||
|
||||
- name: Reset checkout 3rdparty
|
||||
run: |
|
||||
git clean -f 3rdparty
|
||||
git checkout 3rdparty
|
||||
continue-on-error: true
|
||||
|
||||
- name: Reset checkout vendor
|
||||
run: |
|
||||
git clean -f vendor
|
||||
git checkout vendor
|
||||
continue-on-error: true
|
||||
|
||||
- name: Reset checkout vendor-bin
|
||||
run: |
|
||||
git clean -f vendor-bin
|
||||
git checkout vendor-bin
|
||||
continue-on-error: true
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'chore(dev-deps): Bump nextcloud/ocp package'
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
|
||||
signoff: true
|
||||
branch: 'automated/noid/${{ matrix.branches }}-update-nextcloud-ocp'
|
||||
title: '[${{ matrix.branches }}] Update nextcloud/ocp dependency'
|
||||
body: |
|
||||
Auto-generated update of [nextcloud/ocp](https://github.com/nextcloud-deps/ocp/) dependency
|
||||
labels: |
|
||||
dependencies
|
||||
3. to review
|
||||
@@ -1,14 +0,0 @@
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
/vendor/
|
||||
/vendor-bin/*/vendor/
|
||||
|
||||
/.php-cs-fixer.cache
|
||||
/tests/.phpunit.cache
|
||||
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
js/
|
||||
css/
|
||||
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
-469
@@ -1,469 +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.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.
|
||||
-60
@@ -1,60 +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.7.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="30" 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>
|
||||
</info>
|
||||
-110
@@ -1,110 +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',
|
||||
],
|
||||
|
||||
// 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
-50
@@ -1,50 +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/"
|
||||
}
|
||||
},
|
||||
"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": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky",
|
||||
"openapi": "generate-spec",
|
||||
"rector": "rector && composer cs:fix"
|
||||
},
|
||||
"require": {
|
||||
"bamarni/composer-bin-plugin": "^1.8",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"nextcloud/ocp": "dev-stable30",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"bamarni/composer-bin-plugin": true
|
||||
},
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
-1334
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,791 +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 $client;
|
||||
private $userSession;
|
||||
private $urlGenerator;
|
||||
private $logger;
|
||||
private $tokenStorage;
|
||||
private $config;
|
||||
private $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
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
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) {
|
||||
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
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
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) {
|
||||
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
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
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) {
|
||||
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
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
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) {
|
||||
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
|
||||
$refreshCallback = function (string $refreshToken) {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if (!$newTokenData) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,258 +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 $tokenStorage;
|
||||
private $userSession;
|
||||
private $logger;
|
||||
private $config;
|
||||
private $client;
|
||||
private $httpClientService;
|
||||
private $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
|
||||
try {
|
||||
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||
$this->logger->info("Successfully stored app password for user: $userId");
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => 'App password saved successfully'
|
||||
], Http::STATUS_OK);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store app password for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to save app password'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,549 +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\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 $config;
|
||||
private $session;
|
||||
private $userSession;
|
||||
private $urlGenerator;
|
||||
private $tokenStorage;
|
||||
private $logger;
|
||||
private $l;
|
||||
private $httpClient;
|
||||
private $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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user