refactor: extract Astrolabe to separate repository
Astrolabe has been extracted to its own repository at github.com/cbcoutinho/astrolabe for independent releases. Changes: - Replace third_party/astrolabe/ directory with git submodule - Remove astrolabe-ci.yml and appstore-build-publish.yml workflows - Remove scripts/bump-astrolabe.sh - Remove Astrolabe sections from bump-version.yml workflow - Remove Astrolabe build steps from test.yml CI workflow - Remove astrolabe volume mount from docker-compose.yml - Simplify astrolabe install hook to always use app store - Update CONTRIBUTING.md to reflect two-component monorepo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
name: Build and Publish Astrolabe App Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'astrolabe-v*'
|
||||
|
||||
env:
|
||||
APP_NAME: astrolabe
|
||||
APP_DIR: third_party/astrolabe
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: tag
|
||||
run: |
|
||||
echo "TAG=${GITHUB_REF#refs/tags/astrolabe-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate version in info.xml matches tag
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
INFO_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' appinfo/info.xml | tr -d '\t')
|
||||
if [ "$INFO_VERSION" != "${{ steps.tag.outputs.TAG }}" ]; then
|
||||
echo "Version mismatch: info.xml has $INFO_VERSION but tag is ${{ steps.tag.outputs.TAG }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version validated: $INFO_VERSION"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
coverage: none
|
||||
|
||||
- name: Checkout Nextcloud server (for signing)
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: nextcloud/server
|
||||
ref: stable30
|
||||
path: server
|
||||
|
||||
- name: Install dependencies and build
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup signing certificate
|
||||
run: |
|
||||
mkdir -p $HOME/.nextcloud/certificates
|
||||
echo "${{ secrets.APP_PRIVATE_KEY }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.key
|
||||
echo "${{ secrets.APP_PUBLIC_CRT }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.crt
|
||||
|
||||
- name: Build app store package
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: make appstore server_dir=${{ github.workspace }}/server
|
||||
|
||||
- name: Create GitHub release and attach tarball
|
||||
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ env.APP_DIR }}/build/artifacts/${{ env.APP_NAME }}.tar.gz
|
||||
asset_name: ${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
tag: ${{ github.ref }}
|
||||
release_name: Astrolabe ${{ steps.tag.outputs.TAG }}
|
||||
prerelease: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
|
||||
- name: Upload to Nextcloud App Store
|
||||
uses: R0Wi/nextcloud-appstore-push-action@9244bb5445776688cfe90fa1903ea8dff95b0c28 # v1.0.4
|
||||
with:
|
||||
app_name: ${{ env.APP_NAME }}
|
||||
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
@@ -1,323 +0,0 @@
|
||||
# Consolidated CI workflow for Astrolabe Nextcloud app
|
||||
#
|
||||
# Runs on PRs that modify the astrolabe directory
|
||||
# Based on Nextcloud app skeleton workflows
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Astrolabe CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'third_party/astrolabe/**'
|
||||
- '.github/workflows/astrolabe-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: astrolabe-ci-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
php: ${{ steps.changes.outputs.php }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
frontend:
|
||||
- 'third_party/astrolabe/src/**'
|
||||
- 'third_party/astrolabe/package.json'
|
||||
- 'third_party/astrolabe/package-lock.json'
|
||||
- 'third_party/astrolabe/vite.config.js'
|
||||
- 'third_party/astrolabe/**/*.js'
|
||||
- 'third_party/astrolabe/**/*.ts'
|
||||
- 'third_party/astrolabe/**/*.vue'
|
||||
php:
|
||||
- 'third_party/astrolabe/lib/**'
|
||||
- 'third_party/astrolabe/appinfo/**'
|
||||
- 'third_party/astrolabe/composer.json'
|
||||
- 'third_party/astrolabe/psalm.xml'
|
||||
|
||||
# Node.js build and lint
|
||||
node-build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Node.js build
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astrolabe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies & build
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Check webpack build changes
|
||||
run: |
|
||||
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets' && exit 1)"
|
||||
|
||||
# ESLint
|
||||
eslint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: ESLint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astrolabe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# Stylelint
|
||||
stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Stylelint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astrolabe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run stylelint
|
||||
|
||||
# PHP Code Style
|
||||
php-cs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: PHP CS Fixer
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Lint
|
||||
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
|
||||
|
||||
# Psalm Static Analysis
|
||||
psalm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: Psalm
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Get OCP version matrix
|
||||
id: ocp-versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Install OCP for static analysis
|
||||
run: |
|
||||
# Get first OCP version from matrix
|
||||
OCP_VERSION=$(echo '${{ steps.ocp-versions.outputs.ocp-matrix }}' | jq -r '.include[0]."ocp-version"')
|
||||
composer require --dev "nextcloud/ocp:$OCP_VERSION" --ignore-platform-reqs --with-dependencies
|
||||
|
||||
- name: Run Psalm
|
||||
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
|
||||
|
||||
# PHPUnit Tests
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php-versions: ['8.1', '8.2', '8.3']
|
||||
|
||||
name: PHPUnit (PHP ${{ matrix.php-versions }})
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up PHP ${{ matrix.php-versions }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
extensions: ctype, curl, dom, gd, iconv, intl, json, mbstring, openssl, posix, sqlite, xml, zip
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Get OCP version matrix
|
||||
id: ocp-versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Install OCP for testing
|
||||
run: |
|
||||
OCP_VERSION=$(echo '${{ steps.ocp-versions.outputs.ocp-matrix }}' | jq -r '.include[0]."ocp-version"')
|
||||
composer require --dev "nextcloud/ocp:$OCP_VERSION" --ignore-platform-reqs --with-dependencies
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: composer run test:unit
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, node-build, eslint, stylelint, php-cs, psalm, phpunit]
|
||||
if: always()
|
||||
name: astrolabe-ci-summary
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: |
|
||||
if ${{ needs.changes.outputs.frontend != 'false' && (needs.node-build.result != 'success' || needs.eslint.result != 'success' || needs.stylelint.result != 'success') }}; then
|
||||
echo "Frontend checks failed"
|
||||
exit 1
|
||||
fi
|
||||
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success' || needs.phpunit.result != 'success') }}; then
|
||||
echo "PHP checks failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed"
|
||||
@@ -83,9 +83,9 @@ jobs:
|
||||
commit_range="${last_mcp_tag}..HEAD"
|
||||
fi
|
||||
|
||||
# Count conventional commits that are NOT scoped to helm or astrolabe
|
||||
# Count conventional commits that are NOT scoped to helm
|
||||
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
||||
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
|
||||
{ grep -v "(helm)" || true; } | wc -l)
|
||||
|
||||
MCP_BUMPED=false
|
||||
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||
@@ -115,14 +115,6 @@ jobs:
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
fi
|
||||
|
||||
# Bump Astrolabe (scope: astrolabe)
|
||||
echo "Checking Astrolabe for version bump..."
|
||||
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
|
||||
echo "Bumping Astrolabe version..."
|
||||
./scripts/bump-astrolabe.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
|
||||
fi
|
||||
|
||||
# Output summary
|
||||
if [ -z "$BUMPED_COMPONENTS" ]; then
|
||||
echo "No components required version bumps"
|
||||
@@ -158,10 +150,6 @@ jobs:
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
astrolabe)
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
|
||||
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
@@ -48,23 +48,6 @@ 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@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
|
||||
@@ -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 = git@github.com:cbcoutinho/astrolabe.git
|
||||
|
||||
+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,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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ../..
|
||||
+1
Submodule third_party/astrolabe added at c079a70af8
Vendored
-25
@@ -1,25 +0,0 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.10.1"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
|
||||
# Update Astrolabe-specific files only
|
||||
version_files = [
|
||||
"appinfo/info.xml:<version>",
|
||||
"package.json:version"
|
||||
]
|
||||
|
||||
# Ignore tags from other components
|
||||
ignored_tag_formats = [
|
||||
"v*", # MCP server tags
|
||||
"nextcloud-mcp-server-*", # Helm chart tags
|
||||
]
|
||||
|
||||
# Filter commits by scope
|
||||
[tool.commitizen.customize]
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:\\s.+"
|
||||
message_template = "{{change_type}}(astrolabe): {{message}}"
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'@nextcloud',
|
||||
],
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
},
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
/vendor/
|
||||
/vendor-bin/*/vendor/
|
||||
|
||||
/.php-cs-fixer.cache
|
||||
/tests/.phpunit.cache
|
||||
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
js/
|
||||
css/
|
||||
.phpunit.cache/
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
20
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once './vendor-bin/cs-fixer/vendor/autoload.php';
|
||||
|
||||
use Nextcloud\CodingStandard\Config;
|
||||
|
||||
$config = new Config();
|
||||
$config
|
||||
->getFinder()
|
||||
->notPath('build')
|
||||
->notPath('l10n')
|
||||
->notPath('node_modules')
|
||||
->notPath('src')
|
||||
->notPath('vendor')
|
||||
->in(__DIR__);
|
||||
|
||||
return $config;
|
||||
Vendored
-573
@@ -1,573 +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.10.1 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## astrolabe-v0.10.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## astrolabe-v0.9.0 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## astrolabe-v0.8.3 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## astrolabe-v0.8.2 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
||||
|
||||
## astrolabe-v0.8.1 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||
- **ci**: bump helm chart version when MCP appVersion changes
|
||||
|
||||
## astrolabe-v0.8.0 (2026-01-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## astrolabe-v0.7.2 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
|
||||
## astrolabe-v0.7.1 (2025-12-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||
- **mcp**: Move all imports to the top of modules
|
||||
|
||||
## astrolabe-v0.7.0 (2025-12-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- Remove URL rewriting in favor of proper nextcloud config
|
||||
- **helm**: migrate to new environment variable naming convention
|
||||
- Migrate to vue 3
|
||||
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
|
||||
- **helm**: add support for multi-user BasicAuth mode
|
||||
|
||||
### Fix
|
||||
|
||||
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
|
||||
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
|
||||
- **auth**: Skip issuer validation for management API tokens
|
||||
- Use settings.enable_offline_access for env var consolidation
|
||||
- Add required config.py attributes
|
||||
- **docker**: remove overwritehost to fix container-to-container DCR
|
||||
- **deps**: update dependency @nextcloud/vue to v9
|
||||
- **deps**: update dependency vue to v3
|
||||
- **helm**: set OIDC client env vars when using existingSecret
|
||||
- **helm**: trigger chart release workflow on helm chart tags
|
||||
- **helm**: address PR #447 reviewer feedback
|
||||
- **helm**: include MCP server version bumps in changelog pattern
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
||||
|
||||
## astrolabe-v0.6.0 (2025-12-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- **config**: enable DCR for multi-user BasicAuth with offline access
|
||||
- **astrolabe**: implement app password provisioning for multi-user background sync
|
||||
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
|
||||
|
||||
## astrolabe-v0.5.0 (2025-12-20)
|
||||
|
||||
### Feat
|
||||
|
||||
- **auth**: add multi-user BasicAuth pass-through mode
|
||||
- **astrolabe**: add dynamic MCP server configuration for testing
|
||||
|
||||
### Fix
|
||||
|
||||
- **config**: address reviewer feedback
|
||||
|
||||
### Refactor
|
||||
|
||||
- **config**: centralize configuration validation and simplify startup
|
||||
|
||||
## astrolabe-v0.4.4 (2025-12-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: screenshots in info.xml
|
||||
|
||||
## astrolabe-v0.4.3 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: screenshots in info.xml
|
||||
|
||||
## astrolabe-v0.4.2 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Update screenshots
|
||||
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
|
||||
|
||||
## astrolabe-v0.4.1 (2025-12-19)
|
||||
|
||||
## astrolabe-v0.4.0 (2025-12-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **ci**: add --increment flag to bump scripts for manual version control
|
||||
|
||||
## astrolabe-v0.3.2 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add contents:write permission to appstore workflow
|
||||
|
||||
## astrolabe-v0.3.1 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: update commitizen pattern to properly update info.xml version
|
||||
|
||||
## astrolabe-v0.3.0 (2025-12-19)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
|
||||
- **astrolabe**: info.xml
|
||||
|
||||
## astrolabe-v0.2.1 (2025-12-19)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- MCP server now bumps for ANY conventional commit except
|
||||
those explicitly scoped to helm or astrolabe.
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: push all tags explicitly in bump workflow
|
||||
- **ci**: make MCP server default bump target for all non-scoped commits
|
||||
- **ci**: restrict docker build to MCP server tags only
|
||||
- **ci**: correct appstore-push-action version to v1.0.4
|
||||
|
||||
## astrolabe-v0.2.0 (2025-12-19)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- Search algorithms now require Qdrant to be populated.
|
||||
Vector sync must be enabled and documents indexed for search to work.
|
||||
- All OAuth deployments must be reconfigured to specify
|
||||
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
|
||||
choose between multi-audience or token exchange mode.
|
||||
- FASTMCP_-prefixed env vars have been replaced by CLI
|
||||
arguments. Refer to the README for updated usage.
|
||||
|
||||
### Feat
|
||||
|
||||
- **ci**: implement monorepo-aware version bumping workflow
|
||||
- **astrolabe**: add Nextcloud App Store deployment automation
|
||||
- configure commitizen monorepo with independent versioning
|
||||
- add Alembic database migration system
|
||||
- make chunk modal title clickable link to documents
|
||||
- add native Plotly hover styling for clickable points
|
||||
- add click interactivity to Plotly 3D scatter chart
|
||||
- improve chunk viewer with fixed navigation and markdown rendering
|
||||
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
|
||||
- **auth**: implement refresh token rotation for Nextcloud OIDC
|
||||
- **astrolabe**: enhance unified search and add webhook management
|
||||
- **astrolabe**: add webhook management UI to admin settings
|
||||
- **astrolabe**: add OAuth token refresh and webhook presets
|
||||
- **search**: add file_path metadata and chunk offsets to search results
|
||||
- **astrolabe**: use proper icons and thumbnails in unified search
|
||||
- **astrolabe**: add admin search settings and enhanced UI
|
||||
- **astrolabe**: add unified search provider with clickable file links
|
||||
- **astrolabe**: add 3D PCA visualization for semantic search
|
||||
- **astrolabe**: add Nextcloud PHP app for MCP server management
|
||||
- **vector-sync**: enable background sync in OAuth mode
|
||||
- **vector**: add Deck card vector search with visualization support
|
||||
- **vector-viz**: add news_item support for links and chunk expansion
|
||||
- add MCP tool annotations for enhanced UX
|
||||
- **news**: add Nextcloud News app integration
|
||||
- Add tag management methods to WebDAV client
|
||||
- Add OpenAI provider support for embeddings and generation
|
||||
- Add Smithery CLI deployment support
|
||||
- Implement ADR-016 Smithery stateless deployment mode
|
||||
- Add context expansion to semantic search with chunk overlap removal
|
||||
- Use Ollama native batch API in embed_batch()
|
||||
- Implement Qdrant placeholder state management
|
||||
- Switch files to use numeric IDs with file_path resolution
|
||||
- Implement per-chunk vector visualization with context expansion
|
||||
- Improve vector visualization with static assets and fixes
|
||||
- Redesign UI to match Nextcloud ecosystem aesthetic
|
||||
- Replace custom document chunker with LangChain MarkdownTextSplitter
|
||||
- **viz**: Add dual-score display and improve UI controls
|
||||
- add configurable fusion algorithms for BM25 hybrid search
|
||||
- add chunk position tracking to vector indexing and search
|
||||
- add vector viz template and chunk context endpoint
|
||||
- add unified provider architecture with Amazon Bedrock support
|
||||
- add concurrent uploads and --force flag to upload command
|
||||
- implement RAG evaluation framework with CLI tooling
|
||||
- Add OpenTelemetry tracing to @instrument_tool decorator
|
||||
- Implement BM25 hybrid search with native Qdrant RRF fusion
|
||||
- Normalize hybrid search RRF scores to 0-1 range
|
||||
- Enhance vector visualization UI and parallelize search verification
|
||||
- Add Vector Viz tab to app home page
|
||||
- Add vector visualization pane with multi-select document types
|
||||
- Implement custom PCA to remove sklearn dependency
|
||||
- Add multi-document Protocol with cross-app search support
|
||||
- Update nc_semantic_search tool with algorithm selection
|
||||
- Implement unified search algorithm module
|
||||
- Enable SSE transport for mcp service and update test fixtures
|
||||
- Complete Phase 5 - Instrument all 93 MCP tools
|
||||
- Add instrumentation decorator and apply to notes tools (Phase 5)
|
||||
- Add OAuth token and database metrics (Phases 3-4)
|
||||
- Add metrics instrumentation for queue, health, and database operations
|
||||
- Add Grafana dashboard and vector sync metric instrumentation
|
||||
- **ollama**: Pull model on startup if not available in ollama
|
||||
- add dynamic vector sync status updates with htmx polling
|
||||
- add webhook management UI and BeforeNodeDeletedEvent support
|
||||
- validate Nextcloud webhook schemas and document findings
|
||||
- skip tracing for health and metrics endpoints
|
||||
- **helm**: Add document chunking configuration
|
||||
- **vector**: Add configurable chunk size and overlap for document embedding
|
||||
- **vector**: Support multiple embedding models with auto-generated collection names
|
||||
- **helm**: Add observability support with ServiceMonitor and Grafana dashboard
|
||||
- **observability**: Add comprehensive monitoring with Prometheus and OpenTelemetry
|
||||
- **helm**: add Qdrant local mode support with three deployment options [skip ci]
|
||||
- add Qdrant local mode support with in-memory and persistent storage
|
||||
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
||||
- implement MCP sampling for semantic search RAG (ADR-008)
|
||||
- add optional vector database and semantic search to helm chart
|
||||
- add vector sync processing status to /user/page endpoint
|
||||
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
||||
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
||||
- add real elicitation integration test with python-sdk MCP client
|
||||
- unify session architecture and enhance login status visibility
|
||||
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
|
||||
- add scope protection to OAuth provisioning tools
|
||||
- enable authorization services for token exchange in Keycloak
|
||||
- implement scope-based audience mapping and RFC 9728 support
|
||||
- integrate token exchange into MCP server application
|
||||
- implement RFC 8693 Standard Token Exchange for Keycloak
|
||||
- Add userinfo route/page
|
||||
- add browser-based user info page with separate OAuth flow
|
||||
- Implement ADR-004 Progressive Consent foundation (partial)
|
||||
- Complete ADR-004 Progressive Consent OAuth flows implementation
|
||||
- Implement ADR-004 Progressive Consent foundation components
|
||||
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
|
||||
- Auto-configure impersonation role in Keycloak realm import
|
||||
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
|
||||
- Add Keycloak external IdP integration with custom scopes
|
||||
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
|
||||
- Add Keycloak OAuth provider support with refresh token storage
|
||||
- **server**: Add /live & /health endpoints
|
||||
- Initialize helm chart
|
||||
- Add text processing background worker for telling client about progress
|
||||
- **auth**: Add support for client registration deletion
|
||||
- Split read/write scopes into app:read/write scopes
|
||||
- Enable token introspection for opaque tokens
|
||||
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||
- Initialize JWT-scoped tools
|
||||
- **caldav**: Add support for tasks
|
||||
- **webdav**: Add search and list favorite response tools
|
||||
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
|
||||
- Add Groups API client
|
||||
- add sharing API client and server tools
|
||||
- **server**: Experimental support for OAuth2/OIDC authentication
|
||||
- **users**: Initialize user API client
|
||||
- **server**: Add support for `streamable-http` transport type
|
||||
- Add WebDAV resource copy functionality
|
||||
- Add WebDAV resource move/rename functionality
|
||||
- **deck**: Add support for stack, cards, labels
|
||||
- **deck**: Initialize Deck app client/server
|
||||
- **cli**: Replace `mcp run` with click CLI and runtime options
|
||||
- **client**: Preserve fields when modifying contacts/calendar resources
|
||||
- **server**: Add structured output to all tool/resource output
|
||||
- **contacts**: Initialize Contacts App
|
||||
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
||||
- Update webdav client create_directory method to handle recursive directories
|
||||
- **webdav**: add complete file system support
|
||||
- Add TablesClient and associated tools
|
||||
- Switch to using async client
|
||||
- **notes**: Add append to note functionality
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: improve versioning and error handling
|
||||
- **ci**: address critical workflow and validation issues
|
||||
- **astrolabe**: address code review feedback
|
||||
- **security**: address critical security issues from PR #401 code review
|
||||
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
|
||||
- **astrolabe**: revert invalid files_pdfviewer URL for file links
|
||||
- resolve type checking warnings for CI
|
||||
- move Alembic to package submodule for Docker compatibility
|
||||
- update unified search results to match chunk viz display
|
||||
- **astrolabe**: handle OAuth refresh token rotation
|
||||
- address critical code review issues (4 fixes)
|
||||
- resolve CI linting issues for Astroglobe
|
||||
- **news**: revert get_item() to use get_items() + filter
|
||||
- Disable DNS rebinding protection for containerized deployments
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
- address PR review feedback
|
||||
- Update lockfile
|
||||
- Revert mcp version <1.23
|
||||
- resolve all type checking errors (8 errors fixed)
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
- **deps**: update dependency pillow to v12
|
||||
- Add rate limit retry logic to OpenAI provider
|
||||
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
|
||||
- **smithery**: Enable JSON response format for scanner compatibility
|
||||
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
|
||||
- **smithery**: Use container runtime pattern for config discovery
|
||||
- Add Smithery lifespan and auth mode detection
|
||||
- Use alpha_composite for proper RGBA highlight blending
|
||||
- Remove pymupdf.layout.activate() to fix page_chunks behavior
|
||||
- Centralize PDF processing and generate separate images per chunk
|
||||
- Set is_placeholder=False in processor to fix search filtering
|
||||
- Increase placeholder staleness threshold to 5x scan interval
|
||||
- Add placeholder staleness check to prevent duplicate processing
|
||||
- Use empty SparseVector instead of None for placeholders
|
||||
- Return empty array instead of null for query_coords when no results
|
||||
- Align PDF text extraction between indexing and context expansion
|
||||
- Update models and viz to use int-only doc_id
|
||||
- Reconstruct full content for notes to match indexed offsets
|
||||
- Add async/await, PDF metadata, and type safety fixes
|
||||
- **deps**: update dependency mcp to >=1.22,<1.23
|
||||
- Improve 3D plot rendering with explicit dimensions and window resize support
|
||||
- Preserve 3D plot camera and improve documentation
|
||||
- Preserve 3D plot camera position and fix CSS loading
|
||||
- prevent infinite loop in DocumentChunker with position tracking
|
||||
- Relax SearchResult validation to support DBSF fusion scores > 1.0
|
||||
- suppress Starlette middleware type warnings in ty checker
|
||||
- download qrels from BEIR ZIP instead of HuggingFace
|
||||
- Handle named vectors in visualization and semantic search
|
||||
- Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
|
||||
- Update viz routes to use BM25 hybrid search after refactor
|
||||
- Reorder tabs and fix viz pane session access
|
||||
- Use NEXTCLOUD_OIDC_CLIENT_ID/SECRET env vars consistently
|
||||
- return all notes when search query is empty
|
||||
- Move grafana_folder from labels to annotations
|
||||
- add dynamic dimension detection for Ollama embedding models
|
||||
- improve webapp tab UI with CSS Grid and viewport-filling container
|
||||
- add retry logic for ETag conflicts in category change test
|
||||
- optimize Notes API pagination with pruneBefore parameter
|
||||
- Support in-memory Qdrant for CI testing
|
||||
- **helm**: Set default strategy to Recreate
|
||||
- **observability**: isolate metrics endpoint to dedicated port
|
||||
- **readiness**: Only check external Qdrant in network mode
|
||||
- **vector**: Handle missing 'modified' field in notes gracefully
|
||||
- **ci**: Use helm dependency build instead of update to use Chart.lock
|
||||
- **helm**: update Qdrant dependency condition to match new mode structure
|
||||
- **ci**: add Helm repository setup to chart release workflow
|
||||
- implement deletion grace period and vector sync status tool
|
||||
- remove unnecessary urllib3<2.0 constraint
|
||||
- integrate vector sync tasks with Starlette lifespan for streamable-http
|
||||
- **deps**: update dependency mcp to >=1.21,<1.22
|
||||
- Consolidate OAuth callbacks and implement PKCE for all flows
|
||||
- Implement proper OAuth resource parameters and PRM-based discovery
|
||||
- Simplify token verifier to be RFC 7519 compliant
|
||||
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
|
||||
- Correct OAuth token audience validation for multi-audience mode
|
||||
- **deps**: update dependency mcp to >=1.20,<1.21
|
||||
- add missing await for get_nextcloud_client in capabilities resource
|
||||
- use valid Fernet encryption keys in token exchange tests
|
||||
- accept resource URL in token audience for Nextcloud JWT tokens
|
||||
- remove token-exchange-nextcloud scope and accept tokens without audience
|
||||
- move audience mapper from scope to nextcloud-mcp-server client
|
||||
- move token-exchange-nextcloud from default to optional scopes
|
||||
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
|
||||
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
|
||||
- correct OAuth token audience validation using RFC 8707 resource parameter
|
||||
- remove remaining references to deleted oauth_callback and oauth_token
|
||||
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
|
||||
- browser OAuth userinfo endpoint and refresh token rotation
|
||||
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
|
||||
- make provisioning checks opt-in (default false)
|
||||
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
|
||||
- Complete Keycloak external IdP integration with all tests passing
|
||||
- Complete Keycloak external IdP integration with all tests passing
|
||||
- Update DCR token_type tests for OIDC app changes
|
||||
- **helm**: Remove image tag overide
|
||||
- **helm**: Update helm chart with extraArgs
|
||||
- Update helm chart variables
|
||||
- **helm**: Update helm version with release
|
||||
- **helm**: Update helm version with release
|
||||
- **helm**: Update helm version with release
|
||||
- **helm**: Update helm version with release
|
||||
- Trigger release
|
||||
- Add support for RFC 7592 client registration and deletion
|
||||
- Update webdav models for proper serialization
|
||||
- **deps**: update dependency mcp to >=1.19,<1.20
|
||||
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||
- **caldav**: Fix caldav search() due to missing todos
|
||||
- **caldav**: Check that calendar exists after creation to avoid race condition
|
||||
- **caldav**: Properly parse datetimes as vDDDTypes
|
||||
- Increase HTTP client timeout to 30s
|
||||
- Handle RequestError in mcp tools
|
||||
- **deps**: update dependency mcp to >=1.18,<1.19
|
||||
- **deps**: update dependency pillow to v12
|
||||
- **oauth**: Remove the option to force_register new clients
|
||||
- Update user/groups API to OCS v2
|
||||
- **deps**: update dependency mcp to >=1.17,<1.18
|
||||
- **deps**: update dependency mcp to >=1.16,<1.17
|
||||
- **deps**: update dependency mcp to >=1.15,<1.16
|
||||
- **docker**: Provide --host 0.0.0.0 in default docker image
|
||||
- **deps**: update dependency mcp to >=1.13,<1.14
|
||||
- **server**: Replace ErrorResponses with standard McpErrors
|
||||
- **notes**: Include ETags in responses to avoid accidently updates
|
||||
- **notes**: Remove note contents from responses to reduce token usage
|
||||
- **model**: Serialize timestamps in RFC3339 format
|
||||
- **client**: Use paging to fetch all notes
|
||||
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||
- **calendar**: Fix iCalendar date vs datetime format
|
||||
- **calendar**: Remove try/except in calendar API
|
||||
- apply ruff formatting to pass CI checks
|
||||
- **calendar**: address PR feedback from maintainer
|
||||
- apply ruff formatting to test_webdav_operations.py
|
||||
- **deps**: update dependency mcp to >=1.10,<1.11
|
||||
- update tests
|
||||
- Commitizen release process
|
||||
- Do not update dependencies when running in Dockerfile
|
||||
- Configure logging
|
||||
- Limit search results to notes with score > 0.5
|
||||
- Install deps before checking service
|
||||
- **deps**: update dependency mcp to >=1.9,<1.10
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: extract PDF viewer to dedicated component
|
||||
- **astrolabe**: reframe UI as semantic search service
|
||||
- **news**: simplify vector sync to fetch all items
|
||||
- Move background tasks to server lifespan and deprecate SSE transport
|
||||
- Simplify PDF text extraction with single to_markdown call
|
||||
- migrate asyncio to anyio for consistent structured concurrency
|
||||
- replace httpx client with NextcloudClient in upload command
|
||||
- Optimize Nextcloud access verification with centralized filtering
|
||||
- Make all search algorithms query Qdrant payload, not Nextcloud
|
||||
- move webapp from /user/page to /app
|
||||
- consolidate database storage for webhooks and OAuth tokens
|
||||
- simplify OpenTelemetry tracing configuration
|
||||
- migrate vector sync from asyncio.Queue to anyio memory object streams
|
||||
- update to Qdrant query_points API and fix Playwright Keycloak login
|
||||
- Eliminate duplicate validation logic in UnifiedTokenVerifier
|
||||
- integrate token exchange into unified get_client() pattern
|
||||
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
|
||||
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
|
||||
- Unify OAuth configuration to be provider-agnostic
|
||||
- Transform document parsing into pluggable processor architecture
|
||||
- Update JWT client to use DCR, re-enable tool filtering
|
||||
- Migrate from internal CalendarClient to caldav library
|
||||
- Unify logging & remove factory deployment
|
||||
- Add tools for all resources to enable tool-only workflows
|
||||
- Add `http` to --transport option
|
||||
- Use _make_request where available
|
||||
- **calendar**: optimize logging for production readiness
|
||||
- Modularize NC and Notes app client
|
||||
|
||||
### Perf
|
||||
|
||||
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
|
||||
- **news**: use direct API endpoint for get_item()
|
||||
- Optimize vector viz search performance
|
||||
- Optimize PDF processing with parallel extraction and single-render highlights
|
||||
- Eliminate double-fetching in semantic search sampling
|
||||
- fix vector viz search performance and visual encoding
|
||||
- make note deletion concurrent in upload --force
|
||||
- Exclude vector-sync status polling from distributed tracing
|
||||
- **notes**: Improve notes search performance using async iterators
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
In the Nextcloud community, participants from all over the world come together to create Free Software for a free internet. This is made possible by the support, hard work and enthusiasm of thousands of people, including those who create and use Nextcloud software.
|
||||
|
||||
Our code of conduct offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other.
|
||||
|
||||
The Code of Conduct is shared by all contributors and users who engage with the Nextcloud team and its community services. It presents a summary of the shared values and “common sense” thinking in our community.
|
||||
|
||||
You can find our full code of conduct on our website: https://nextcloud.com/code-of-conduct/
|
||||
|
||||
Please, keep our CoC in mind when you contribute! That way, everyone can be a part of our community in a productive, positive, creative and fun way.
|
||||
Vendored
-661
@@ -1,661 +0,0 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
Vendored
-101
@@ -1,101 +0,0 @@
|
||||
# Nextcloud App Store Release Makefile for Astrolabe
|
||||
#
|
||||
# Based on: https://nextcloudappstore.readthedocs.io/en/latest/developer.html
|
||||
|
||||
app_name=astrolabe
|
||||
project_dir=$(CURDIR)
|
||||
build_dir=$(project_dir)/build
|
||||
appstore_dir=$(build_dir)/artifacts
|
||||
package_name=$(appstore_dir)/$(app_name)
|
||||
cert_dir=$(HOME)/.nextcloud/certificates
|
||||
|
||||
# Nextcloud server path (configurable via environment variable)
|
||||
server_dir?=../../server
|
||||
occ=$(server_dir)/occ
|
||||
|
||||
# Signing
|
||||
private_key=$(cert_dir)/$(app_name).key
|
||||
certificate=$(cert_dir)/$(app_name).crt
|
||||
sign_cmd=php $(occ) integrity:sign-app --privateKey=$(private_key) --certificate=$(certificate)
|
||||
|
||||
# Clean build artifacts
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf $(build_dir)
|
||||
|
||||
# Validate required dependencies
|
||||
.PHONY: validate-deps
|
||||
validate-deps:
|
||||
@command -v composer >/dev/null 2>&1 || { echo "Error: composer not found. Install from https://getcomposer.org/"; exit 1; }
|
||||
@command -v npm >/dev/null 2>&1 || { echo "Error: npm not found. Install Node.js from https://nodejs.org/"; exit 1; }
|
||||
@command -v php >/dev/null 2>&1 || { echo "Error: php not found. Install PHP 8.1 or higher."; exit 1; }
|
||||
@echo "✓ All dependencies found"
|
||||
|
||||
# Install PHP and Node dependencies
|
||||
.PHONY: install-deps
|
||||
install-deps: validate-deps
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
|
||||
# Build production frontend assets
|
||||
.PHONY: build-frontend
|
||||
build-frontend:
|
||||
npm run build
|
||||
|
||||
# Run all linters
|
||||
.PHONY: lint
|
||||
lint:
|
||||
composer lint
|
||||
composer cs:check
|
||||
npm run lint
|
||||
npm run stylelint
|
||||
|
||||
# Assemble app files into build directory (exclude dev files)
|
||||
.PHONY: assemble
|
||||
assemble: clean install-deps build-frontend
|
||||
mkdir -p $(package_name)
|
||||
# Copy app files
|
||||
rsync -av \
|
||||
--exclude='.git*' \
|
||||
--exclude='build/' \
|
||||
--exclude='tests/' \
|
||||
--exclude='node_modules/' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.github/' \
|
||||
--exclude='composer.json' \
|
||||
--exclude='composer.lock' \
|
||||
--exclude='package.json' \
|
||||
--exclude='package-lock.json' \
|
||||
--exclude='vite.config.js' \
|
||||
--exclude='.eslintrc.js' \
|
||||
--exclude='.php-cs-fixer.*' \
|
||||
--exclude='psalm.xml' \
|
||||
--exclude='*.iml' \
|
||||
--exclude='.idea' \
|
||||
--exclude='src/' \
|
||||
./ $(package_name)/
|
||||
|
||||
# Validate signing prerequisites
|
||||
.PHONY: validate-signing
|
||||
validate-signing:
|
||||
@test -f $(occ) || { echo "Error: Nextcloud server not found at $(server_dir)"; echo "Set server_dir variable: make appstore server_dir=/path/to/server"; exit 1; }
|
||||
@test -f $(private_key) || { echo "Error: Private key not found at $(private_key)"; exit 1; }
|
||||
@test -f $(certificate) || { echo "Error: Certificate not found at $(certificate)"; exit 1; }
|
||||
@echo "✓ Signing prerequisites validated"
|
||||
|
||||
# Create signed release tarball for App Store
|
||||
.PHONY: appstore
|
||||
appstore: assemble validate-signing
|
||||
# Sign the app
|
||||
$(sign_cmd) --path=$(package_name)
|
||||
# Create tarball
|
||||
cd $(appstore_dir) && \
|
||||
tar -czf $(app_name).tar.gz $(app_name)
|
||||
# Show package info
|
||||
@echo "========================================="
|
||||
@echo "App package created:"
|
||||
@echo " $(appstore_dir)/$(app_name).tar.gz"
|
||||
@echo ""
|
||||
@echo "Signature:"
|
||||
@cat $(package_name)/appinfo/signature.json | head -n 5
|
||||
@echo "========================================="
|
||||
Vendored
-223
@@ -1,223 +0,0 @@
|
||||
# Astrolabe: The Intelligence Layer for Nextcloud
|
||||
|
||||
Your Nextcloud instance is more than just a bucket for files—it is a galaxy of ideas, projects, and knowledge. But until now, you've been navigating it in the dark, relying on exact filenames and rigid keywords.
|
||||
|
||||
**It's time to turn the lights on.**
|
||||
|
||||
Astrolabe is a fully integrated Nextcloud application that transforms your server into a semantic intelligence engine. It doesn't just store your data; it **maps it, understands it, and connects it** to the AI future.
|
||||
|
||||
---
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### 🔍 Search That Actually Understands
|
||||
|
||||
Forget clunky external tools. Astrolabe registers as a **native Nextcloud Search Provider**.
|
||||
|
||||
- **Seamless**: Lives right in the standard Nextcloud search bar you already use
|
||||
- **Semantic**: Type "marketing strategy for the winter launch" and Astrolabe finds the relevant PDFs, chat logs, and text files—even if those exact words never appear in the document
|
||||
- **Intelligent**: It finds the **concept**, not just the string
|
||||
|
||||
### 🌌 Visualize Your Data Universe
|
||||
|
||||
Data shouldn't just be a list; it should be a landscape. Astrolabe includes a dedicated dashboard that visualizes your document chunks as a **3D PCA Vector Plot**.
|
||||
|
||||
- **See the Connections**: View your data as a constellation of points in 3D space
|
||||
- **Explore Clusters**: Visually identify how your documents relate to one another
|
||||
- **True "Astroglobe" Experience**: Rotate, zoom, and fly through your semantic universe just like navigators once studied the stars
|
||||
|
||||
### 🤖 Power Your AI Agents
|
||||
|
||||
Astrolabe isn't just for humans; it's for your AI agents, too. It acts as a bridge, running a **Model Context Protocol (MCP) Server** directly from your Nextcloud.
|
||||
|
||||
- **Bring Your Own Brain**: Connect external AI clients (like Claude Desktop or Cursor) to your private data
|
||||
- **Agentic Workflows**: Enable LLMs to "sample" your files, read content, and perform complex reasoning tasks using your Nextcloud data as the source of truth
|
||||
- **Private & Secure**: Your data never leaves your infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From App Store (Recommended)
|
||||
|
||||
1. Open **Apps** in your Nextcloud
|
||||
2. Search for **"Astrolabe"**
|
||||
3. Click **"Download and enable"**
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Clone into your Nextcloud apps directory
|
||||
cd /path/to/nextcloud/apps
|
||||
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
|
||||
cd nextcloud-mcp-server/third_party/astrolabe
|
||||
|
||||
# Install dependencies
|
||||
composer install
|
||||
|
||||
# Enable the app
|
||||
php /path/to/nextcloud/occ app:enable astrolabe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure the MCP Server URL
|
||||
|
||||
Add this to your Nextcloud `config/config.php`:
|
||||
|
||||
```php
|
||||
'mcp_server_url' => 'http://localhost:8000',
|
||||
```
|
||||
|
||||
### 2. Start the MCP Server
|
||||
|
||||
The MCP server handles semantic search and AI agent connections. See the [MCP Server Installation Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md) for details.
|
||||
|
||||
Quick start with Docker:
|
||||
|
||||
```bash
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
```
|
||||
|
||||
### 3. Authorize Access
|
||||
|
||||
1. Go to **Settings → Personal → Astrolabe**
|
||||
2. Click **"Authorize Access"**
|
||||
3. Sign in to your identity provider
|
||||
4. Approve the requested permissions
|
||||
|
||||
That's it! You can now use semantic search and explore your data universe.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Personal Settings
|
||||
|
||||
Located in: **Settings → Personal → Astrolabe**
|
||||
|
||||
- **Semantic Search Dashboard**: Interactive 3D visualization of your document chunks
|
||||
- **OAuth Authorization**: Authorize Nextcloud to access the MCP server on your behalf
|
||||
- **Session Information**: View connection status and authentication details
|
||||
- **Connection Management**: Revoke access or disconnect when needed
|
||||
|
||||
### Admin Settings
|
||||
|
||||
Located in: **Settings → Administration → Astrolabe**
|
||||
|
||||
- **Server Status**: Monitor MCP server health and version
|
||||
- **Vector Sync Metrics**: See how many documents are indexed, processing rates, and sync status
|
||||
- **Configuration Validation**: Verify server URL and connectivity
|
||||
- **Feature Availability**: Check which capabilities are enabled
|
||||
|
||||
### Unified Search Integration
|
||||
|
||||
Astrolabe integrates directly with Nextcloud's **Unified Search**:
|
||||
|
||||
- Available in the top search bar across all Nextcloud pages
|
||||
- Returns semantic matches ranked by relevance
|
||||
- Shows excerpts from matching documents
|
||||
- Links directly to source files in Nextcloud
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### For Individuals
|
||||
|
||||
- **Research**: Find all notes related to a project, even if they use different terminology
|
||||
- **Organization**: Discover forgotten documents related to your current work
|
||||
- **Exploration**: Visualize how your knowledge connects and evolves over time
|
||||
|
||||
### For Teams
|
||||
|
||||
- **Knowledge Discovery**: Surface institutional knowledge that would otherwise stay buried
|
||||
- **Collaboration**: Find team members working on similar problems
|
||||
- **Documentation**: Locate relevant documentation without knowing exact titles
|
||||
|
||||
### For Developers
|
||||
|
||||
- **AI Integration**: Connect Claude Desktop, Cursor, or other MCP clients to Nextcloud
|
||||
- **RAG Workflows**: Build retrieval-augmented generation pipelines on your private data
|
||||
- **Custom Agents**: Use the MCP protocol to create specialized workflows
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Nextcloud**: Version 30 or later
|
||||
- **MCP Server**: Running instance (Docker recommended)
|
||||
- **Identity Provider**: OAuth provider supporting PKCE (Nextcloud OIDC Login or Keycloak)
|
||||
- **Vector Sync**: Optional but recommended for semantic search (see [configuration guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md))
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### User Guides
|
||||
|
||||
- [MCP Server Installation](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md)
|
||||
- [Configuration Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md)
|
||||
- [OAuth Setup](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/oauth-setup.md)
|
||||
|
||||
### Technical Details
|
||||
|
||||
- [ADR-018: Nextcloud PHP App Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-018-nextcloud-php-app-for-settings-ui.md)
|
||||
- [OAuth PKCE Flow Details](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-004-progressive-consent.md)
|
||||
- [Vector Sync Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-002-vector-sync-authentication.md)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
**Cannot connect to MCP server:**
|
||||
- Verify `mcp_server_url` in `config.php`
|
||||
- Check MCP server is running: `curl http://localhost:8000/health`
|
||||
- Review logs: `tail -f data/nextcloud.log`
|
||||
|
||||
**Authorization fails:**
|
||||
- Ensure MCP server is in OAuth mode
|
||||
- Verify identity provider is accessible
|
||||
- Check browser console for errors
|
||||
|
||||
**Semantic search returns no results:**
|
||||
- Verify vector sync is enabled and running
|
||||
- Check indexing status in Admin settings
|
||||
- Allow time for initial indexing to complete
|
||||
|
||||
For more help, see the [Troubleshooting Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/troubleshooting.md).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Here's how to get started:
|
||||
|
||||
1. Fork the [nextcloud-mcp-server repository](https://github.com/cbcoutinho/nextcloud-mcp-server)
|
||||
2. Create a feature branch: `git checkout -b feature/your-feature`
|
||||
3. Make your changes in `third_party/astrolabe/`
|
||||
4. Test thoroughly with a local Nextcloud instance
|
||||
5. Submit a pull request
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
**Astrolabe** is developed as part of the [Nextcloud MCP Server](https://github.com/cbcoutinho/nextcloud-mcp-server) project, bringing the power of semantic search and AI integration to Nextcloud.
|
||||
|
||||
**Author**: Chris Coutinho <chris@coutinho.io>
|
||||
|
||||
---
|
||||
|
||||
**Your Data. Mapped. Visualized. Connected.**
|
||||
|
||||
Install Astrolabe for Nextcloud.
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||
<id>astrolabe</id>
|
||||
<name>Astrolabe</name>
|
||||
<summary>AI-powered semantic search across your Nextcloud</summary>
|
||||
<description>< for configuration details.
|
||||
]]></description>
|
||||
<version>0.10.1</version>
|
||||
<licence>agpl</licence>
|
||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||
<namespace>Astrolabe</namespace>
|
||||
<category>ai</category>
|
||||
<bugs>https://github.com/cbcoutinho/nextcloud-mcp-server/issues</bugs>
|
||||
<repository type="git">https://github.com/cbcoutinho/nextcloud-mcp-server</repository>
|
||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/02-semantic-search-with-plot.png?raw=1</screenshot>
|
||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1</screenshot>
|
||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1</screenshot>
|
||||
<dependencies>
|
||||
<nextcloud min-version="31" max-version="32"/>
|
||||
</dependencies>
|
||||
<settings>
|
||||
<personal>OCA\Astrolabe\Settings\Personal</personal>
|
||||
<personal-section>OCA\Astrolabe\Settings\PersonalSection</personal-section>
|
||||
<admin>OCA\Astrolabe\Settings\Admin</admin>
|
||||
<admin-section>OCA\Astrolabe\Settings\AdminSection</admin-section>
|
||||
</settings>
|
||||
<navigations>
|
||||
<navigation>
|
||||
<id>astrolabe</id>
|
||||
<name>Astrolabe</name>
|
||||
<route>astrolabe.page.index</route>
|
||||
<icon>app.svg</icon>
|
||||
<type>link</type>
|
||||
</navigation>
|
||||
</navigations>
|
||||
<background-jobs>
|
||||
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
||||
</background-jobs>
|
||||
</info>
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Routes configuration for MCP Server UI app.
|
||||
*
|
||||
* Defines URL routes for OAuth flow and form handlers.
|
||||
*/
|
||||
|
||||
return [
|
||||
'routes' => [
|
||||
// OAuth routes
|
||||
[
|
||||
'name' => 'oauth#initiateOAuth',
|
||||
'url' => '/oauth/authorize',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'oauth#oauthCallback',
|
||||
'url' => '/oauth/callback',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'oauth#disconnect',
|
||||
'url' => '/oauth/disconnect',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
|
||||
// API routes (form handlers)
|
||||
[
|
||||
'name' => 'api#revokeAccess',
|
||||
'url' => '/api/revoke',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
|
||||
// Background sync credentials routes
|
||||
[
|
||||
'name' => 'credentials#storeAppPassword',
|
||||
'url' => '/api/v1/background-sync/credentials',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#getCredentials',
|
||||
'url' => '/api/v1/background-sync/credentials/{userId}',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#deleteCredentials',
|
||||
'url' => '/api/v1/background-sync/credentials/revoke',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'credentials#getStatus',
|
||||
'url' => '/api/v1/background-sync/status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
|
||||
// Vector search API routes
|
||||
[
|
||||
'name' => 'api#search',
|
||||
'url' => '/api/search',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#vectorStatus',
|
||||
'url' => '/api/vector-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#chunkContext',
|
||||
'url' => '/api/chunk-context',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#pdfPreview',
|
||||
'url' => '/api/pdf-preview',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
|
||||
// Admin settings routes
|
||||
[
|
||||
'name' => 'api#serverStatus',
|
||||
'url' => '/api/admin/server-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#adminVectorStatus',
|
||||
'url' => '/api/admin/vector-status',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#saveSearchSettings',
|
||||
'url' => '/api/admin/search-settings',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
|
||||
// Webhook management routes (admin only)
|
||||
[
|
||||
'name' => 'api#getWebhookPresets',
|
||||
'url' => '/api/admin/webhooks/presets',
|
||||
'verb' => 'GET',
|
||||
],
|
||||
[
|
||||
'name' => 'api#enableWebhookPreset',
|
||||
'url' => '/api/admin/webhooks/presets/{presetId}/enable',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'api#disableWebhookPreset',
|
||||
'url' => '/api/admin/webhooks/presets/{presetId}/disable',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
],
|
||||
];
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"name": "nextcloud/astrolabe",
|
||||
"description": "This app provides a management UI for the Nextcloud MCP Server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Chris Coutinho",
|
||||
"email": "chris@coutinho.io",
|
||||
"homepage": "https://github.com/cbcoutinho"
|
||||
}
|
||||
],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OCA\\Astrolabe\\": "lib/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"OCP\\": "vendor/nextcloud/ocp/OCP/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": [
|
||||
"@composer bin all install --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@composer bin all install --ansi"
|
||||
],
|
||||
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
|
||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"psalm": "psalm --threads=1 --no-cache",
|
||||
"test:unit": "./vendor/bin/phpunit -c tests/unit/phpunit.xml --colors=always",
|
||||
"openapi": "generate-spec",
|
||||
"rector": "rector && composer cs:fix"
|
||||
},
|
||||
"require": {
|
||||
"bamarni/composer-bin-plugin": "^1.8",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^3.8",
|
||||
"nextcloud/ocp": "dev-stable30",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"bamarni/composer-bin-plugin": true
|
||||
},
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true,
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
-3305
File diff suppressed because it is too large
Load Diff
-3
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#000"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\AppInfo;
|
||||
|
||||
use OCA\Astrolabe\Listener\AstrolabeAdminSettingsListener;
|
||||
use OCA\Astrolabe\Search\SemanticSearchProvider;
|
||||
use OCA\Astrolabe\Settings\AstrolabeAdminSettings;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
|
||||
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
|
||||
|
||||
class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'astrolabe';
|
||||
|
||||
/** @psalm-suppress PossiblyUnusedMethod */
|
||||
public function __construct() {
|
||||
parent::__construct(self::APP_ID);
|
||||
}
|
||||
|
||||
public function register(IRegistrationContext $context): void {
|
||||
// Register unified search provider for semantic search
|
||||
$context->registerSearchProvider(SemanticSearchProvider::class);
|
||||
|
||||
// Register declarative admin settings
|
||||
$context->registerDeclarativeSettings(AstrolabeAdminSettings::class);
|
||||
|
||||
// Register event listeners for declarative settings
|
||||
$context->registerEventListener(
|
||||
DeclarativeSettingsGetValueEvent::class,
|
||||
AstrolabeAdminSettingsListener::class
|
||||
);
|
||||
$context->registerEventListener(
|
||||
DeclarativeSettingsSetValueEvent::class,
|
||||
AstrolabeAdminSettingsListener::class
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(IBootContext $context): void {
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\Lock\LockedException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Background job to proactively refresh OAuth tokens before expiration.
|
||||
*
|
||||
* Runs every 15 minutes and refreshes tokens based on their actual expiration
|
||||
* time. Works with any IdP (Nextcloud OIDC, Keycloak, etc.) since it uses
|
||||
* the real token expiration rather than IdP configuration.
|
||||
*
|
||||
* Refresh strategy: Refresh when less than 50% of token lifetime remains,
|
||||
* ensuring tokens are refreshed well before expiration regardless of the
|
||||
* IdP's configured token lifetime.
|
||||
*
|
||||
* @psalm-suppress UnusedClass - Background jobs are loaded dynamically by Nextcloud
|
||||
*/
|
||||
class RefreshUserTokens extends TimedJob {
|
||||
/** Job runs every 15 minutes */
|
||||
private const JOB_INTERVAL_SECONDS = 900;
|
||||
|
||||
/** Refresh when this percentage of token lifetime remains */
|
||||
private const REFRESH_AT_REMAINING_PERCENT = 0.5;
|
||||
|
||||
/** Minimum threshold to avoid constant refresh (5 minutes) */
|
||||
private const MIN_THRESHOLD_SECONDS = 300;
|
||||
|
||||
/** Default assumed token lifetime if we can't determine it (1 hour) */
|
||||
private const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
|
||||
|
||||
/** Batch size for processing users (prevents memory issues on large installations) */
|
||||
private const BATCH_SIZE = 100;
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
$this->setInterval(self::JOB_INTERVAL_SECONDS);
|
||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||
}
|
||||
|
||||
protected function run(mixed $argument): void {
|
||||
$this->logger->info('RefreshUserTokens: Starting background token refresh');
|
||||
|
||||
$refreshed = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$offset = 0;
|
||||
$totalUsers = 0;
|
||||
|
||||
// Process users in batches to prevent memory issues on large installations
|
||||
do {
|
||||
$userIds = $this->tokenStorage->getAllUsersWithTokens(self::BATCH_SIZE, $offset);
|
||||
$batchCount = count($userIds);
|
||||
$totalUsers += $batchCount;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$result = $this->refreshUserTokenIfNeeded($userId);
|
||||
match ($result) {
|
||||
'refreshed' => $refreshed++,
|
||||
'failed' => $failed++,
|
||||
'skipped' => $skipped++,
|
||||
};
|
||||
}
|
||||
|
||||
$offset += self::BATCH_SIZE;
|
||||
} while ($batchCount === self::BATCH_SIZE);
|
||||
|
||||
$this->logger->info("RefreshUserTokens: Complete - total=$totalUsers, refreshed=$refreshed, failed=$failed, skipped=$skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user's token if it's nearing expiration.
|
||||
*
|
||||
* Calculates the refresh threshold based on the token's actual lifetime,
|
||||
* refreshing when less than 50% of the lifetime remains.
|
||||
*
|
||||
* Uses locking to prevent race conditions with on-demand refresh in
|
||||
* getAccessToken(). If lock cannot be acquired, skips this user since
|
||||
* on-demand refresh is already handling it.
|
||||
*
|
||||
* @return string 'refreshed', 'failed', or 'skipped'
|
||||
*/
|
||||
private function refreshUserTokenIfNeeded(string $userId): string {
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($token === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
$expiresAt = (int)($token['expires_at'] ?? 0);
|
||||
$issuedAt = isset($token['issued_at']) ? (int)$token['issued_at'] : null;
|
||||
$timeRemaining = $expiresAt - time();
|
||||
|
||||
// Calculate token lifetime from stored data or use default
|
||||
if ($issuedAt !== null) {
|
||||
$tokenLifetime = $expiresAt - $issuedAt;
|
||||
} else {
|
||||
// Fallback: use default lifetime assumption
|
||||
$tokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
// Calculate threshold: refresh when 50% of lifetime remains
|
||||
$threshold = max(
|
||||
(int)($tokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($timeRemaining > $threshold) {
|
||||
// Token still has plenty of time, skip
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Token is expiring soon, attempt refresh with lock
|
||||
try {
|
||||
return $this->tokenStorage->withTokenLock($userId, function () use ($userId) {
|
||||
// Re-check token after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for lock
|
||||
$currentToken = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($currentToken === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Recalculate threshold with current token data
|
||||
$currentExpiresAt = (int)($currentToken['expires_at'] ?? 0);
|
||||
$currentIssuedAt = isset($currentToken['issued_at']) ? (int)$currentToken['issued_at'] : null;
|
||||
$currentTimeRemaining = $currentExpiresAt - time();
|
||||
|
||||
if ($currentIssuedAt !== null) {
|
||||
$currentTokenLifetime = $currentExpiresAt - $currentIssuedAt;
|
||||
} else {
|
||||
$currentTokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
$currentThreshold = max(
|
||||
(int)($currentTokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($currentTimeRemaining > $currentThreshold) {
|
||||
// Token was refreshed by another process while we waited
|
||||
$this->logger->debug("RefreshUserTokens: Token already refreshed for user $userId while waiting for lock");
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Still needs refresh, proceed
|
||||
if (!isset($currentToken['refresh_token'])) {
|
||||
$this->logger->warning("RefreshUserTokens: User $userId has no refresh token");
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Refreshing token for user $userId (remaining={$currentTimeRemaining}s, threshold={$currentThreshold}s)");
|
||||
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
$this->logger->warning("RefreshUserTokens: Refresh returned null for user $userId");
|
||||
// Don't delete token here - let on-demand refresh handle cleanup
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// Calculate new expiration and store issued_at for future calculations
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? self::DEFAULT_TOKEN_LIFETIME_SECONDS);
|
||||
$now = time();
|
||||
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
|
||||
$this->tokenStorage->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at
|
||||
);
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Successfully refreshed token for user $userId");
|
||||
return 'refreshed';
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Lock held by on-demand refresh - expected, not an error
|
||||
$this->logger->debug("RefreshUserTokens: Lock held for user $userId, skipping");
|
||||
return 'skipped';
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("RefreshUserTokens: Failed to refresh for user $userId: " . $e->getMessage());
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,854 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCA\Astrolabe\Service\WebhookPresets;
|
||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\IConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* API controller for MCP Server UI.
|
||||
*
|
||||
* Handles form submissions and AJAX requests from settings panels.
|
||||
*/
|
||||
class ApiController extends Controller {
|
||||
private McpServerClient $client;
|
||||
private IUserSession $userSession;
|
||||
private IURLGenerator $urlGenerator;
|
||||
private LoggerInterface $logger;
|
||||
private McpTokenStorage $tokenStorage;
|
||||
private IConfig $config;
|
||||
private IdpTokenRefresher $tokenRefresher;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
McpServerClient $client,
|
||||
IUserSession $userSession,
|
||||
IURLGenerator $urlGenerator,
|
||||
LoggerInterface $logger,
|
||||
McpTokenStorage $tokenStorage,
|
||||
IConfig $config,
|
||||
IdpTokenRefresher $tokenRefresher,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->client = $client;
|
||||
$this->userSession = $userSession;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->logger = $logger;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->config = $config;
|
||||
$this->tokenRefresher = $tokenRefresher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke user's background access (delete refresh token).
|
||||
*
|
||||
* Called from personal settings form POST.
|
||||
* Redirects back to personal settings after completion.
|
||||
*
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function revokeAccess(): RedirectResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
// Should not happen (NoAdminRequired ensures user is logged in)
|
||||
$this->logger->error('Revoke access called without authenticated user');
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Get user's OAuth token
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
if (!$token) {
|
||||
$this->logger->error("Cannot revoke access: No token found for user $userId");
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
$accessToken = $token['access_token'];
|
||||
|
||||
// Call MCP server API to revoke access
|
||||
$result = $this->client->revokeUserAccess($userId, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$this->logger->error("Failed to revoke access for user $userId", [
|
||||
'error' => $result['error']
|
||||
]);
|
||||
// TODO: Add flash message/notification for user feedback
|
||||
} else {
|
||||
$this->logger->info("Successfully revoked background access for user $userId");
|
||||
|
||||
// Delete local OAuth tokens from Nextcloud config
|
||||
// This ensures hasBackgroundAccess() returns false on next page load
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
$this->logger->debug("Deleted local OAuth tokens for user $userId");
|
||||
|
||||
// TODO: Add success flash message/notification
|
||||
}
|
||||
|
||||
// Redirect back to personal settings
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search via MCP server.
|
||||
*
|
||||
* AJAX endpoint for vector search UI in app page.
|
||||
* Uses user's OAuth token for authentication.
|
||||
*
|
||||
* @param string $query Search query
|
||||
* @param string $algorithm Search algorithm (semantic, bm25, hybrid)
|
||||
* @param int $limit Number of results (max 50)
|
||||
* @param string $doc_types Comma-separated document types (e.g., "note,file")
|
||||
* @param string $include_pca Whether to include PCA coordinates for visualization
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function search(
|
||||
string $query = '',
|
||||
string $algorithm = 'hybrid',
|
||||
int $limit = 10,
|
||||
string $doc_types = '',
|
||||
string $include_pca = 'true',
|
||||
): JSONResponse {
|
||||
if (empty($query)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Missing required parameter: query'
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get current user
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback that calls IdP directly
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required. Please authorize the app first.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Validate algorithm
|
||||
$validAlgorithms = ['semantic', 'bm25', 'hybrid'];
|
||||
if (!in_array($algorithm, $validAlgorithms)) {
|
||||
$algorithm = 'hybrid';
|
||||
}
|
||||
|
||||
// Enforce limit bounds
|
||||
$limit = max(1, min($limit, 50));
|
||||
|
||||
// Parse doc_types filter
|
||||
$docTypesArray = null;
|
||||
if (!empty($doc_types)) {
|
||||
$validDocTypes = ['note', 'file', 'deck_card', 'calendar', 'contact', 'news_item'];
|
||||
$docTypesArray = array_filter(
|
||||
explode(',', $doc_types),
|
||||
fn ($t) => in_array(trim($t), $validDocTypes)
|
||||
);
|
||||
$docTypesArray = array_map('trim', $docTypesArray);
|
||||
if (empty($docTypesArray)) {
|
||||
$docTypesArray = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse include_pca (string "true"/"false" from query params)
|
||||
$includePcaBool = in_array(strtolower($include_pca), ['true', '1', 'yes'], true);
|
||||
|
||||
// Execute search via MCP server with OAuth token
|
||||
$result = $this->client->search($query, $algorithm, $limit, $includePcaBool, $docTypesArray, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $result['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'results' => $result['results'] ?? [],
|
||||
'algorithm_used' => $result['algorithm_used'] ?? $algorithm,
|
||||
'total_documents' => $result['total_documents'] ?? 0,
|
||||
];
|
||||
|
||||
// Include PCA visualization coordinates if requested and available
|
||||
if ($includePcaBool) {
|
||||
$response['coordinates_3d'] = $result['coordinates_3d'] ?? [];
|
||||
$response['query_coords'] = $result['query_coords'] ?? [];
|
||||
if (isset($result['pca_variance'])) {
|
||||
$response['pca_variance'] = $result['pca_variance'];
|
||||
}
|
||||
}
|
||||
|
||||
return new JSONResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status from MCP server.
|
||||
*
|
||||
* AJAX endpoint for status refresh in personal settings.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function vectorStatus(): JSONResponse {
|
||||
$status = $this->client->getVectorSyncStatus();
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP server status.
|
||||
*
|
||||
* Admin-only endpoint for admin settings page.
|
||||
* Returns server version, uptime, and vector sync availability.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function serverStatus(): JSONResponse {
|
||||
$status = $this->client->getStatus();
|
||||
|
||||
// Validate that status is an array before accessing
|
||||
if (!is_array($status)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid response from MCP server'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status for admin.
|
||||
*
|
||||
* Admin-only endpoint for admin settings page.
|
||||
* Returns indexing metrics and sync status.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function adminVectorStatus(): JSONResponse {
|
||||
$status = $this->client->getVectorSyncStatus();
|
||||
|
||||
// Validate that status is an array before accessing
|
||||
if (!is_array($status)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid response from MCP server'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
if (isset($status['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $status['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'status' => $status
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save admin search settings.
|
||||
*
|
||||
* Admin-only endpoint to configure AI Search provider parameters.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function saveSearchSettings(): JSONResponse {
|
||||
// Parse JSON body
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
if ($data === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid JSON body'
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Validate and save algorithm
|
||||
$validAlgorithms = ['hybrid', 'semantic', 'bm25'];
|
||||
$algorithm = $data['algorithm'] ?? AdminSettings::DEFAULT_SEARCH_ALGORITHM;
|
||||
if (!in_array($algorithm, $validAlgorithms)) {
|
||||
$algorithm = AdminSettings::DEFAULT_SEARCH_ALGORITHM;
|
||||
}
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_ALGORITHM,
|
||||
$algorithm
|
||||
);
|
||||
|
||||
// Validate and save fusion method
|
||||
$validFusions = ['rrf', 'dbsf'];
|
||||
$fusion = $data['fusion'] ?? AdminSettings::DEFAULT_SEARCH_FUSION;
|
||||
if (!in_array($fusion, $validFusions)) {
|
||||
$fusion = AdminSettings::DEFAULT_SEARCH_FUSION;
|
||||
}
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_FUSION,
|
||||
$fusion
|
||||
);
|
||||
|
||||
// Validate and save score threshold (0-100)
|
||||
$scoreThreshold = (int)($data['scoreThreshold'] ?? AdminSettings::DEFAULT_SEARCH_SCORE_THRESHOLD);
|
||||
$scoreThreshold = max(0, min(100, $scoreThreshold));
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_SCORE_THRESHOLD,
|
||||
(string)$scoreThreshold
|
||||
);
|
||||
|
||||
// Validate and save limit (5-100)
|
||||
$limit = (int)($data['limit'] ?? AdminSettings::DEFAULT_SEARCH_LIMIT);
|
||||
$limit = max(5, min(100, $limit));
|
||||
$this->config->setAppValue(
|
||||
$this->appName,
|
||||
AdminSettings::SETTING_SEARCH_LIMIT,
|
||||
(string)$limit
|
||||
);
|
||||
|
||||
$this->logger->info('Admin search settings saved', [
|
||||
'algorithm' => $algorithm,
|
||||
'fusion' => $fusion,
|
||||
'scoreThreshold' => $scoreThreshold,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'settings' => [
|
||||
'algorithm' => $algorithm,
|
||||
'fusion' => $fusion,
|
||||
'scoreThreshold' => $scoreThreshold,
|
||||
'limit' => $limit,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available webhook presets.
|
||||
*
|
||||
* Admin-only endpoint that lists webhook presets filtered by installed apps.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function getWebhookPresets(): JSONResponse {
|
||||
// Get admin's OAuth token for API calls
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Get installed apps to filter presets
|
||||
$installedAppsResult = $this->client->getInstalledApps($accessToken);
|
||||
if (isset($installedAppsResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $installedAppsResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$installedApps = $installedAppsResult['apps'] ?? [];
|
||||
|
||||
// Get registered webhooks to check preset status
|
||||
$webhooksResult = $this->client->listWebhooks($accessToken);
|
||||
if (isset($webhooksResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $webhooksResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
|
||||
|
||||
// Filter presets by installed apps
|
||||
$presets = WebhookPresets::filterPresetsByInstalledApps($installedApps);
|
||||
|
||||
// Add enabled status to each preset
|
||||
// IMPORTANT: Match both event type AND filter to avoid false positives
|
||||
// (e.g., Notes and Files both use FILE_EVENT_* but with different filters)
|
||||
$presetsWithStatus = [];
|
||||
foreach ($presets as $presetId => $preset) {
|
||||
// Check if all events for this preset are registered with matching filters
|
||||
$allEventsRegistered = true;
|
||||
foreach ($preset['events'] as $presetEvent) {
|
||||
$eventMatched = false;
|
||||
foreach ($registeredWebhooks as $webhook) {
|
||||
// Match event type
|
||||
if ($webhook['event'] !== $presetEvent['event']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match filter (both must have filter or both must not have filter)
|
||||
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
|
||||
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
|
||||
|
||||
// Compare filters (use json_encode for deep comparison)
|
||||
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
|
||||
$eventMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$eventMatched) {
|
||||
$allEventsRegistered = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$presetsWithStatus[$presetId] = array_merge($preset, [
|
||||
'enabled' => $allEventsRegistered
|
||||
]);
|
||||
}
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'presets' => $presetsWithStatus
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a webhook preset.
|
||||
*
|
||||
* Admin-only endpoint that registers all webhooks for a preset.
|
||||
*
|
||||
* @param string $presetId Preset ID to enable
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function enableWebhookPreset(string $presetId): JSONResponse {
|
||||
// Get admin's OAuth token
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Get preset configuration
|
||||
$preset = WebhookPresets::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => "Unknown preset: $presetId"
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get MCP server URL for webhook callback URI
|
||||
$mcpServerUrl = $this->client->getServerUrl();
|
||||
$callbackUri = $mcpServerUrl . '/api/v1/webhooks/callback';
|
||||
|
||||
// Register each event in the preset
|
||||
$registered = [];
|
||||
$errors = [];
|
||||
foreach ($preset['events'] as $eventConfig) {
|
||||
$result = $this->client->createWebhook(
|
||||
$eventConfig['event'],
|
||||
$callbackUri,
|
||||
!empty($eventConfig['filter']) ? $eventConfig['filter'] : null,
|
||||
$accessToken
|
||||
);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = [
|
||||
'event' => $eventConfig['event'],
|
||||
'error' => $result['error']
|
||||
];
|
||||
} else {
|
||||
$registered[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to register some webhooks',
|
||||
'registered' => $registered,
|
||||
'errors' => $errors
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$this->logger->info("Enabled webhook preset $presetId for user $userId", [
|
||||
'preset_id' => $presetId,
|
||||
'webhooks_registered' => count($registered)
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => "Enabled {$preset['name']}",
|
||||
'webhooks' => $registered
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a webhook preset.
|
||||
*
|
||||
* Admin-only endpoint that deletes all webhooks for a preset.
|
||||
*
|
||||
* @param string $presetId Preset ID to disable
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function disableWebhookPreset(string $presetId): JSONResponse {
|
||||
// Get admin's OAuth token
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get access token with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Get preset configuration
|
||||
$preset = WebhookPresets::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => "Unknown preset: $presetId"
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get all registered webhooks
|
||||
$webhooksResult = $this->client->listWebhooks($accessToken);
|
||||
if (isset($webhooksResult['error'])) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => $webhooksResult['error']
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$registeredWebhooks = $webhooksResult['webhooks'] ?? [];
|
||||
|
||||
// Find webhooks that match this preset's events AND filters
|
||||
// IMPORTANT: Must match both event type AND filter to avoid deleting
|
||||
// webhooks from other presets (e.g., Notes vs Files both use FILE_EVENT_*)
|
||||
$webhooksToDelete = [];
|
||||
foreach ($registeredWebhooks as $webhook) {
|
||||
// Check if this webhook matches any event in the preset
|
||||
foreach ($preset['events'] as $presetEvent) {
|
||||
// Match event type
|
||||
if ($webhook['event'] !== $presetEvent['event']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match filter (both must have filter or both must not have filter)
|
||||
$presetFilter = !empty($presetEvent['filter']) ? $presetEvent['filter'] : null;
|
||||
$webhookFilter = !empty($webhook['eventFilter']) ? $webhook['eventFilter'] : null;
|
||||
|
||||
// Compare filters (use json_encode for deep comparison)
|
||||
if (json_encode($presetFilter) === json_encode($webhookFilter)) {
|
||||
$webhooksToDelete[] = $webhook;
|
||||
break; // This webhook matches, no need to check other preset events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete each matching webhook
|
||||
$deleted = [];
|
||||
$errors = [];
|
||||
foreach ($webhooksToDelete as $webhook) {
|
||||
$result = $this->client->deleteWebhook($webhook['id'], $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = [
|
||||
'webhook_id' => $webhook['id'],
|
||||
'event' => $webhook['event'],
|
||||
'error' => $result['error']
|
||||
];
|
||||
} else {
|
||||
$deleted[] = $webhook['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to delete some webhooks',
|
||||
'deleted' => $deleted,
|
||||
'errors' => $errors
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
$this->logger->info("Disabled webhook preset $presetId for user $userId", [
|
||||
'preset_id' => $presetId,
|
||||
'webhooks_deleted' => count($deleted)
|
||||
]);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => "Disabled {$preset['name']}",
|
||||
'deleted' => $deleted
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk context for visualization.
|
||||
*
|
||||
* @param string $doc_type Document type
|
||||
* @param string $doc_id Document ID
|
||||
* @param int $start Start offset
|
||||
* @param int $end End offset
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function chunkContext(
|
||||
string $doc_type,
|
||||
string $doc_id,
|
||||
int $start,
|
||||
int $end,
|
||||
): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$result = $this->client->getChunkContext($doc_type, $doc_id, $start, $end, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF page preview (server-side rendered).
|
||||
*
|
||||
* AJAX endpoint for PDF viewer in semantic search UI.
|
||||
* Uses server-side PyMuPDF rendering to avoid CSP/worker issues.
|
||||
*
|
||||
* @param string $file_path WebDAV path to PDF file
|
||||
* @param int $page Page number (1-indexed, default: 1)
|
||||
* @param float $scale Zoom factor (default: 2.0)
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function pdfPreview(
|
||||
string $file_path,
|
||||
int $page = 1,
|
||||
float $scale = 2.0,
|
||||
): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['success' => false, 'error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get user's OAuth token for MCP server with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'MCP server authorization required.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$result = $this->client->getPdfPreview($file_path, $page, $scale, $accessToken);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return new JSONResponse(['success' => false, 'error' => $result['error']], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return new JSONResponse($result);
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Controller for managing background sync credentials (app passwords).
|
||||
*
|
||||
* Handles storing and validating app passwords for multi-user BasicAuth mode.
|
||||
*/
|
||||
class CredentialsController extends Controller {
|
||||
private McpTokenStorage $tokenStorage;
|
||||
private IUserSession $userSession;
|
||||
private LoggerInterface $logger;
|
||||
private IConfig $config;
|
||||
private McpServerClient $client;
|
||||
private IClientService $httpClientService;
|
||||
private IURLGenerator $urlGenerator;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
McpTokenStorage $tokenStorage,
|
||||
IUserSession $userSession,
|
||||
LoggerInterface $logger,
|
||||
IConfig $config,
|
||||
McpServerClient $client,
|
||||
IClientService $httpClientService,
|
||||
IURLGenerator $urlGenerator,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->userSession = $userSession;
|
||||
$this->logger = $logger;
|
||||
$this->config = $config;
|
||||
$this->client = $client;
|
||||
$this->httpClientService = $httpClientService;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store app password for background sync.
|
||||
*
|
||||
* Validates the app password by making a test request to Nextcloud,
|
||||
* then stores it encrypted if valid.
|
||||
*
|
||||
* @param string $appPassword Nextcloud app password
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function storeAppPassword(string $appPassword): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
$this->logger->error('storeAppPassword called without authenticated user');
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||
if (!preg_match('/^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$/', $appPassword)) {
|
||||
$this->logger->warning("Invalid app password format for user: $userId");
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid app password format'
|
||||
], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Validate app password with Nextcloud
|
||||
$isValid = $this->validateAppPassword($userId, $appPassword);
|
||||
|
||||
if (!$isValid) {
|
||||
$this->logger->warning("App password validation failed for user: $userId");
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Invalid app password. Please check the password and try again.'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Store encrypted app password locally in Nextcloud
|
||||
try {
|
||||
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||
$this->logger->info("Stored app password locally for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store app password locally for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to save app password locally'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
// Send app password to MCP server for background sync
|
||||
// Get MCP server URL from system config (set in config.php)
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
$this->logger->warning('MCP server URL not configured, app password stored locally only');
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server not configured)'
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
try {
|
||||
$httpClient = $this->httpClientService->newClient();
|
||||
|
||||
// Send to MCP server with BasicAuth (user proves ownership of password)
|
||||
$mcpEndpoint = rtrim($mcpServerUrl, '/') . '/api/v1/users/' . urlencode($userId) . '/app-password';
|
||||
|
||||
$this->logger->debug("Sending app password to MCP server: $mcpEndpoint");
|
||||
|
||||
$response = $httpClient->post($mcpEndpoint, [
|
||||
'auth' => [$userId, $appPassword],
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
$body = json_decode($response->getBody(), true);
|
||||
|
||||
if ($statusCode === 200 && ($body['success'] ?? false)) {
|
||||
$this->logger->info("Successfully provisioned app password to MCP server for user: $userId");
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => false,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => true,
|
||||
'message' => 'App password saved successfully'
|
||||
], Http::STATUS_OK);
|
||||
} else {
|
||||
$error = $body['error'] ?? 'Unknown error';
|
||||
$this->logger->error("MCP server rejected app password for user $userId: $error");
|
||||
// Return partial success since it was stored locally but MCP sync failed
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server sync failed)',
|
||||
'mcp_error' => $error
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to send app password to MCP server for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Return partial success since it was stored locally but MCP was unreachable
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'partial_success' => true,
|
||||
'local_storage' => true,
|
||||
'mcp_sync' => false,
|
||||
'message' => 'App password saved locally (MCP server unreachable)',
|
||||
'mcp_error' => $e->getMessage()
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate app password by making a test request to Nextcloud.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param string $appPassword App password to validate
|
||||
* @return bool True if valid, false otherwise
|
||||
*/
|
||||
private function validateAppPassword(string $userId, string $appPassword): bool {
|
||||
try {
|
||||
// Use 127.0.0.1 for internal validation (we're running inside Nextcloud container)
|
||||
// Using IP address instead of 'localhost' to avoid Nextcloud's overwrite.cli.url rewriting
|
||||
// getAbsoluteURL() returns the external URL which isn't accessible from inside the container
|
||||
$baseUrl = 'http://127.0.0.1';
|
||||
|
||||
// Make a test request to Nextcloud API with BasicAuth
|
||||
// Using OCS API user endpoint as a lightweight test
|
||||
$testUrl = $baseUrl . '/ocs/v1.php/cloud/user?format=json';
|
||||
|
||||
$this->logger->debug("Validating app password for user: $userId against $testUrl");
|
||||
|
||||
// Use Nextcloud's HTTP client
|
||||
$httpClient = $this->httpClientService->newClient();
|
||||
|
||||
$response = $httpClient->get($testUrl, [
|
||||
'auth' => [$userId, $appPassword],
|
||||
'headers' => [
|
||||
'OCS-APIRequest' => 'true',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
// Success is 200 OK
|
||||
if ($statusCode === 200) {
|
||||
$this->logger->debug("App password validation successful for user: $userId");
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logger->warning("App password validation failed for user: $userId (HTTP $statusCode)");
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Exception during app password validation for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background sync credentials status for the current user.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function getStatus(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'has_background_access' => $hasAccess,
|
||||
'sync_type' => $syncType,
|
||||
'provisioned_at' => $provisionedAt,
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credentials for a specific user (admin only).
|
||||
*
|
||||
* Note: This does NOT return the actual password, only metadata.
|
||||
*
|
||||
* @param string $userId User ID to check
|
||||
* @return JSONResponse
|
||||
*/
|
||||
public function getCredentials(string $userId): JSONResponse {
|
||||
// This endpoint should only be accessible by admins
|
||||
// For now, just return metadata (not actual credentials)
|
||||
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'user_id' => $userId,
|
||||
'has_background_access' => $hasAccess,
|
||||
'sync_type' => $syncType,
|
||||
'provisioned_at' => $provisionedAt,
|
||||
], Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete background sync credentials for the current user.
|
||||
*
|
||||
* @return JSONResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function deleteCredentials(): JSONResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'User not authenticated'
|
||||
], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
try {
|
||||
// Delete both OAuth tokens and app password (if any exist)
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
$this->tokenStorage->deleteBackgroundSyncPassword($userId);
|
||||
|
||||
$this->logger->info("Deleted background sync credentials for user: $userId");
|
||||
|
||||
return new JSONResponse([
|
||||
'success' => true,
|
||||
'message' => 'Credentials deleted successfully'
|
||||
], Http::STATUS_OK);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to delete credentials for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return new JSONResponse([
|
||||
'success' => false,
|
||||
'error' => 'Failed to delete credentials'
|
||||
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
use OCP\ISession;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* OAuth controller for MCP Server UI.
|
||||
*
|
||||
* Implements OAuth 2.0 Authorization Code flow with PKCE (RFC 9207).
|
||||
* PKCE is always used for all clients (public and confidential) as recommended
|
||||
* by modern OAuth 2.0 best practices.
|
||||
*
|
||||
* - Public clients: PKCE only
|
||||
* - Confidential clients: PKCE + client_secret (defense in depth)
|
||||
*/
|
||||
class OauthController extends Controller {
|
||||
private IConfig $config;
|
||||
private ISession $session;
|
||||
private IUserSession $userSession;
|
||||
private IURLGenerator $urlGenerator;
|
||||
private McpTokenStorage $tokenStorage;
|
||||
private LoggerInterface $logger;
|
||||
private IL10N $l;
|
||||
private IClient $httpClient;
|
||||
private McpServerClient $client;
|
||||
|
||||
public function __construct(
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
IConfig $config,
|
||||
ISession $session,
|
||||
IUserSession $userSession,
|
||||
IURLGenerator $urlGenerator,
|
||||
McpTokenStorage $tokenStorage,
|
||||
LoggerInterface $logger,
|
||||
IL10N $l,
|
||||
IClientService $clientService,
|
||||
McpServerClient $client,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->config = $config;
|
||||
$this->session = $session;
|
||||
$this->userSession = $userSession;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->logger = $logger;
|
||||
$this->l = $l;
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth authorization flow.
|
||||
*
|
||||
* Always generates PKCE code verifier and challenge (RFC 9207).
|
||||
* Stores state and code verifier in session, then redirects user to IdP authorization endpoint.
|
||||
*
|
||||
* @return RedirectResponse|TemplateResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function initiateOAuth() {
|
||||
$this->logger->info('initiateOAuth called');
|
||||
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
$this->logger->error('initiateOAuth: User not authenticated');
|
||||
return new TemplateResponse(
|
||||
'astrolabe',
|
||||
'settings/error',
|
||||
['error' => $this->l->t('User not authenticated')]
|
||||
);
|
||||
}
|
||||
|
||||
$this->logger->info('initiateOAuth: User authenticated: ' . $user->getUID());
|
||||
|
||||
try {
|
||||
// Get MCP server configuration
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Always generate PKCE values (RFC 9207: PKCE recommended for all clients)
|
||||
$codeVerifier = bin2hex(random_bytes(32));
|
||||
$codeChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));
|
||||
|
||||
// Check if confidential client secret is also configured
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
$isConfidentialClient = !empty($clientSecret);
|
||||
|
||||
if ($isConfidentialClient) {
|
||||
$this->logger->info('Using confidential client mode with PKCE and client secret');
|
||||
} else {
|
||||
$this->logger->info('Using public client mode with PKCE only');
|
||||
}
|
||||
|
||||
// Generate state for CSRF protection
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
// Store values in session
|
||||
$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
|
||||
$this->session->set('mcp_oauth_state', $state);
|
||||
$this->session->set('mcp_oauth_user_id', $user->getUID());
|
||||
|
||||
// Build OAuth authorization URL
|
||||
$authUrl = $this->buildAuthorizationUrl(
|
||||
$mcpServerUrl,
|
||||
$state,
|
||||
$codeChallenge
|
||||
);
|
||||
|
||||
$this->logger->info('Initiating OAuth flow for user: ' . $user->getUID());
|
||||
|
||||
return new RedirectResponse($authUrl);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to initiate OAuth flow', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return new TemplateResponse(
|
||||
'astrolabe',
|
||||
'settings/error',
|
||||
['error' => $this->l->t('Failed to initiate OAuth: %s', [$e->getMessage()])]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback after user authorization.
|
||||
*
|
||||
* Validates state, exchanges authorization code for access token using PKCE,
|
||||
* and stores tokens for the user.
|
||||
*
|
||||
* @param string $code Authorization code
|
||||
* @param string $state State parameter for CSRF protection
|
||||
* @param string|null $error Error from IdP
|
||||
* @param string|null $error_description Error description from IdP
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[NoCSRFRequired]
|
||||
public function oauthCallback(
|
||||
string $code = '',
|
||||
string $state = '',
|
||||
?string $error = null,
|
||||
?string $error_description = null,
|
||||
): RedirectResponse {
|
||||
try {
|
||||
// Check for errors from IdP
|
||||
if ($error) {
|
||||
throw new \Exception("OAuth error: $error - " . ($error_description ?? ''));
|
||||
}
|
||||
|
||||
// Validate state to prevent CSRF
|
||||
$storedState = $this->session->get('mcp_oauth_state');
|
||||
if (empty($storedState) || $state !== $storedState) {
|
||||
throw new \Exception('Invalid state parameter (CSRF protection)');
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier (always required)
|
||||
$codeVerifier = $this->session->get('mcp_oauth_code_verifier');
|
||||
if (empty($codeVerifier)) {
|
||||
throw new \Exception('PKCE code verifier not found in session');
|
||||
}
|
||||
|
||||
// Get user ID from session
|
||||
$userId = $this->session->get('mcp_oauth_user_id');
|
||||
if (empty($userId)) {
|
||||
throw new \Exception('User ID not found in session');
|
||||
}
|
||||
|
||||
// Get MCP server configuration
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
$tokenData = $this->exchangeCodeForToken(
|
||||
$mcpServerUrl,
|
||||
$code,
|
||||
$codeVerifier
|
||||
);
|
||||
|
||||
// Store tokens for user
|
||||
$this->tokenStorage->storeUserToken(
|
||||
$userId,
|
||||
$tokenData['access_token'],
|
||||
$tokenData['refresh_token'] ?? '',
|
||||
time() + ($tokenData['expires_in'] ?? 3600)
|
||||
);
|
||||
|
||||
// Clean up session
|
||||
$this->session->remove('mcp_oauth_code_verifier');
|
||||
$this->session->remove('mcp_oauth_state');
|
||||
$this->session->remove('mcp_oauth_user_id');
|
||||
|
||||
$this->logger->info("OAuth flow completed successfully for user: $userId");
|
||||
|
||||
// Redirect back to personal settings
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('OAuth callback failed', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
// Clean up session
|
||||
$this->session->remove('mcp_oauth_code_verifier');
|
||||
$this->session->remove('mcp_oauth_state');
|
||||
$this->session->remove('mcp_oauth_user_id');
|
||||
|
||||
// Redirect to settings with error
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', [
|
||||
'section' => 'astrolabe',
|
||||
'error' => urlencode($e->getMessage())
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect user's MCP OAuth tokens.
|
||||
*
|
||||
* Deletes stored tokens from Nextcloud. Note: Does not revoke tokens on IdP side.
|
||||
*
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function disconnect(): RedirectResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
try {
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
$this->logger->info("Disconnected MCP OAuth for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to disconnect MCP OAuth for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
return new RedirectResponse(
|
||||
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build OAuth authorization URL.
|
||||
*
|
||||
* Queries MCP server for IdP configuration, then performs OIDC discovery
|
||||
* to find the authorization endpoint. Supports both Nextcloud OIDC and
|
||||
* external IdPs like Keycloak.
|
||||
*
|
||||
* Always uses PKCE (RFC 9207 recommends PKCE for all clients).
|
||||
*
|
||||
* @param string $mcpServerUrl Base URL of MCP server
|
||||
* @param string $state CSRF state parameter
|
||||
* @param string $codeChallenge PKCE code challenge
|
||||
* @return string Authorization URL
|
||||
* @throws \Exception if OIDC discovery fails
|
||||
*/
|
||||
private function buildAuthorizationUrl(
|
||||
string $mcpServerUrl,
|
||||
string $state,
|
||||
string $codeChallenge,
|
||||
): string {
|
||||
// First, query MCP server to discover which IdP it's configured to use
|
||||
$this->logger->info('buildAuthorizationUrl: Starting', [
|
||||
'mcp_server_url' => $mcpServerUrl,
|
||||
]);
|
||||
|
||||
try {
|
||||
$statusUrl = $mcpServerUrl . '/api/v1/status';
|
||||
$this->logger->info('buildAuthorizationUrl: Fetching MCP server status', [
|
||||
'url' => $statusUrl,
|
||||
]);
|
||||
|
||||
$statusResponse = $this->httpClient->get($statusUrl);
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON in status response: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
$this->logger->info('buildAuthorizationUrl: MCP server status received', [
|
||||
'auth_mode' => $statusData['auth_mode'] ?? 'unknown',
|
||||
'has_oidc' => isset($statusData['oidc']),
|
||||
'oidc_discovery_url' => $statusData['oidc']['discovery_url'] ?? 'not_set',
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('buildAuthorizationUrl: Failed to fetch MCP server status', [
|
||||
'url' => $mcpServerUrl . '/api/v1/status',
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL
|
||||
// Priority: 1) MCP server's configured discovery URL, 2) Nextcloud OIDC app
|
||||
if (isset($statusData['oidc']['discovery_url'])) {
|
||||
// MCP server has external IdP configured (e.g., Keycloak)
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
$this->logger->info('Using IdP from MCP server configuration', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
} else {
|
||||
// Fall back to Nextcloud's OIDC app
|
||||
// Use internal localhost URL for HTTP request (accessible from inside container)
|
||||
// We'll transform the returned URLs to external format after discovery
|
||||
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
|
||||
$internalBaseUrl = 'http://localhost';
|
||||
|
||||
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
// Perform OIDC discovery
|
||||
$this->logger->info('buildAuthorizationUrl: Starting OIDC discovery', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->get($discoveryUrl);
|
||||
$responseBody = $response->getBody();
|
||||
$this->logger->info('buildAuthorizationUrl: Got OIDC discovery response', [
|
||||
'status_code' => $response->getStatusCode(),
|
||||
'body_length' => strlen($responseBody),
|
||||
]);
|
||||
|
||||
$discovery = json_decode($responseBody, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON in OIDC discovery: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
if (!isset($discovery['authorization_endpoint'])) {
|
||||
throw new \RuntimeException('Missing authorization_endpoint in OIDC discovery');
|
||||
}
|
||||
|
||||
$authEndpoint = $discovery['authorization_endpoint'];
|
||||
|
||||
// Transform internal URL to external URL if using Nextcloud OIDC app
|
||||
// The discovery was done via internal http://localhost but browsers need
|
||||
// the external URL (e.g., http://localhost:8080)
|
||||
if (isset($internalBaseUrl)) {
|
||||
$externalBaseUrl = $this->urlGenerator->getAbsoluteURL('/');
|
||||
$externalBaseUrl = rtrim($externalBaseUrl, '/');
|
||||
$authEndpoint = str_replace($internalBaseUrl, $externalBaseUrl, $authEndpoint);
|
||||
}
|
||||
|
||||
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
|
||||
'auth_endpoint' => $authEndpoint,
|
||||
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('buildAuthorizationUrl: OIDC discovery failed', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw new \Exception('Failed to discover OAuth endpoints: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Build callback URL
|
||||
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
|
||||
'astrolabe.oauth.oauthCallback'
|
||||
);
|
||||
|
||||
// Get public MCP server URL for token audience (RFC 8707 Resource Indicator)
|
||||
// Use public URL that clients/browsers see, not internal Docker URL
|
||||
$mcpServerPublicUrl = $this->config->getSystemValue('mcp_server_public_url', $mcpServerUrl);
|
||||
|
||||
// Build authorization URL parameters
|
||||
$params = [
|
||||
'client_id' => $this->client->getClientId(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'openid profile email offline_access', // Request MCP scopes
|
||||
'state' => $state,
|
||||
'resource' => $mcpServerPublicUrl, // RFC 8707 Resource Indicator - request token with MCP server audience
|
||||
];
|
||||
|
||||
// Add PKCE parameters (always required per RFC 9207)
|
||||
$params['code_challenge'] = $codeChallenge;
|
||||
$params['code_challenge_method'] = 'S256';
|
||||
|
||||
return $authEndpoint . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token.
|
||||
*
|
||||
* Always uses PKCE code_verifier (RFC 9207).
|
||||
* For confidential clients: Also includes client_secret for additional security.
|
||||
* For public clients: Uses PKCE code_verifier only.
|
||||
*
|
||||
* Queries MCP server for IdP configuration, then performs OIDC discovery
|
||||
* to find the token endpoint. Supports both Nextcloud OIDC and external IdPs.
|
||||
*
|
||||
* @param string $mcpServerUrl Base URL of MCP server
|
||||
* @param string $code Authorization code
|
||||
* @param string $codeVerifier PKCE code verifier
|
||||
* @return array Token data containing access_token, refresh_token, expires_in
|
||||
* @throws \Exception on HTTP or token error
|
||||
*/
|
||||
private function exchangeCodeForToken(
|
||||
string $mcpServerUrl,
|
||||
string $code,
|
||||
string $codeVerifier,
|
||||
): array {
|
||||
// Query MCP server to discover which IdP it's configured to use
|
||||
try {
|
||||
$statusResponse = $this->httpClient->get($mcpServerUrl . '/api/v1/status');
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid status response from MCP server');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to fetch MCP server status during token exchange', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL and token endpoint
|
||||
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
|
||||
|
||||
if (!$useInternalNextcloud) {
|
||||
// External IdP configured - use discovery
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->get($discoveryUrl);
|
||||
$discovery = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
|
||||
throw new \RuntimeException('Invalid OIDC discovery response');
|
||||
}
|
||||
|
||||
$tokenEndpoint = $discovery['token_endpoint'];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('OIDC discovery failed during token exchange', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw new \Exception('Failed to discover token endpoint: ' . $e->getMessage());
|
||||
}
|
||||
} else {
|
||||
// Nextcloud's OIDC app - use internal URL directly (no HTTP request needed)
|
||||
// This avoids network issues when overwritehost includes external port
|
||||
$tokenEndpoint = 'http://localhost/apps/oidc/token';
|
||||
}
|
||||
|
||||
$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
|
||||
'astrolabe.oauth.oauthCallback'
|
||||
);
|
||||
|
||||
// Build token request parameters
|
||||
$postData = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $redirectUri,
|
||||
'client_id' => $this->client->getClientId(),
|
||||
];
|
||||
|
||||
// Always include PKCE code verifier (RFC 9207)
|
||||
$postData['code_verifier'] = $codeVerifier;
|
||||
|
||||
// Also include client secret if configured (defense in depth for confidential clients)
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
if (!empty($clientSecret)) {
|
||||
$postData['client_secret'] = $clientSecret;
|
||||
$this->logger->info('Using PKCE with client secret for token exchange');
|
||||
} else {
|
||||
$this->logger->info('Using PKCE only for token exchange');
|
||||
}
|
||||
|
||||
// Use Nextcloud's HTTP client for token request
|
||||
try {
|
||||
$response = $this->httpClient->post($tokenEndpoint, [
|
||||
'body' => http_build_query($postData),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
|
||||
throw new \RuntimeException('Invalid token response from server');
|
||||
}
|
||||
|
||||
return $tokenData;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Token exchange failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'token_endpoint' => $tokenEndpoint,
|
||||
]);
|
||||
throw new \Exception('Token exchange failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL-safe encoding (for PKCE).
|
||||
*
|
||||
* @param string $data Data to encode
|
||||
* @return string Base64 URL-encoded string
|
||||
*/
|
||||
private function base64UrlEncode(string $data): string {
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Controller;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedClass
|
||||
*/
|
||||
class PageController extends Controller {
|
||||
#[NoCSRFRequired]
|
||||
#[NoAdminRequired]
|
||||
#[OpenAPI(OpenAPI::SCOPE_IGNORE)]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/')]
|
||||
public function index(): TemplateResponse {
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'index',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Listener;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\IConfig;
|
||||
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
|
||||
use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<DeclarativeSettingsGetValueEvent|DeclarativeSettingsSetValueEvent>
|
||||
*/
|
||||
class AstrolabeAdminSettingsListener implements IEventListener {
|
||||
public function __construct(
|
||||
private IConfig $config,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof DeclarativeSettingsGetValueEvent && !$event instanceof DeclarativeSettingsSetValueEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getApp() !== Application::APP_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getFormId() !== 'astrolabe-admin-settings') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof DeclarativeSettingsGetValueEvent) {
|
||||
$this->handleGetValue($event);
|
||||
} elseif ($event instanceof DeclarativeSettingsSetValueEvent) {
|
||||
$this->handleSetValue($event);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleGetValue(DeclarativeSettingsGetValueEvent $event): void {
|
||||
$fieldId = $event->getFieldId();
|
||||
|
||||
// Map field IDs to system config keys
|
||||
$value = match($fieldId) {
|
||||
'mcp_server_url' => $this->config->getSystemValue('mcp_server_url', ''),
|
||||
'mcp_server_api_key' => '****', // Never leak the API key on read
|
||||
'astrolabe_client_id' => $this->config->getSystemValue('astrolabe_client_id', ''),
|
||||
'astrolabe_client_secret' => '****', // Never leak the secret on read
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($value !== null) {
|
||||
$event->setValue($value);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleSetValue(DeclarativeSettingsSetValueEvent $event): void {
|
||||
$fieldId = $event->getFieldId();
|
||||
$value = $event->getValue();
|
||||
|
||||
// Only save if value is not empty (allow clearing by setting to empty string)
|
||||
// For password fields, if the value is '****', don't update (user didn't change it)
|
||||
if ($fieldId === 'mcp_server_api_key' && $value === '****') {
|
||||
$event->stopPropagation();
|
||||
return;
|
||||
}
|
||||
if ($fieldId === 'astrolabe_client_secret' && $value === '****') {
|
||||
$event->stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
match($fieldId) {
|
||||
'mcp_server_url' => $this->config->setSystemValue('mcp_server_url', (string)$value),
|
||||
'mcp_server_api_key' => $this->config->setSystemValue('mcp_server_api_key', (string)$value),
|
||||
'astrolabe_client_id' => $this->config->setSystemValue('astrolabe_client_id', (string)$value),
|
||||
'astrolabe_client_secret' => $this->config->setSystemValue('astrolabe_client_secret', (string)$value),
|
||||
default => null,
|
||||
};
|
||||
|
||||
$this->logger->info('Astrolabe admin setting updated', [
|
||||
'field' => $fieldId,
|
||||
'app' => Application::APP_ID,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to update Astrolabe admin setting', [
|
||||
'field' => $fieldId,
|
||||
'error' => $e->getMessage(),
|
||||
'app' => Application::APP_ID,
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$event->stopPropagation();
|
||||
}
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Search;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||
use OCP\Files\FileInfo;
|
||||
use OCP\Files\IMimeTypeDetector;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\IPreview;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\Search\IProvider;
|
||||
use OCP\Search\ISearchQuery;
|
||||
use OCP\Search\SearchResult;
|
||||
use OCP\Search\SearchResultEntry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unified Search provider for MCP Server semantic search.
|
||||
*
|
||||
* Delegates search queries to the MCP server's vector search API,
|
||||
* returning semantically relevant results from indexed Nextcloud content
|
||||
* (notes, files, calendar, deck cards).
|
||||
*
|
||||
* Security: Results are filtered server-side to only include documents
|
||||
* owned by the searching user. User identity comes from OAuth token.
|
||||
*/
|
||||
class SemanticSearchProvider implements IProvider {
|
||||
public function __construct(
|
||||
private McpServerClient $client,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private IConfig $config,
|
||||
private IL10N $l10n,
|
||||
private IURLGenerator $urlGenerator,
|
||||
private IMimeTypeDetector $mimeTypeDetector,
|
||||
private IPreview $previewManager,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique identifier for this search provider.
|
||||
*/
|
||||
public function getId(): string {
|
||||
return Application::APP_ID . '_semantic';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name shown in search results grouping.
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->l10n->t('Astrolabe');
|
||||
}
|
||||
|
||||
/**
|
||||
* Order in search results. Lower = higher priority.
|
||||
* Use negative value when user is in our app's context.
|
||||
*/
|
||||
public function getOrder(string $route, array $routeParameters): int {
|
||||
if (str_contains($route, Application::APP_ID)) {
|
||||
return -1; // Prioritize when in Astrolabe app
|
||||
}
|
||||
return 40; // Above most apps, below files/mail
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search via MCP server.
|
||||
*
|
||||
* SECURITY: Results are filtered server-side to only include documents
|
||||
* owned by the searching user. User identity comes from OAuth token.
|
||||
*/
|
||||
public function search(IUser $user, ISearchQuery $query): SearchResult {
|
||||
$term = $query->getTerm();
|
||||
$limit = $query->getLimit();
|
||||
$cursor = $query->getCursor();
|
||||
|
||||
// Skip empty queries
|
||||
if (empty(trim($term))) {
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback matching ApiController pattern
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get OAuth token for user with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
// User hasn't authorized the app yet - return empty results
|
||||
$this->logger->debug('No OAuth token for user in semantic search', [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Check if MCP server is available and vector sync enabled
|
||||
$status = $this->client->getStatus();
|
||||
if (!empty($status['error']) || !($status['vector_sync_enabled'] ?? false)) {
|
||||
$this->logger->debug('MCP server not available or vector sync disabled', [
|
||||
'status' => $status,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Load admin search settings
|
||||
$algorithm = $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_ALGORITHM,
|
||||
AdminSettings::DEFAULT_SEARCH_ALGORITHM
|
||||
);
|
||||
$fusion = $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_FUSION,
|
||||
AdminSettings::DEFAULT_SEARCH_FUSION
|
||||
);
|
||||
$scoreThreshold = (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_SCORE_THRESHOLD,
|
||||
(string)AdminSettings::DEFAULT_SEARCH_SCORE_THRESHOLD
|
||||
);
|
||||
$configuredLimit = (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
AdminSettings::SETTING_SEARCH_LIMIT,
|
||||
(string)AdminSettings::DEFAULT_SEARCH_LIMIT
|
||||
);
|
||||
|
||||
// Use configured limit if query limit is higher
|
||||
$effectiveLimit = min($limit, $configuredLimit);
|
||||
|
||||
// Calculate offset from cursor
|
||||
$offset = $cursor ? (int)$cursor : 0;
|
||||
|
||||
// Execute semantic search with OAuth token and admin settings
|
||||
// Server extracts user_id from token - results filtered to that user's documents
|
||||
$results = $this->client->searchForUnifiedSearch(
|
||||
query: $term,
|
||||
token: $accessToken,
|
||||
limit: $effectiveLimit,
|
||||
offset: $offset,
|
||||
algorithm: $algorithm,
|
||||
fusion: $fusion,
|
||||
scoreThreshold: $scoreThreshold / 100.0, // Convert percentage to 0-1 range
|
||||
);
|
||||
|
||||
if (!empty($results['error'])) {
|
||||
$this->logger->warning('Semantic search failed', [
|
||||
'error' => $results['error'],
|
||||
'query' => $term,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Transform results to SearchResultEntry objects
|
||||
$entries = [];
|
||||
foreach ($results['results'] ?? [] as $result) {
|
||||
$entries[] = $this->transformResult($result);
|
||||
}
|
||||
|
||||
// Return paginated if more results might exist
|
||||
$totalFound = $results['total_found'] ?? count($entries);
|
||||
if (count($entries) >= $effectiveLimit && $totalFound > $offset + $effectiveLimit) {
|
||||
return SearchResult::paginated(
|
||||
$this->getName(),
|
||||
$entries,
|
||||
(string)($offset + $effectiveLimit)
|
||||
);
|
||||
}
|
||||
|
||||
return SearchResult::complete($this->getName(), $entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform MCP search result to Nextcloud SearchResultEntry.
|
||||
*/
|
||||
private function transformResult(array $result): SearchResultEntry {
|
||||
$docType = $result['doc_type'] ?? 'unknown';
|
||||
$title = $result['title'] ?? $this->l10n->t('Untitled');
|
||||
$score = $result['score'] ?? 0;
|
||||
$id = isset($result['id']) ? (string)$result['id'] : null;
|
||||
$mimeType = $result['mime_type'] ?? null;
|
||||
|
||||
// Build resource URL based on document type
|
||||
$resourceUrl = $this->buildResourceUrl($result);
|
||||
|
||||
// Get icon and thumbnail based on document type
|
||||
[$thumbnailUrl, $iconClass] = $this->getIconAndThumbnail($docType, $id, $mimeType);
|
||||
|
||||
// Build metadata string with chunk and page info
|
||||
$metadataParts = [];
|
||||
|
||||
// Chunk info (always available)
|
||||
if (isset($result['chunk_index']) && isset($result['total_chunks'])) {
|
||||
$chunkNum = $result['chunk_index'] + 1; // Convert 0-based to 1-based
|
||||
$metadataParts[] = sprintf('Chunk %d/%d', $chunkNum, $result['total_chunks']);
|
||||
}
|
||||
|
||||
// Page info for PDFs
|
||||
if (!empty($result['page_number']) && !empty($result['page_count'])) {
|
||||
$metadataParts[] = sprintf('Page %d/%d', $result['page_number'], $result['page_count']);
|
||||
}
|
||||
|
||||
// Combine metadata parts
|
||||
$metadata = !empty($metadataParts) ? implode(' · ', $metadataParts) : '';
|
||||
|
||||
// Subline shows only chunk/page metadata (no excerpt, consistent with chunk viz)
|
||||
$subline = $metadata ?: sprintf(
|
||||
'%s · %d%% %s',
|
||||
$this->getDocTypeLabel($docType),
|
||||
(int)($score * 100),
|
||||
$this->l10n->t('relevant')
|
||||
);
|
||||
|
||||
return new SearchResultEntry(
|
||||
$thumbnailUrl,
|
||||
$title,
|
||||
$subline,
|
||||
$resourceUrl,
|
||||
$iconClass,
|
||||
false // not rounded
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL to navigate to Astrolabe with chunk viewer.
|
||||
*
|
||||
* Links to Astrolabe app with query parameters that trigger the chunk modal,
|
||||
* allowing users to preview the chunk before navigating to the full document.
|
||||
*/
|
||||
private function buildResourceUrl(array $result): string {
|
||||
// Build base URL to Astrolabe app
|
||||
$baseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index');
|
||||
|
||||
// Extract chunk parameters
|
||||
$docType = $result['doc_type'] ?? 'unknown';
|
||||
$id = $result['id'] ?? null;
|
||||
$chunkStart = $result['chunk_start_offset'] ?? null;
|
||||
$chunkEnd = $result['chunk_end_offset'] ?? null;
|
||||
|
||||
// If we have chunk information, build URL with parameters
|
||||
if ($id !== null && $chunkStart !== null && $chunkEnd !== null) {
|
||||
$params = [
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $id,
|
||||
'chunk_start' => $chunkStart,
|
||||
'chunk_end' => $chunkEnd,
|
||||
];
|
||||
|
||||
// Add optional metadata
|
||||
if (isset($result['title'])) {
|
||||
$params['title'] = $result['title'];
|
||||
}
|
||||
if (isset($result['path'])) {
|
||||
$params['path'] = $result['path'];
|
||||
}
|
||||
if (isset($result['page_number'])) {
|
||||
$params['page_number'] = $result['page_number'];
|
||||
}
|
||||
if (isset($result['board_id'])) {
|
||||
$params['board_id'] = $result['board_id'];
|
||||
}
|
||||
|
||||
// Encode parameters for URL
|
||||
$queryString = http_build_query($params);
|
||||
return $baseUrl . '?' . $queryString;
|
||||
}
|
||||
|
||||
// Fallback to base URL if no chunk information
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon and thumbnail for document type.
|
||||
*
|
||||
* Returns [thumbnailUrl, iconClass] tuple.
|
||||
* For files, uses mimetype-specific icons and preview thumbnails when available.
|
||||
* For other document types, uses appropriate icon classes.
|
||||
*
|
||||
* @return array{string, string} [thumbnailUrl, iconClass]
|
||||
*/
|
||||
private function getIconAndThumbnail(string $docType, ?string $id, ?string $mimeType): array {
|
||||
if ($docType === 'file' && $id !== null && $mimeType !== null) {
|
||||
// For files, check if preview is supported
|
||||
$thumbnailUrl = '';
|
||||
if ($this->previewManager->isMimeSupported($mimeType)) {
|
||||
$thumbnailUrl = $this->urlGenerator->linkToRouteAbsolute(
|
||||
'core.Preview.getPreviewByFileId',
|
||||
['x' => 32, 'y' => 32, 'fileId' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
// Get mimetype-specific icon class
|
||||
$iconClass = $mimeType === FileInfo::MIMETYPE_FOLDER
|
||||
? 'icon-folder'
|
||||
: $this->mimeTypeDetector->mimeTypeIcon($mimeType);
|
||||
|
||||
return [$thumbnailUrl, $iconClass];
|
||||
}
|
||||
|
||||
// For non-file document types, use icon classes
|
||||
$iconClass = match ($docType) {
|
||||
'note' => 'icon-notes',
|
||||
'deck_card' => 'icon-deck',
|
||||
'calendar', 'calendar_event' => 'icon-calendar',
|
||||
'news_item' => 'icon-rss',
|
||||
'contact' => 'icon-contacts',
|
||||
default => 'icon-file',
|
||||
};
|
||||
|
||||
return ['', $iconClass];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable label for document type.
|
||||
*/
|
||||
private function getDocTypeLabel(string $docType): string {
|
||||
return match ($docType) {
|
||||
'note' => $this->l10n->t('Note'),
|
||||
'file' => $this->l10n->t('File'),
|
||||
'deck_card' => $this->l10n->t('Deck Card'),
|
||||
'calendar', 'calendar_event' => $this->l10n->t('Calendar'),
|
||||
'news_item' => $this->l10n->t('News'),
|
||||
'contact' => $this->l10n->t('Contact'),
|
||||
default => $this->l10n->t('Document'),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Refreshes OAuth tokens directly with the Identity Provider.
|
||||
*
|
||||
* Works with both Nextcloud OIDC and external IdPs like Keycloak.
|
||||
* Uses OIDC discovery to find the token endpoint automatically.
|
||||
*
|
||||
* This service is only used for confidential clients (with client_secret).
|
||||
* Public clients without client_secret cannot refresh tokens.
|
||||
*/
|
||||
class IdpTokenRefresher {
|
||||
private IConfig $config;
|
||||
private IClient $httpClient;
|
||||
private LoggerInterface $logger;
|
||||
private McpServerClient $mcpServerClient;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
IClientService $clientService,
|
||||
LoggerInterface $logger,
|
||||
McpServerClient $mcpServerClient,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->logger = $logger;
|
||||
$this->mcpServerClient = $mcpServerClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Nextcloud base URL for constructing internal OIDC endpoint URLs.
|
||||
*
|
||||
* IMPORTANT: This is for INTERNAL server-to-server requests (PHP to local Apache),
|
||||
* NOT for external client URLs. We must use the internal container URL, not the
|
||||
* external URL that browsers see.
|
||||
*
|
||||
* Configuration priority:
|
||||
* 1. astrolabe_internal_url - Explicit internal URL (for custom container setups)
|
||||
* 2. http://localhost - Default for Docker containers (web server on port 80)
|
||||
*
|
||||
* NOTE: We intentionally DO NOT use overwrite.cli.url here because:
|
||||
* - overwrite.cli.url is the EXTERNAL URL (e.g., http://localhost:8080)
|
||||
* - External URLs are not accessible from inside the container
|
||||
* - This method is for internal HTTP requests to the local web server
|
||||
*
|
||||
* @return string Base URL for internal requests (e.g., "http://localhost")
|
||||
*/
|
||||
private function getNextcloudBaseUrl(): string {
|
||||
// Check for explicit internal URL config (for custom container setups)
|
||||
$internalUrl = $this->config->getSystemValue('astrolabe_internal_url', '');
|
||||
if (!is_string($internalUrl)) {
|
||||
$internalUrl = '';
|
||||
}
|
||||
if (!empty($internalUrl)) {
|
||||
// Validate URL format
|
||||
if (!filter_var($internalUrl, FILTER_VALIDATE_URL)) {
|
||||
$this->logger->warning('Invalid astrolabe_internal_url format, using default', [
|
||||
'configured_url' => $internalUrl,
|
||||
]);
|
||||
return 'http://localhost';
|
||||
}
|
||||
// Warn if it looks like an external URL (common misconfiguration)
|
||||
if (preg_match('/:\d{4,5}$/', $internalUrl)) {
|
||||
$this->logger->warning('astrolabe_internal_url appears to use external port mapping', [
|
||||
'configured_url' => $internalUrl,
|
||||
'hint' => 'Internal URLs should use port 80, not mapped ports like :8080',
|
||||
]);
|
||||
}
|
||||
return rtrim($internalUrl, '/');
|
||||
}
|
||||
|
||||
// Default: container environment with web server on localhost:80
|
||||
// This works because PHP runs inside the same container as Apache
|
||||
return 'http://localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token.
|
||||
*
|
||||
* Calls IdP's token endpoint directly (NOT MCP server).
|
||||
*
|
||||
* @param string $refreshToken The refresh token
|
||||
* @return array|null New token data or null on failure
|
||||
*/
|
||||
public function refreshAccessToken(string $refreshToken): ?array {
|
||||
// Check if confidential client secret is configured
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
|
||||
if (empty($clientSecret)) {
|
||||
$this->logger->warning('Cannot refresh: no client secret configured. Confidential client required for token refresh.');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get MCP server URL
|
||||
$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
if (empty($mcpServerUrl)) {
|
||||
throw new \Exception('MCP server URL not configured');
|
||||
}
|
||||
|
||||
// Query MCP server to discover which IdP it's configured to use
|
||||
$statusResponse = $this->httpClient->get($mcpServerUrl . '/api/v1/status');
|
||||
$statusData = json_decode($statusResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid status response from MCP server');
|
||||
}
|
||||
|
||||
// Determine OIDC discovery URL and token endpoint
|
||||
$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);
|
||||
|
||||
if (!$useInternalNextcloud) {
|
||||
// External IdP configured - use OIDC discovery
|
||||
$discoveryUrl = $statusData['oidc']['discovery_url'];
|
||||
|
||||
$this->logger->debug('IdpTokenRefresher: Using external IdP', [
|
||||
'discovery_url' => $discoveryUrl,
|
||||
]);
|
||||
|
||||
$discoveryResponse = $this->httpClient->get($discoveryUrl);
|
||||
$discovery = json_decode($discoveryResponse->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
|
||||
throw new \RuntimeException('Invalid OIDC discovery response');
|
||||
}
|
||||
|
||||
$tokenEndpoint = $discovery['token_endpoint'];
|
||||
} else {
|
||||
// Nextcloud's OIDC app - use internal URL
|
||||
$tokenEndpoint = $this->getNextcloudBaseUrl() . '/apps/oidc/token';
|
||||
|
||||
$this->logger->debug('IdpTokenRefresher: Using Nextcloud OIDC app', [
|
||||
'token_endpoint' => $tokenEndpoint,
|
||||
]);
|
||||
}
|
||||
|
||||
// Call IdP's token endpoint with refresh_token grant
|
||||
$postData = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->mcpServerClient->getClientId(),
|
||||
'client_secret' => $clientSecret,
|
||||
];
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Requesting token refresh');
|
||||
|
||||
$response = $this->httpClient->post($tokenEndpoint, [
|
||||
'body' => http_build_query($postData),
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]);
|
||||
|
||||
$tokenData = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
|
||||
throw new \RuntimeException('Invalid token response from IdP');
|
||||
}
|
||||
|
||||
// Validate refresh_token is present (required for token rotation)
|
||||
if (!isset($tokenData['refresh_token'])) {
|
||||
$this->logger->error(
|
||||
'IdpTokenRefresher: No refresh token in response - token rotation will fail',
|
||||
[
|
||||
'has_access_token' => isset($tokenData['access_token']),
|
||||
'response_keys' => array_keys($tokenData),
|
||||
]
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->logger->info('IdpTokenRefresher: Token refresh successful');
|
||||
|
||||
return $tokenData;
|
||||
|
||||
} catch (\OCP\Http\Client\LocalServerException $e) {
|
||||
// Network/connection error - may be transient
|
||||
$this->logger->warning('IdpTokenRefresher: Network error during refresh', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
$statusCode = null;
|
||||
if (method_exists($e, 'getCode')) {
|
||||
$statusCode = $e->getCode();
|
||||
}
|
||||
|
||||
// Log with appropriate level based on error type
|
||||
if ($statusCode === 401 || $statusCode === 403) {
|
||||
// Auth error - token is invalid, should be deleted
|
||||
$this->logger->error('IdpTokenRefresher: Auth error - token invalid', [
|
||||
'status_code' => $statusCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} elseif ($statusCode >= 500) {
|
||||
// Server error - may be transient
|
||||
$this->logger->warning('IdpTokenRefresher: Server error during refresh', [
|
||||
'status_code' => $statusCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} else {
|
||||
$this->logger->error('IdpTokenRefresher: Token refresh failed', [
|
||||
'status_code' => $statusCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* HTTP client for communicating with the MCP server's management API.
|
||||
*
|
||||
* This service wraps the MCP server's REST API endpoints defined in ADR-018.
|
||||
* It handles authentication via OAuth bearer tokens and provides typed methods
|
||||
* for all management operations.
|
||||
*/
|
||||
class McpServerClient {
|
||||
private IClient $httpClient;
|
||||
private IConfig $config;
|
||||
private LoggerInterface $logger;
|
||||
private string $baseUrl;
|
||||
|
||||
public function __construct(
|
||||
IClientService $clientService,
|
||||
IConfig $config,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->httpClient = $clientService->newClient();
|
||||
$this->config = $config;
|
||||
$this->logger = $logger;
|
||||
|
||||
// Get MCP server configuration from Nextcloud config
|
||||
$baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
|
||||
$this->baseUrl = is_string($baseUrl) ? $baseUrl : 'http://localhost:8000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server status (version, auth mode, features).
|
||||
*
|
||||
* Public endpoint - no authentication required.
|
||||
*
|
||||
* @return array{
|
||||
* version?: string,
|
||||
* auth_mode?: string,
|
||||
* vector_sync_enabled?: bool,
|
||||
* uptime_seconds?: int,
|
||||
* management_api_version?: string,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getStatus(): array {
|
||||
try {
|
||||
$response = $this->httpClient->get($this->baseUrl . '/api/v1/status');
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get MCP server status', [
|
||||
'error' => $e->getMessage(),
|
||||
'server_url' => $this->baseUrl,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user session details.
|
||||
*
|
||||
* Requires authentication via OAuth bearer token.
|
||||
*
|
||||
* @param string $userId The user ID to query
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* session_id?: string,
|
||||
* background_access_granted?: bool,
|
||||
* background_access_details?: array,
|
||||
* idp_profile?: array,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getUserSession(string $userId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/session',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to get session for user $userId", [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke user's background access (delete refresh token).
|
||||
*
|
||||
* Requires authentication via OAuth bearer token.
|
||||
*
|
||||
* @param string $userId The user ID whose access to revoke
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{success?: bool, message?: string, error?: string}
|
||||
*/
|
||||
public function revokeUserAccess(string $userId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/revoke',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to revoke access for user $userId", [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vector sync status (indexing metrics).
|
||||
*
|
||||
* Public endpoint - no authentication required.
|
||||
* Only available if VECTOR_SYNC_ENABLED=true on server.
|
||||
*
|
||||
* @return array{
|
||||
* status?: string,
|
||||
* indexed_documents?: int,
|
||||
* pending_documents?: int,
|
||||
* last_sync_time?: string,
|
||||
* documents_per_second?: float,
|
||||
* errors_24h?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getVectorSyncStatus(): array {
|
||||
try {
|
||||
$response = $this->httpClient->get($this->baseUrl . '/api/v1/vector-sync/status');
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get vector sync status', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search for vector visualization.
|
||||
*
|
||||
* Requires OAuth bearer token for user-filtered search.
|
||||
* Only available if VECTOR_SYNC_ENABLED=true on server.
|
||||
*
|
||||
* @param string $query Search query string
|
||||
* @param string $algorithm Search algorithm: "semantic", "bm25", or "hybrid"
|
||||
* @param int $limit Number of results (max 50)
|
||||
* @param bool $includePca Whether to include PCA coordinates for 2D plot
|
||||
* @param array|null $docTypes Document types to filter (e.g., ['note', 'file'])
|
||||
* @param string|null $token OAuth bearer token for authentication
|
||||
* @return array{
|
||||
* results?: array,
|
||||
* pca_coordinates?: array,
|
||||
* algorithm_used?: string,
|
||||
* total_documents?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function search(
|
||||
string $query,
|
||||
string $algorithm = 'hybrid',
|
||||
int $limit = 10,
|
||||
bool $includePca = true,
|
||||
?array $docTypes = null,
|
||||
?string $token = null,
|
||||
): array {
|
||||
try {
|
||||
$requestBody = [
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
'limit' => min($limit, 50), // Enforce max limit
|
||||
'include_pca' => $includePca,
|
||||
];
|
||||
|
||||
// Add doc_types filter if specified
|
||||
if ($docTypes !== null && count($docTypes) > 0) {
|
||||
$requestBody['doc_types'] = $docTypes;
|
||||
}
|
||||
|
||||
$options = ['json' => $requestBody];
|
||||
|
||||
// Add authorization header if token provided
|
||||
if ($token !== null) {
|
||||
$options['headers'] = [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
];
|
||||
}
|
||||
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/vector-viz/search',
|
||||
$options
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to execute search', [
|
||||
'error' => $e->getMessage(),
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute semantic search for Nextcloud Unified Search.
|
||||
*
|
||||
* Simplified search method specifically for the unified search provider.
|
||||
* Uses OAuth bearer token for authentication and user-scoped filtering.
|
||||
*
|
||||
* @param string $query Search query string
|
||||
* @param string $token OAuth bearer token for authentication
|
||||
* @param int $limit Maximum number of results (default: 20)
|
||||
* @param int $offset Pagination offset (default: 0)
|
||||
* @param string $algorithm Search algorithm: hybrid, semantic, or bm25 (default: hybrid)
|
||||
* @param string $fusion Fusion method for hybrid: rrf or dbsf (default: rrf)
|
||||
* @param float $scoreThreshold Minimum score threshold 0-1 (default: 0)
|
||||
* @return array{
|
||||
* results?: array<array{
|
||||
* id?: string|int,
|
||||
* title?: string,
|
||||
* doc_type?: string,
|
||||
* excerpt?: string,
|
||||
* score?: float,
|
||||
* path?: string,
|
||||
* board_id?: int,
|
||||
* card_id?: int
|
||||
* }>,
|
||||
* total_found?: int,
|
||||
* algorithm_used?: string,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function searchForUnifiedSearch(
|
||||
string $query,
|
||||
string $token,
|
||||
int $limit = 20,
|
||||
int $offset = 0,
|
||||
string $algorithm = 'hybrid',
|
||||
string $fusion = 'rrf',
|
||||
float $scoreThreshold = 0.0,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/search',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'query' => $query,
|
||||
'algorithm' => $algorithm,
|
||||
'fusion' => $fusion,
|
||||
'score_threshold' => $scoreThreshold,
|
||||
'limit' => min($limit, 100),
|
||||
'offset' => $offset,
|
||||
'include_pca' => false,
|
||||
'include_chunks' => true,
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Unified search failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'query' => $query,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the MCP server is reachable and API key is valid.
|
||||
*
|
||||
* @return bool True if server is reachable and healthy
|
||||
*/
|
||||
public function isServerReachable(): bool {
|
||||
$status = $this->getStatus();
|
||||
return !isset($status['error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured MCP server internal URL (for API calls).
|
||||
*
|
||||
* @return string The internal base URL
|
||||
*/
|
||||
public function getServerUrl(): string {
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public MCP server URL (for display, OAuth audience).
|
||||
*
|
||||
* Falls back to internal URL if public URL not configured.
|
||||
*
|
||||
* @return string The public URL users/browsers see
|
||||
*/
|
||||
public function getPublicServerUrl(): string {
|
||||
return $this->config->getSystemValue('mcp_server_public_url', $this->baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OAuth client ID from system config.
|
||||
*
|
||||
* The Astrolabe app has its own OAuth client (separate from MCP server's client).
|
||||
* Client ID must be configured in config.php for OAuth functionality to work.
|
||||
*
|
||||
* @return string OAuth client ID or empty string if not configured
|
||||
*/
|
||||
public function getClientId(): string {
|
||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||
|
||||
if (empty($clientId)) {
|
||||
$this->logger->warning('astrolabe_client_id is not configured in config.php - OAuth functionality will not work');
|
||||
return '';
|
||||
}
|
||||
|
||||
$this->logger->debug('Using client ID from system config: ' . substr($clientId, 0, 8) . '...');
|
||||
return $clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered webhooks for a user.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* webhooks?: array<array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool
|
||||
* }>,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function listWebhooks(string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/webhooks',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to list webhooks', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new webhook registration.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $event Event type (e.g., "\\OCA\\Files::postCreate")
|
||||
* @param string $uri Callback URI for webhook notifications
|
||||
* @param array|null $eventFilter Optional event filter parameters
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function createWebhook(
|
||||
string $event,
|
||||
string $uri,
|
||||
?array $eventFilter,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$requestBody = [
|
||||
'event' => $event,
|
||||
'uri' => $uri,
|
||||
];
|
||||
|
||||
if ($eventFilter !== null) {
|
||||
$requestBody['event_filter'] = $eventFilter;
|
||||
}
|
||||
|
||||
$response = $this->httpClient->post(
|
||||
$this->baseUrl . '/api/v1/webhooks',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => $requestBody
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to create webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
'event' => $event,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webhook registration.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param int $webhookId Webhook ID to delete
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{success?: bool, error?: string}
|
||||
*/
|
||||
public function deleteWebhook(int $webhookId, string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->delete(
|
||||
$this->baseUrl . '/api/v1/webhooks/' . $webhookId,
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
// Successful DELETE may return 204 No Content
|
||||
if ($response->getStatusCode() === 204) {
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to delete webhook', [
|
||||
'error' => $e->getMessage(),
|
||||
'webhook_id' => $webhookId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of installed Nextcloud apps.
|
||||
*
|
||||
* Used to filter webhook presets based on available apps.
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* apps?: array<string>,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getInstalledApps(string $token): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/apps',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get installed apps', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk context (text, surrounding context, page image).
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $docType Document type
|
||||
* @param string $docId Document ID
|
||||
* @param int $start Start offset
|
||||
* @param int $end End offset
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array
|
||||
*/
|
||||
public function getChunkContext(
|
||||
string $docType,
|
||||
string $docId,
|
||||
int $start,
|
||||
int $end,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/chunk-context',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
],
|
||||
'query' => [
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $docId,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'context' => 500
|
||||
]
|
||||
]
|
||||
);
|
||||
$data = json_decode($response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get chunk context', [
|
||||
'error' => $e->getMessage(),
|
||||
'doc_type' => $docType,
|
||||
'doc_id' => $docId,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF page preview (server-side rendered).
|
||||
*
|
||||
* Renders a PDF page to PNG using PyMuPDF on the server.
|
||||
* This avoids client-side PDF.js issues with CSP and ES private fields.
|
||||
*
|
||||
* Requires OAuth bearer token for authentication.
|
||||
*
|
||||
* @param string $filePath WebDAV path to PDF file
|
||||
* @param int $page Page number (1-indexed)
|
||||
* @param float $scale Zoom factor (default: 2.0)
|
||||
* @param string $token OAuth bearer token
|
||||
* @return array{
|
||||
* success?: bool,
|
||||
* image?: string,
|
||||
* page_number?: int,
|
||||
* total_pages?: int,
|
||||
* error?: string
|
||||
* }
|
||||
*/
|
||||
public function getPdfPreview(
|
||||
string $filePath,
|
||||
int $page,
|
||||
float $scale,
|
||||
string $token,
|
||||
): array {
|
||||
try {
|
||||
$response = $this->httpClient->get(
|
||||
$this->baseUrl . '/api/v1/pdf-preview',
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token
|
||||
],
|
||||
'query' => [
|
||||
'file_path' => $filePath,
|
||||
'page' => $page,
|
||||
'scale' => $scale,
|
||||
]
|
||||
]
|
||||
);
|
||||
/** @var array{success?: bool, image?: string, page_number?: int, total_pages?: int, error?: string} $data */
|
||||
$data = json_decode((string)$response->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Invalid JSON response from server');
|
||||
}
|
||||
|
||||
return $data;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to get PDF preview', [
|
||||
'error' => $e->getMessage(),
|
||||
'file_path' => $filePath,
|
||||
'page' => $page,
|
||||
]);
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ICrypto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Storage service for per-user MCP OAuth tokens.
|
||||
*
|
||||
* Stores encrypted access and refresh tokens in user preferences.
|
||||
* Handles token expiration checking and refresh logic.
|
||||
*/
|
||||
class McpTokenStorage {
|
||||
/** Buffer time in seconds before actual expiry to trigger refresh */
|
||||
private const TOKEN_EXPIRY_BUFFER_SECONDS = 60;
|
||||
|
||||
private $config;
|
||||
private $crypto;
|
||||
private $db;
|
||||
private $logger;
|
||||
private ILockingProvider $lockingProvider;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
ICrypto $crypto,
|
||||
IDBConnection $db,
|
||||
LoggerInterface $logger,
|
||||
ILockingProvider $lockingProvider,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->crypto = $crypto;
|
||||
$this->db = $db;
|
||||
$this->logger = $logger;
|
||||
$this->lockingProvider = $lockingProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store MCP OAuth tokens for a user.
|
||||
*
|
||||
* Tokens are encrypted before storage to protect user credentials.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param string $accessToken OAuth access token
|
||||
* @param string $refreshToken OAuth refresh token
|
||||
* @param int $expiresAt Unix timestamp when token expires
|
||||
* @param int|null $issuedAt Unix timestamp when token was issued (for lifetime calculation)
|
||||
*/
|
||||
public function storeUserToken(
|
||||
string $userId,
|
||||
string $accessToken,
|
||||
string $refreshToken,
|
||||
int $expiresAt,
|
||||
?int $issuedAt = null,
|
||||
): void {
|
||||
try {
|
||||
$tokenData = [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt,
|
||||
'issued_at' => $issuedAt ?? time(),
|
||||
];
|
||||
|
||||
// Encrypt token data before storage
|
||||
$encrypted = $this->crypto->encrypt(json_encode($tokenData));
|
||||
|
||||
// Store in user preferences
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens',
|
||||
$encrypted
|
||||
);
|
||||
|
||||
$this->logger->info("Stored MCP OAuth tokens for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP OAuth tokens for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return array|null Token data array with keys: access_token, refresh_token, expires_at
|
||||
*/
|
||||
public function getUserToken(string $userId): ?array {
|
||||
try {
|
||||
$encrypted = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens',
|
||||
''
|
||||
);
|
||||
|
||||
if (empty($encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt and parse token data
|
||||
$decrypted = $this->crypto->decrypt($encrypted);
|
||||
$tokenData = json_decode($decrypted, true);
|
||||
|
||||
if (!$tokenData || !isset($tokenData['access_token'])) {
|
||||
$this->logger->warning("Invalid token data for user: $userId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tokenData;
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to retrieve MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired or about to expire.
|
||||
*
|
||||
* Uses TOKEN_EXPIRY_BUFFER_SECONDS buffer to refresh tokens before they actually expire.
|
||||
*
|
||||
* @param array $token Token data array
|
||||
* @return bool True if expired or about to expire
|
||||
*/
|
||||
public function isExpired(array $token): bool {
|
||||
if (!isset($token['expires_at'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expire early to avoid race conditions
|
||||
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lock path for a user's token refresh operation.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string Lock path
|
||||
*/
|
||||
private function getTokenRefreshLockPath(string $userId): string {
|
||||
return 'astrolabe/oauth/tokens/' . $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback while holding exclusive lock on user's token.
|
||||
*
|
||||
* Prevents race conditions between background job and on-demand token refresh.
|
||||
*
|
||||
* Note: Lock TTL is configured at the Nextcloud server level (default: 3600s).
|
||||
* If a process crashes while holding the lock, it will auto-expire after the TTL.
|
||||
* The ILockingProvider interface does not support per-call timeouts.
|
||||
*
|
||||
* @template T
|
||||
* @param string $userId User ID
|
||||
* @param callable(): T $callback
|
||||
* @return T
|
||||
* @throws LockedException If lock cannot be acquired
|
||||
*/
|
||||
public function withTokenLock(string $userId, callable $callback): mixed {
|
||||
$lockPath = $this->getTokenRefreshLockPath($userId);
|
||||
|
||||
$this->lockingProvider->acquireLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->lockingProvider->releaseLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored tokens for a user.
|
||||
*
|
||||
* Used when user disconnects or revokes access.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
*/
|
||||
public function deleteUserToken(string $userId): void {
|
||||
try {
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'oauth_tokens'
|
||||
);
|
||||
|
||||
$this->logger->info("Deleted MCP OAuth tokens for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to delete MCP tokens for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs that have OAuth tokens stored.
|
||||
*
|
||||
* Queries oc_preferences directly since IConfig doesn't support
|
||||
* listing all users with a specific key set.
|
||||
*
|
||||
* @param int $limit Maximum users to return (0 = no limit, for backward compatibility)
|
||||
* @param int $offset Starting offset for pagination
|
||||
* @return list<string> Array of user IDs
|
||||
*/
|
||||
public function getAllUsersWithTokens(int $limit = 0, int $offset = 0): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('userid')
|
||||
->from('preferences')
|
||||
->where($qb->expr()->eq('appid', $qb->createNamedParameter('astrolabe')))
|
||||
->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('oauth_tokens')));
|
||||
|
||||
if ($limit > 0) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset > 0) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
/** @var list<string> $userIds */
|
||||
$userIds = [];
|
||||
/** @psalm-suppress MixedAssignment - IResult::fetch() returns mixed */
|
||||
while (($row = $result->fetch()) !== false) {
|
||||
if (is_array($row) && isset($row['userid']) && is_string($row['userid'])) {
|
||||
$userIds[] = $row['userid'];
|
||||
}
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token for a user, handling expiration and refresh.
|
||||
*
|
||||
* This is a convenience method that combines token retrieval,
|
||||
* expiration checking, and automatic refresh if needed.
|
||||
*
|
||||
* Uses double-check locking pattern to prevent race conditions between
|
||||
* background job and on-demand refresh while minimizing lock contention.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param callable|null $refreshCallback Callback to refresh token if expired
|
||||
* Should accept (refreshToken) and return new token data
|
||||
* @return string|null Access token, or null if not available
|
||||
*/
|
||||
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
||||
// Quick check without lock (optimization)
|
||||
$token = $this->getUserToken($userId);
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If not expired, return immediately without lock
|
||||
if (!$this->isExpired($token)) {
|
||||
return $token['access_token'];
|
||||
}
|
||||
|
||||
// Token expired - acquire lock for refresh
|
||||
try {
|
||||
/**
|
||||
* @return string|null
|
||||
* @psalm-suppress MixedInferredReturnType
|
||||
*/
|
||||
return $this->withTokenLock($userId, function () use ($userId, $refreshCallback): ?string {
|
||||
// Re-check after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for the lock
|
||||
$currentToken = $this->getUserToken($userId);
|
||||
|
||||
if ($currentToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if another process already refreshed the token
|
||||
if (!$this->isExpired($currentToken)) {
|
||||
$this->logger->debug("Token already refreshed for user $userId while waiting for lock");
|
||||
/** @var string */
|
||||
return $currentToken['access_token'];
|
||||
}
|
||||
|
||||
// Still expired, perform refresh
|
||||
if ($refreshCallback && isset($currentToken['refresh_token'])) {
|
||||
try {
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $refreshCallback($refreshToken);
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$now = time();
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? 3600);
|
||||
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at for accurate lifetime calculation
|
||||
);
|
||||
|
||||
return $accessToken;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Delete stale token to prevent repeated refresh attempts
|
||||
$this->deleteUserToken($userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh callback returned null or invalid data - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expired and no refresh callback available - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Could not acquire lock - another process is refreshing
|
||||
// Return stale token rather than failing - caller can retry if needed
|
||||
$this->logger->warning("Could not acquire token lock for user $userId, returning stale token");
|
||||
/** @var string|null $staleToken */
|
||||
$staleToken = $token['access_token'] ?? null;
|
||||
return $staleToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store app password for background sync.
|
||||
*
|
||||
* App passwords are encrypted before storage and used as an alternative
|
||||
* to OAuth refresh tokens for background sync operations.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param string $appPassword Nextcloud app password
|
||||
*/
|
||||
public function storeBackgroundSyncPassword(
|
||||
string $userId,
|
||||
string $appPassword,
|
||||
): void {
|
||||
try {
|
||||
// Encrypt app password before storage
|
||||
$encrypted = $this->crypto->encrypt($appPassword);
|
||||
|
||||
// Store in user preferences
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_password',
|
||||
$encrypted
|
||||
);
|
||||
|
||||
// Mark credential type
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_type',
|
||||
'app_password'
|
||||
);
|
||||
|
||||
// Store provisioned timestamp
|
||||
$this->config->setUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_provisioned_at',
|
||||
(string)time()
|
||||
);
|
||||
|
||||
$this->logger->info("Stored background sync app password for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to store app password for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app password for background sync.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string|null Decrypted app password, or null if not set
|
||||
*/
|
||||
public function getBackgroundSyncPassword(string $userId): ?string {
|
||||
try {
|
||||
$encrypted = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_password',
|
||||
''
|
||||
);
|
||||
|
||||
if (empty($encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt app password
|
||||
return $this->crypto->decrypt($encrypted);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to retrieve app password for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete background sync app password for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
*/
|
||||
public function deleteBackgroundSyncPassword(string $userId): void {
|
||||
try {
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_password'
|
||||
);
|
||||
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_type'
|
||||
);
|
||||
|
||||
$this->config->deleteUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_provisioned_at'
|
||||
);
|
||||
|
||||
$this->logger->info("Deleted background sync app password for user: $userId");
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to delete app password for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has provisioned background sync access.
|
||||
*
|
||||
* Returns true if either OAuth tokens or app password is configured.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return bool True if background sync is provisioned
|
||||
*/
|
||||
public function hasBackgroundSyncAccess(string $userId): bool {
|
||||
// Check for OAuth tokens
|
||||
$oauthToken = $this->getUserToken($userId);
|
||||
if ($oauthToken !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for app password
|
||||
$appPassword = $this->getBackgroundSyncPassword($userId);
|
||||
return $appPassword !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background sync credential type for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string|null 'oauth' or 'app_password', or null if not provisioned
|
||||
*/
|
||||
public function getBackgroundSyncType(string $userId): ?string {
|
||||
$type = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_type',
|
||||
''
|
||||
);
|
||||
|
||||
// Fallback to OAuth if tokens exist but type not set
|
||||
if (empty($type) && $this->getUserToken($userId) !== null) {
|
||||
return 'oauth';
|
||||
}
|
||||
|
||||
return empty($type) ? null : $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background sync provisioned timestamp for a user.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return int|null Unix timestamp, or null if not provisioned
|
||||
*/
|
||||
public function getBackgroundSyncProvisionedAt(string $userId): ?int {
|
||||
$timestamp = $this->config->getUserValue(
|
||||
$userId,
|
||||
'astrolabe',
|
||||
'background_sync_provisioned_at',
|
||||
''
|
||||
);
|
||||
|
||||
return empty($timestamp) ? null : (int)$timestamp;
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
/**
|
||||
* Webhook preset configurations for common sync scenarios.
|
||||
*
|
||||
* Defines pre-configured webhook bundles that simplify webhook setup
|
||||
* for common use cases like Notes sync, Calendar sync, etc.
|
||||
*/
|
||||
class WebhookPresets {
|
||||
// File/Notes webhook events
|
||||
public const FILE_EVENT_CREATED = 'OCP\\Files\\Events\\Node\\NodeCreatedEvent';
|
||||
public const FILE_EVENT_WRITTEN = 'OCP\\Files\\Events\\Node\\NodeWrittenEvent';
|
||||
// Use BeforeNodeDeletedEvent instead of NodeDeletedEvent to get node.id
|
||||
// See: https://github.com/nextcloud/server/issues/56371
|
||||
public const FILE_EVENT_DELETED = 'OCP\\Files\\Events\\Node\\BeforeNodeDeletedEvent';
|
||||
|
||||
// Calendar webhook events
|
||||
public const CALENDAR_EVENT_CREATED = 'OCP\\Calendar\\Events\\CalendarObjectCreatedEvent';
|
||||
public const CALENDAR_EVENT_UPDATED = 'OCP\\Calendar\\Events\\CalendarObjectUpdatedEvent';
|
||||
public const CALENDAR_EVENT_DELETED = 'OCP\\Calendar\\Events\\CalendarObjectDeletedEvent';
|
||||
|
||||
// Tables webhook events (Nextcloud 30+)
|
||||
public const TABLES_EVENT_ROW_ADDED = 'OCA\\Tables\\Event\\RowAddedEvent';
|
||||
public const TABLES_EVENT_ROW_UPDATED = 'OCA\\Tables\\Event\\RowUpdatedEvent';
|
||||
public const TABLES_EVENT_ROW_DELETED = 'OCA\\Tables\\Event\\RowDeletedEvent';
|
||||
|
||||
// Forms webhook events (Nextcloud 30+)
|
||||
public const FORMS_EVENT_FORM_SUBMITTED = 'OCA\\Forms\\Events\\FormSubmittedEvent';
|
||||
|
||||
// NOTE: Deck and Contacts do NOT support webhooks
|
||||
// Their event classes do not implement IWebhookCompatibleEvent interface.
|
||||
// Alternative sync strategies:
|
||||
// - Deck: Use polling with ETag-based change detection
|
||||
// - Contacts: Use CardDAV sync-token mechanism for efficient syncing
|
||||
|
||||
/**
|
||||
* Get all available webhook presets.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* name: string,
|
||||
* description: string,
|
||||
* app: string,
|
||||
* events: array<array{event: string, filter: array}>
|
||||
* }>
|
||||
*/
|
||||
public static function getPresets(): array {
|
||||
return [
|
||||
'notes_sync' => [
|
||||
'name' => 'Notes Sync',
|
||||
'description' => 'Real-time synchronization for Notes app (create, update, delete)',
|
||||
'app' => 'notes',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => ['event.node.path' => '/^\\/.*\\/files\\/Notes\\//'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'calendar_sync' => [
|
||||
'name' => 'Calendar Sync',
|
||||
'description' => 'Real-time synchronization for Calendar events (create, update, delete)',
|
||||
'app' => 'calendar',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::CALENDAR_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'tables_sync' => [
|
||||
'name' => 'Tables Sync',
|
||||
'description' => 'Real-time synchronization for Tables rows (add, update, delete)',
|
||||
'app' => 'tables',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_ADDED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_UPDATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::TABLES_EVENT_ROW_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'forms_sync' => [
|
||||
'name' => 'Forms Sync',
|
||||
'description' => 'Real-time synchronization for Forms submissions',
|
||||
'app' => 'forms',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FORMS_EVENT_FORM_SUBMITTED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
'files_sync' => [
|
||||
'name' => 'All Files Sync',
|
||||
'description' => 'Real-time synchronization for all file operations (create, update, delete)',
|
||||
'app' => 'files',
|
||||
'events' => [
|
||||
[
|
||||
'event' => self::FILE_EVENT_CREATED,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_WRITTEN,
|
||||
'filter' => [],
|
||||
],
|
||||
[
|
||||
'event' => self::FILE_EVENT_DELETED,
|
||||
'filter' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a webhook preset by ID.
|
||||
*
|
||||
* @param string $presetId Preset identifier (e.g., "notes_sync", "calendar_sync")
|
||||
* @return array|null Preset configuration or null if not found
|
||||
*/
|
||||
public static function getPreset(string $presetId): ?array {
|
||||
$presets = self::getPresets();
|
||||
return $presets[$presetId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of event class names for a preset.
|
||||
*
|
||||
* @param string $presetId Preset identifier
|
||||
* @return array<string> List of fully qualified event class names
|
||||
*/
|
||||
public static function getPresetEvents(string $presetId): array {
|
||||
$preset = self::getPreset($presetId);
|
||||
if ($preset === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn ($eventConfig) => $eventConfig['event'],
|
||||
$preset['events']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter webhook presets to only show those for installed apps.
|
||||
*
|
||||
* @param array<string> $installedApps List of installed app names
|
||||
* @return array<string, array> Filtered presets
|
||||
*/
|
||||
public static function filterPresetsByInstalledApps(array $installedApps): array {
|
||||
$filtered = [];
|
||||
foreach (self::getPresets() as $presetId => $preset) {
|
||||
$appName = $preset['app'];
|
||||
// "files" is always available (core functionality)
|
||||
if ($appName === 'files' || in_array($appName, $installedApps)) {
|
||||
$filtered[$presetId] = $preset;
|
||||
}
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IConfig;
|
||||
use OCP\Settings\ISettings;
|
||||
|
||||
/**
|
||||
* Admin settings panel for Astrolabe.
|
||||
*
|
||||
* Displays semantic search service status, indexing metrics,
|
||||
* configuration, and provides administrative controls.
|
||||
*/
|
||||
class Admin implements ISettings {
|
||||
// Search settings keys and defaults
|
||||
public const SETTING_SEARCH_ALGORITHM = 'search_algorithm';
|
||||
public const SETTING_SEARCH_FUSION = 'search_fusion';
|
||||
public const SETTING_SEARCH_SCORE_THRESHOLD = 'search_score_threshold';
|
||||
public const SETTING_SEARCH_LIMIT = 'search_limit';
|
||||
|
||||
public const DEFAULT_SEARCH_ALGORITHM = 'hybrid';
|
||||
public const DEFAULT_SEARCH_FUSION = 'rrf';
|
||||
public const DEFAULT_SEARCH_SCORE_THRESHOLD = 0;
|
||||
public const DEFAULT_SEARCH_LIMIT = 20;
|
||||
|
||||
private $client;
|
||||
private $config;
|
||||
private $initialState;
|
||||
|
||||
public function __construct(
|
||||
McpServerClient $client,
|
||||
IConfig $config,
|
||||
IInitialState $initialState,
|
||||
) {
|
||||
$this->client = $client;
|
||||
$this->config = $config;
|
||||
$this->initialState = $initialState;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
public function getForm(): TemplateResponse {
|
||||
// Get configuration from config.php (local, fast)
|
||||
$serverUrl = $this->config->getSystemValue('mcp_server_url', '');
|
||||
$apiKeyConfigured = !empty($this->config->getSystemValue('mcp_server_api_key', ''));
|
||||
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
|
||||
$clientIdConfigured = !empty($clientId);
|
||||
$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
|
||||
$clientSecretConfigured = !empty($clientSecret);
|
||||
|
||||
// Load search settings from app config
|
||||
$searchSettings = [
|
||||
'algorithm' => $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_ALGORITHM,
|
||||
self::DEFAULT_SEARCH_ALGORITHM
|
||||
),
|
||||
'fusion' => $this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_FUSION,
|
||||
self::DEFAULT_SEARCH_FUSION
|
||||
),
|
||||
'scoreThreshold' => (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_SCORE_THRESHOLD,
|
||||
(string)self::DEFAULT_SEARCH_SCORE_THRESHOLD
|
||||
),
|
||||
'limit' => (int)$this->config->getAppValue(
|
||||
Application::APP_ID,
|
||||
self::SETTING_SEARCH_LIMIT,
|
||||
(string)self::DEFAULT_SEARCH_LIMIT
|
||||
),
|
||||
];
|
||||
|
||||
// Provide initial state for Vue.js frontend
|
||||
// MCP server data will be fetched asynchronously by Vue component
|
||||
$this->initialState->provideInitialState('admin-config', [
|
||||
'config' => [
|
||||
'serverUrl' => $serverUrl,
|
||||
'apiKeyConfigured' => $apiKeyConfigured,
|
||||
'clientIdConfigured' => $clientIdConfigured,
|
||||
'clientSecretConfigured' => $clientSecretConfigured,
|
||||
],
|
||||
'searchSettings' => $searchSettings,
|
||||
]);
|
||||
|
||||
$parameters = [];
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/admin',
|
||||
$parameters,
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getSection(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
/**
|
||||
* Admin settings section for Astrolabe.
|
||||
*
|
||||
* Creates a dedicated section in admin settings for semantic search administration.
|
||||
*/
|
||||
class AdminSection implements IIconSection {
|
||||
private $l;
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
|
||||
$this->l = $l;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getID(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The translated section name
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->l->t('Astrolabe');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up in list)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Section icon (SVG or image URL)
|
||||
*/
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath('astrolabe', 'app-dark.svg');
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\Settings\DeclarativeSettingsTypes;
|
||||
use OCP\Settings\IDeclarativeSettingsForm;
|
||||
|
||||
class AstrolabeAdminSettings implements IDeclarativeSettingsForm {
|
||||
public function __construct(
|
||||
private IL10N $l,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getSchema(): array {
|
||||
return [
|
||||
'id' => 'astrolabe-admin-settings',
|
||||
'priority' => 10,
|
||||
'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
|
||||
'section_id' => 'astrolabe',
|
||||
'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL,
|
||||
'title' => $this->l->t('MCP Server Configuration'),
|
||||
'description' => $this->l->t('Configure the connection to your Nextcloud MCP Server'),
|
||||
'doc_url' => 'https://github.com/cbcoutinho/nextcloud-mcp-server',
|
||||
|
||||
'fields' => [
|
||||
[
|
||||
'id' => 'mcp_server_url',
|
||||
'title' => $this->l->t('MCP Server URL'),
|
||||
'description' => $this->l->t('The base URL of your Nextcloud MCP Server instance (e.g., http://localhost:8000)'),
|
||||
'type' => DeclarativeSettingsTypes::URL,
|
||||
'placeholder' => 'http://localhost:8000',
|
||||
'default' => '',
|
||||
],
|
||||
[
|
||||
'id' => 'mcp_server_api_key',
|
||||
'title' => $this->l->t('API Key'),
|
||||
'description' => $this->l->t('Authentication key for the MCP server (leave empty if not required)'),
|
||||
'type' => DeclarativeSettingsTypes::PASSWORD,
|
||||
'placeholder' => $this->l->t('Enter API key'),
|
||||
'default' => '',
|
||||
],
|
||||
[
|
||||
'id' => 'astrolabe_client_id',
|
||||
'title' => $this->l->t('OAuth Client ID'),
|
||||
'description' => $this->l->t('The OAuth client ID for Astrolabe (required for multi-user deployments)'),
|
||||
'type' => DeclarativeSettingsTypes::TEXT,
|
||||
'placeholder' => $this->l->t('Enter OAuth client ID'),
|
||||
'default' => '',
|
||||
],
|
||||
[
|
||||
'id' => 'astrolabe_client_secret',
|
||||
'title' => $this->l->t('OAuth Client Secret'),
|
||||
'description' => $this->l->t('Optional: Client secret for OAuth. If not set, PKCE will be used as fallback.'),
|
||||
'type' => DeclarativeSettingsTypes::PASSWORD,
|
||||
'placeholder' => $this->l->t('Enter client secret (optional)'),
|
||||
'default' => '',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
-238
@@ -1,238 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Settings\ISettings;
|
||||
|
||||
/**
|
||||
* Personal settings panel for Astrolabe.
|
||||
*
|
||||
* Displays semantic search status, background indexing access,
|
||||
* and provides controls for managing content indexing.
|
||||
*
|
||||
* Uses OAuth PKCE flow - each user must authorize background access.
|
||||
*/
|
||||
class Personal implements ISettings {
|
||||
private $client;
|
||||
private $userSession;
|
||||
private $initialState;
|
||||
private $tokenStorage;
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(
|
||||
McpServerClient $client,
|
||||
IUserSession $userSession,
|
||||
IInitialState $initialState,
|
||||
McpTokenStorage $tokenStorage,
|
||||
IURLGenerator $urlGenerator,
|
||||
) {
|
||||
$this->client = $client;
|
||||
$this->userSession = $userSession;
|
||||
$this->initialState = $initialState;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TemplateResponse
|
||||
*/
|
||||
public function getForm(): TemplateResponse {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new TemplateResponse(Application::APP_ID, 'settings/error', [
|
||||
'error' => 'User not authenticated'
|
||||
], TemplateResponse::RENDER_AS_BLANK);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Fetch server status to determine auth mode
|
||||
$serverStatus = $this->client->getStatus();
|
||||
|
||||
// Check for server connection error
|
||||
if (isset($serverStatus['error'])) {
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/error',
|
||||
[
|
||||
'error' => 'Cannot connect to MCP server',
|
||||
'details' => $serverStatus['error'],
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// Get auth mode from server (defaults to oauth if not specified)
|
||||
$authMode = $serverStatus['auth_mode'] ?? 'oauth';
|
||||
$supportsAppPasswords = $serverStatus['supports_app_passwords'] ?? false;
|
||||
|
||||
// Check if user has MCP OAuth token
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
// For multi_user_basic mode with app password support (hybrid mode)
|
||||
// User needs BOTH:
|
||||
// 1. OAuth token for Astrolabe→MCP API calls (stored in McpTokenStorage)
|
||||
// 2. App password for MCP→Nextcloud background sync
|
||||
if ($authMode === 'multi_user_basic' && $supportsAppPasswords) {
|
||||
// Check both credentials
|
||||
$hasOAuthToken = ($token !== null && !$this->tokenStorage->isExpired($token));
|
||||
// In hybrid mode, check specifically for app password (not general background access)
|
||||
// because MCP server needs the app password for background sync
|
||||
$hasAppPassword = ($this->tokenStorage->getBackgroundSyncPassword($userId) !== null);
|
||||
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
// OAuth URL for Astrolabe's own OAuth controller (NOT MCP server's browser OAuth)
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
// Consolidated template parameters (camelCase convention)
|
||||
$parameters = [
|
||||
'userId' => $userId,
|
||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||
'serverStatus' => $serverStatus,
|
||||
'authMode' => $authMode,
|
||||
'supportsAppPasswords' => $supportsAppPasswords,
|
||||
'session' => null, // No session in hybrid mode
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
// OAuth token status (for Astrolabe→MCP API calls)
|
||||
'hasOAuthToken' => $hasOAuthToken,
|
||||
'oauthUrl' => $oauthUrl,
|
||||
// App password status (for MCP→Nextcloud background sync)
|
||||
'hasBackgroundAccess' => $hasAppPassword,
|
||||
'backgroundAccessGranted' => $hasAppPassword, // Legacy alias
|
||||
'backgroundSyncType' => $backgroundSyncType,
|
||||
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||
'requesttoken' => \OCP\Util::callRegister(),
|
||||
];
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/personal',
|
||||
$parameters,
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
// For OAuth modes, if no token or token is expired, show OAuth authorization UI
|
||||
elseif (!$token || $this->tokenStorage->isExpired($token)) {
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/oauth-required',
|
||||
[
|
||||
'oauth_url' => $oauthUrl,
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
'has_expired' => ($token !== null), // true if token exists but expired
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// User has valid token - fetch data from MCP server
|
||||
$accessToken = $token['access_token'];
|
||||
|
||||
// Fetch server status (public endpoint, no token needed)
|
||||
$serverStatus = $this->client->getStatus();
|
||||
|
||||
// Fetch user session data (requires token)
|
||||
$userSession = $this->client->getUserSession($userId, $accessToken);
|
||||
|
||||
// Check for server connection error
|
||||
if (isset($serverStatus['error'])) {
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/error',
|
||||
[
|
||||
'error' => 'Cannot connect to MCP server',
|
||||
'details' => $serverStatus['error'],
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// Check for authentication error (invalid/expired token)
|
||||
if (isset($userSession['error'])) {
|
||||
// Token might be invalid - delete it and show OAuth UI
|
||||
$this->tokenStorage->deleteUserToken($userId);
|
||||
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/oauth-required',
|
||||
[
|
||||
'oauth_url' => $oauthUrl,
|
||||
'server_url' => $this->client->getPublicServerUrl(),
|
||||
'has_expired' => true,
|
||||
'error_message' => 'Your session has expired. Please sign in again.',
|
||||
],
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
// Check background sync credential status
|
||||
$hasBackgroundAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
||||
$backgroundSyncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
||||
$backgroundSyncProvisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
// OAuth URL for standard OAuth mode (in case user needs to re-authorize)
|
||||
$oauthUrl = $this->urlGenerator->linkToRoute('astrolabe.oauth.initiateOAuth');
|
||||
|
||||
// Provide initial state for Vue.js frontend (if needed)
|
||||
$this->initialState->provideInitialState('user-data', [
|
||||
'userId' => $userId,
|
||||
'serverStatus' => $serverStatus,
|
||||
'session' => $userSession,
|
||||
]);
|
||||
|
||||
// Consolidated template parameters (camelCase convention)
|
||||
$parameters = [
|
||||
'userId' => $userId,
|
||||
'serverUrl' => $this->client->getPublicServerUrl(),
|
||||
'serverStatus' => $serverStatus,
|
||||
'session' => $userSession,
|
||||
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
|
||||
// OAuth status
|
||||
'hasOAuthToken' => true,
|
||||
'oauthUrl' => $oauthUrl,
|
||||
// Background sync status
|
||||
'hasBackgroundAccess' => $hasBackgroundAccess,
|
||||
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false, // Legacy
|
||||
'backgroundSyncType' => $backgroundSyncType,
|
||||
'backgroundSyncProvisionedAt' => $backgroundSyncProvisionedAt,
|
||||
'requesttoken' => \OCP\Util::callRegister(),
|
||||
];
|
||||
|
||||
return new TemplateResponse(
|
||||
Application::APP_ID,
|
||||
'settings/personal',
|
||||
$parameters,
|
||||
TemplateResponse::RENDER_AS_BLANK
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getSection(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Settings;
|
||||
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Settings\IIconSection;
|
||||
|
||||
/**
|
||||
* Personal settings section for Astrolabe.
|
||||
*
|
||||
* Creates a dedicated section in personal settings for semantic search configuration.
|
||||
*/
|
||||
class PersonalSection implements IIconSection {
|
||||
private $l;
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
|
||||
$this->l = $l;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The section ID
|
||||
*/
|
||||
public function getID(): string {
|
||||
return 'astrolabe';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The translated section name
|
||||
*/
|
||||
public function getName(): string {
|
||||
return $this->l->t('Astrolabe');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Priority (lower = higher up in list, 0-99)
|
||||
*/
|
||||
public function getPriority(): int {
|
||||
return 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Section icon (SVG or image URL)
|
||||
*/
|
||||
public function getIcon(): string {
|
||||
return $this->urlGenerator->imagePath('astrolabe', 'app-dark.svg');
|
||||
}
|
||||
}
|
||||
Vendored
-149
@@ -1,149 +0,0 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "astrolabe",
|
||||
"version": "0.0.1",
|
||||
"description": "Manage the MCP Server from within Nextcloud UI",
|
||||
"license": {
|
||||
"name": "agpl"
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"basic_auth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
},
|
||||
"bearer_auth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"OCSMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status",
|
||||
"statuscode"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"statuscode": {
|
||||
"type": "integer"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"totalitems": {
|
||||
"type": "string"
|
||||
},
|
||||
"itemsperpage": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/ocs/v2.php/apps/astrolabe/api": {
|
||||
"get": {
|
||||
"operationId": "api-index",
|
||||
"summary": "An example API endpoint",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Data returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
-10450
File diff suppressed because it is too large
Load Diff
Vendored
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.10.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
"npm": "^10.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite --mode development build",
|
||||
"watch": "vite --mode development build --watch",
|
||||
"lint": "eslint src",
|
||||
"stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css"
|
||||
},
|
||||
"type": "module",
|
||||
"browserslist": [
|
||||
"extends @nextcloud/browserslist-config"
|
||||
],
|
||||
"dependencies": {
|
||||
"@nextcloud/axios": "^2.5.1",
|
||||
"@nextcloud/dialogs": "^7.2.0",
|
||||
"@nextcloud/initial-state": "^3.0.0",
|
||||
"@nextcloud/l10n": "^3.1.0",
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
"plotly.js-dist-min": "^3.0.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nextcloud/browserslist-config": "3.1.2",
|
||||
"@nextcloud/eslint-config": "8.4.2",
|
||||
"@nextcloud/stylelint-config": "3.1.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"sass-embedded": "^1.97.1",
|
||||
"terser": "5.44.1",
|
||||
"vite": "7.2.7"
|
||||
}
|
||||
}
|
||||
-488
@@ -1,488 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<files psalm-version="5.26.1@d747f6500b38ac4f7dfc5edbcae6e4b637d7add0">
|
||||
<file src="lib/Controller/ApiController.php">
|
||||
<DeprecatedMethod>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
<code><![CDATA[setAppValue]]></code>
|
||||
</DeprecatedMethod>
|
||||
<InvalidArrayOffset>
|
||||
<code><![CDATA[$result['coordinates_3d']]]></code>
|
||||
<code><![CDATA[$result['pca_variance']]]></code>
|
||||
<code><![CDATA[$result['query_coords']]]></code>
|
||||
<code><![CDATA[$webhook['eventFilter']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[!empty($eventConfig['filter']) ? $eventConfig['filter'] : null]]></code>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
<code><![CDATA[$algorithm]]></code>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
<code><![CDATA[$fusion]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$data['algorithm']]]></code>
|
||||
<code><![CDATA[$data['fusion']]]></code>
|
||||
<code><![CDATA[$data['limit']]]></code>
|
||||
<code><![CDATA[$data['scoreThreshold']]]></code>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
<code><![CDATA[$eventConfig['filter']]]></code>
|
||||
<code><![CDATA[$presetEvent['event']]]></code>
|
||||
<code><![CDATA[$presetEvent['event']]]></code>
|
||||
<code><![CDATA[$presetEvent['filter']]]></code>
|
||||
<code><![CDATA[$presetEvent['filter']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
<code><![CDATA[$algorithm]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$eventConfig]]></code>
|
||||
<code><![CDATA[$fusion]]></code>
|
||||
<code><![CDATA[$presetEvent]]></code>
|
||||
<code><![CDATA[$presetEvent]]></code>
|
||||
<code><![CDATA[$presetFilter]]></code>
|
||||
<code><![CDATA[$presetFilter]]></code>
|
||||
<code><![CDATA[$response['coordinates_3d']]]></code>
|
||||
<code><![CDATA[$response['pca_variance']]]></code>
|
||||
<code><![CDATA[$response['query_coords']]]></code>
|
||||
<code><![CDATA[$webhookFilter]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyUndefinedArrayOffset>
|
||||
<code><![CDATA[$webhook['event']]]></code>
|
||||
<code><![CDATA[$webhook['event']]]></code>
|
||||
<code><![CDATA[$webhook['event']]]></code>
|
||||
<code><![CDATA[$webhook['id']]]></code>
|
||||
</PossiblyUndefinedArrayOffset>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[!$token]]></code>
|
||||
<code><![CDATA[empty($webhook['eventFilter'])]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<TypeDoesNotContainType>
|
||||
<code><![CDATA[is_array($status)]]></code>
|
||||
<code><![CDATA[is_array($status)]]></code>
|
||||
</TypeDoesNotContainType>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[ApiController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Controller/CredentialsController.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$body['error']]]></code>
|
||||
<code><![CDATA[$body['success']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$body]]></code>
|
||||
<code><![CDATA[$error]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[$body['success'] ?? false]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[CredentialsController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Controller/OauthController.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$authEndpoint]]></code>
|
||||
<code><![CDATA[$codeVerifier]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$internalBaseUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$tokenData['access_token']]]></code>
|
||||
<code><![CDATA[$tokenData['refresh_token'] ?? '']]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
<code><![CDATA[$userId]]></code>
|
||||
<code><![CDATA[time() + ($tokenData['expires_in'] ?? 3600)]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$discovery['authorization_endpoint']]]></code>
|
||||
<code><![CDATA[$discovery['token_endpoint']]]></code>
|
||||
<code><![CDATA[$discovery['token_endpoint']]]></code>
|
||||
<code><![CDATA[$statusData['auth_mode']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$authEndpoint]]></code>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$codeVerifier]]></code>
|
||||
<code><![CDATA[$discovery]]></code>
|
||||
<code><![CDATA[$discovery]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$mcpServerPublicUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$postData['client_secret']]]></code>
|
||||
<code><![CDATA[$statusData]]></code>
|
||||
<code><![CDATA[$statusData]]></code>
|
||||
<code><![CDATA[$storedState]]></code>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
<code><![CDATA[$userId]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[array]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$authEndpoint]]></code>
|
||||
<code><![CDATA[$tokenData['expires_in'] ?? 3600]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$responseBody]]></code>
|
||||
<code><![CDATA[$responseBody]]></code>
|
||||
<code><![CDATA[$statusResponse->getBody()]]></code>
|
||||
<code><![CDATA[$statusResponse->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[$error]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[OauthController]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Listener/AstrolabeAdminSettingsListener.php">
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$value]]></code>
|
||||
<code><![CDATA[$value]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<RedundantCondition>
|
||||
<code><![CDATA[$event instanceof DeclarativeSettingsSetValueEvent]]></code>
|
||||
</RedundantCondition>
|
||||
</file>
|
||||
<file src="lib/Search/SemanticSearchProvider.php">
|
||||
<DeprecatedMethod>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
</DeprecatedMethod>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$chunkNum]]></code>
|
||||
<code><![CDATA[$docType]]></code>
|
||||
<code><![CDATA[$mimeType]]></code>
|
||||
<code><![CDATA[$result['page_count']]]></code>
|
||||
<code><![CDATA[$result['page_number']]]></code>
|
||||
<code><![CDATA[$result['total_chunks']]]></code>
|
||||
<code><![CDATA[$title]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$chunkEnd]]></code>
|
||||
<code><![CDATA[$chunkNum]]></code>
|
||||
<code><![CDATA[$chunkStart]]></code>
|
||||
<code><![CDATA[$docType]]></code>
|
||||
<code><![CDATA[$docType]]></code>
|
||||
<code><![CDATA[$id]]></code>
|
||||
<code><![CDATA[$mimeType]]></code>
|
||||
<code><![CDATA[$params['board_id']]]></code>
|
||||
<code><![CDATA[$params['page_number']]]></code>
|
||||
<code><![CDATA[$params['path']]]></code>
|
||||
<code><![CDATA[$params['title']]]></code>
|
||||
<code><![CDATA[$score]]></code>
|
||||
<code><![CDATA[$title]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$result['chunk_index']]]></code>
|
||||
<code><![CDATA[$score]]></code>
|
||||
</MixedOperand>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[$cursor]]></code>
|
||||
<code><![CDATA[empty($results['error'])]]></code>
|
||||
<code><![CDATA[empty($status['error'])]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
</file>
|
||||
<file src="lib/Service/IdpTokenRefresher.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$discovery['token_endpoint']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']]]></code>
|
||||
<code><![CDATA[$statusData['oidc']['discovery_url']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$discovery]]></code>
|
||||
<code><![CDATA[$discoveryUrl]]></code>
|
||||
<code><![CDATA[$internalUrl]]></code>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
<code><![CDATA[$statusData]]></code>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
<code><![CDATA[$tokenEndpoint]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[array|null]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$mcpServerUrl]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$discoveryResponse->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$statusResponse->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Service/McpServerClient.php">
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$clientId]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$baseUrl]]></code>
|
||||
<code><![CDATA[$clientId]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[array]]></code>
|
||||
<code><![CDATA[array{
|
||||
* apps?: array<string>,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* results?: array,
|
||||
* pca_coordinates?: array,
|
||||
* algorithm_used?: string,
|
||||
* total_documents?: int,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* results?: array<array{
|
||||
* id?: string|int,
|
||||
* title?: string,
|
||||
* doc_type?: string,
|
||||
* excerpt?: string,
|
||||
* score?: float,
|
||||
* path?: string,
|
||||
* board_id?: int,
|
||||
* card_id?: int
|
||||
* }>,
|
||||
* total_found?: int,
|
||||
* algorithm_used?: string,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* session_id?: string,
|
||||
* background_access_granted?: bool,
|
||||
* background_access_details?: array,
|
||||
* idp_profile?: array,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* status?: string,
|
||||
* indexed_documents?: int,
|
||||
* pending_documents?: int,
|
||||
* last_sync_time?: string,
|
||||
* documents_per_second?: float,
|
||||
* errors_24h?: int,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* version?: string,
|
||||
* auth_mode?: string,
|
||||
* vector_sync_enabled?: bool,
|
||||
* uptime_seconds?: int,
|
||||
* management_api_version?: string,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{
|
||||
* webhooks?: array<array{
|
||||
* id?: int,
|
||||
* event?: string,
|
||||
* uri?: string,
|
||||
* event_filter?: array,
|
||||
* enabled?: bool
|
||||
* }>,
|
||||
* error?: string
|
||||
* }]]></code>
|
||||
<code><![CDATA[array{success?: bool, error?: string}]]></code>
|
||||
<code><![CDATA[array{success?: bool, message?: string, error?: string}]]></code>
|
||||
<code><![CDATA[string]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$data]]></code>
|
||||
<code><![CDATA[$this->config->getSystemValue('mcp_server_public_url', $this->baseUrl)]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyInvalidArgument>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
<code><![CDATA[$response->getBody()]]></code>
|
||||
</PossiblyInvalidArgument>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
<code><![CDATA[isServerReachable]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Service/McpTokenStorage.php">
|
||||
<InvalidReturnStatement>
|
||||
<code><![CDATA[$tokenData]]></code>
|
||||
</InvalidReturnStatement>
|
||||
<InvalidReturnType>
|
||||
<code><![CDATA[array|null]]></code>
|
||||
</InvalidReturnType>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$newTokenData]]></code>
|
||||
</MixedAssignment>
|
||||
<MixedInferredReturnType>
|
||||
<code><![CDATA[string|null]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$token['expires_at']]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$token['access_token']]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[!$token]]></code>
|
||||
<code><![CDATA[$refreshCallback]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
</file>
|
||||
<file src="lib/Service/WebhookPresets.php">
|
||||
<MissingClosureParamType>
|
||||
<code><![CDATA[$eventConfig]]></code>
|
||||
</MissingClosureParamType>
|
||||
<MissingClosureReturnType>
|
||||
<code><![CDATA[fn ($eventConfig) => $eventConfig['event']]]></code>
|
||||
</MissingClosureReturnType>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$preset['events']]]></code>
|
||||
</MixedArgument>
|
||||
<MixedArrayAccess>
|
||||
<code><![CDATA[$eventConfig['event']]]></code>
|
||||
</MixedArrayAccess>
|
||||
<MixedReturnTypeCoercion>
|
||||
<code><![CDATA[array<string>]]></code>
|
||||
<code><![CDATA[array_map(
|
||||
fn ($eventConfig) => $eventConfig['event'],
|
||||
$preset['events']
|
||||
)]]></code>
|
||||
</MixedReturnTypeCoercion>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[getPresetEvents]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Settings/Admin.php">
|
||||
<DeprecatedMethod>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
<code><![CDATA[getAppValue]]></code>
|
||||
</DeprecatedMethod>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$clientId]]></code>
|
||||
<code><![CDATA[$clientSecret]]></code>
|
||||
<code><![CDATA[$serverUrl]]></code>
|
||||
</MixedAssignment>
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
<UnusedProperty>
|
||||
<code><![CDATA[$client]]></code>
|
||||
</UnusedProperty>
|
||||
</file>
|
||||
<file src="lib/Settings/AdminSection.php">
|
||||
<UnusedClass>
|
||||
<code><![CDATA[AdminSection]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Settings/AstrolabeAdminSettings.php">
|
||||
<PossiblyUnusedMethod>
|
||||
<code><![CDATA[__construct]]></code>
|
||||
</PossiblyUnusedMethod>
|
||||
</file>
|
||||
<file src="lib/Settings/Personal.php">
|
||||
<InvalidArrayOffset>
|
||||
<code><![CDATA[$serverStatus['supports_app_passwords']]]></code>
|
||||
</InvalidArrayOffset>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$accessToken]]></code>
|
||||
<code><![CDATA[$supportsAppPasswords]]></code>
|
||||
</MixedAssignment>
|
||||
<RiskyTruthyFalsyComparison>
|
||||
<code><![CDATA[!$token]]></code>
|
||||
<code><![CDATA[$supportsAppPasswords]]></code>
|
||||
</RiskyTruthyFalsyComparison>
|
||||
<UnusedClass>
|
||||
<code><![CDATA[Personal]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
<file src="lib/Settings/PersonalSection.php">
|
||||
<UnusedClass>
|
||||
<code><![CDATA[PersonalSection]]></code>
|
||||
</UnusedClass>
|
||||
</file>
|
||||
</files>
|
||||
Vendored
-22
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<psalm
|
||||
errorLevel="1"
|
||||
resolveFromConfigFile="true"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="https://getpsalm.org/schema/config"
|
||||
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
|
||||
findUnusedBaselineEntry="true"
|
||||
findUnusedCode="true"
|
||||
phpVersion="8.1"
|
||||
errorBaseline="psalm-baseline.xml"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="lib" />
|
||||
<ignoreFiles>
|
||||
<directory name="vendor" />
|
||||
</ignoreFiles>
|
||||
</projectFiles>
|
||||
<extraFiles>
|
||||
<directory name="vendor"/>
|
||||
</extraFiles>
|
||||
</psalm>
|
||||
Vendored
-30
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__ . '/lib',
|
||||
__DIR__ . '/tests',
|
||||
])
|
||||
->withPhpSets(php80: true)
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
codeQuality: true,
|
||||
codingStyle: true,
|
||||
typeDeclarations: true,
|
||||
privatization: true,
|
||||
instanceOf: true,
|
||||
earlyReturn: true,
|
||||
strictBooleans: true,
|
||||
carbon: true,
|
||||
rectorPreset: true,
|
||||
phpunitCodeQuality: true,
|
||||
doctrineCodeQuality: true,
|
||||
symfonyCodeQuality: true,
|
||||
symfonyConfigs: true,
|
||||
twig: true,
|
||||
phpunit: true,
|
||||
);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 736 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB |
Vendored
-1432
File diff suppressed because it is too large
Load Diff
-18
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Admin settings page Vue app for Astrolabe.
|
||||
*
|
||||
* Mounts the AdminSettings Vue component for async loading
|
||||
* and improved UX.
|
||||
*/
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import AdminSettings from './components/admin/AdminSettings.vue'
|
||||
|
||||
const app = createApp(AdminSettings)
|
||||
|
||||
// Add translation methods globally
|
||||
app.config.globalProperties.t = t
|
||||
app.config.globalProperties.n = n
|
||||
|
||||
app.mount('#astrolabe-admin-settings')
|
||||
@@ -1,172 +0,0 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="markdown-viewer" v-html="html" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const html = ref('')
|
||||
|
||||
// Initialize markdown renderer
|
||||
const md = new MarkdownIt({
|
||||
html: false, // Disable HTML for security
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
function renderMarkdown(text) {
|
||||
if (!text) {
|
||||
html.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
html.value = md.render(text)
|
||||
} catch (error) {
|
||||
console.error('Markdown rendering error:', error)
|
||||
// Fallback to escaped plain text
|
||||
html.value = `<pre>${escapeHtml(text)}</pre>`
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
// Watch for content changes
|
||||
watch(() => props.content, (newContent) => {
|
||||
renderMarkdown(newContent)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.markdown-viewer {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-main-text);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
// Typography
|
||||
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
:deep(h1) { font-size: 2em; border-bottom: 1px solid var(--color-border); padding-bottom: 8px; }
|
||||
:deep(h2) { font-size: 1.5em; border-bottom: 1px solid var(--color-border); padding-bottom: 8px; }
|
||||
:deep(h3) { font-size: 1.25em; }
|
||||
:deep(h4) { font-size: 1em; }
|
||||
:deep(h5) { font-size: 0.875em; }
|
||||
:deep(h6) { font-size: 0.85em; color: var(--color-text-maxcontrast); }
|
||||
|
||||
// Paragraphs and spacing
|
||||
:deep(p) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// Lists
|
||||
:deep(ul), :deep(ol) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
// Code blocks
|
||||
:deep(code) {
|
||||
padding: 2px 6px;
|
||||
background: var(--color-background-dark);
|
||||
border-radius: var(--border-radius);
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background: var(--color-background-dark);
|
||||
padding: 16px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow-x: auto;
|
||||
margin-bottom: 16px;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Blockquotes
|
||||
:deep(blockquote) {
|
||||
margin: 0 0 16px 0;
|
||||
padding: 0 16px;
|
||||
border-left: 4px solid var(--color-primary-element);
|
||||
color: var(--color-text-maxcontrast);
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Links
|
||||
:deep(a) {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Tables
|
||||
:deep(table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(th), :deep(td) {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(th) {
|
||||
background: var(--color-background-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
:deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
// Images
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
<template>
|
||||
<div class="pdf-viewer">
|
||||
<div v-if="loading" class="loading-indicator">
|
||||
<NcLoadingIcon :size="64" />
|
||||
<p>{{ t('astrolabe', 'Loading PDF...') }}</p>
|
||||
</div>
|
||||
<div v-else-if="error" class="error-message">
|
||||
<AlertCircle :size="48" />
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<div v-else class="pdf-image-container">
|
||||
<img
|
||||
:src="`data:image/png;base64,${imageData}`"
|
||||
class="pdf-page-image"
|
||||
alt="PDF page" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* PDFViewer - Server-side PDF rendering component.
|
||||
*
|
||||
* Displays PDF pages as server-rendered PNG images, avoiding client-side
|
||||
* PDF.js issues with CSP worker restrictions and ES private field access
|
||||
* in Chromium browsers.
|
||||
*
|
||||
* The server uses PyMuPDF to render PDF pages to PNG images, which are
|
||||
* returned as base64-encoded data.
|
||||
*/
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||
|
||||
const props = defineProps({
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pageNumber: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 2.0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['loaded', 'error', 'page-rendered'])
|
||||
|
||||
// Reactive state
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const imageData = ref(null)
|
||||
const totalPages = ref(0)
|
||||
|
||||
/**
|
||||
* Fetch a PDF page from the server as a PNG image.
|
||||
*/
|
||||
async function loadPage() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Build request URL
|
||||
const url = generateUrl('/apps/astrolabe/api/pdf-preview')
|
||||
const params = {
|
||||
file_path: props.filePath,
|
||||
page: props.pageNumber,
|
||||
scale: props.scale,
|
||||
}
|
||||
|
||||
const response = await axios.get(url, { params })
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Failed to load PDF page')
|
||||
}
|
||||
|
||||
const data = response.data
|
||||
|
||||
// Update state
|
||||
imageData.value = data.image
|
||||
totalPages.value = data.total_pages
|
||||
|
||||
// Emit loaded event - App.vue uses this for navigation controls
|
||||
emit('loaded', { totalPages: data.total_pages })
|
||||
emit('page-rendered', { pageNumber: props.pageNumber })
|
||||
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
console.error('PDF load error:', err)
|
||||
|
||||
// Provide user-friendly error messages based on axios error structure
|
||||
const status = err.response?.status
|
||||
const serverError = err.response?.data?.error
|
||||
|
||||
if (status === 404) {
|
||||
error.value = t('astrolabe', 'PDF file not found')
|
||||
} else if (status === 401 || status === 403) {
|
||||
error.value = serverError || t('astrolabe', 'Authorization required to view PDF')
|
||||
} else if (err.code === 'ERR_NETWORK' || err.message?.includes('Network')) {
|
||||
error.value = t('astrolabe', 'Network error loading PDF')
|
||||
} else if (serverError) {
|
||||
error.value = serverError
|
||||
} else {
|
||||
error.value = t('astrolabe', 'Unable to load PDF page')
|
||||
}
|
||||
|
||||
emit('error', err)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch when file path or page number changes
|
||||
watch(() => [props.filePath, props.pageNumber], loadPage)
|
||||
|
||||
// Initial load
|
||||
onMounted(() => {
|
||||
loadPage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pdf-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 48px;
|
||||
|
||||
p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 48px;
|
||||
color: var(--color-error);
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-image-container {
|
||||
position: relative;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: var(--color-main-background);
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pdf-page-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pdf-viewer {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,695 +0,0 @@
|
||||
<template>
|
||||
<div class="admin-settings">
|
||||
<NcLoadingIcon v-if="loading" :size="64" class="loading-icon" />
|
||||
|
||||
<NcNoteCard v-else-if="error" type="error">
|
||||
<p><strong>{{ t('astrolabe', 'Cannot connect to MCP server') }}</strong></p>
|
||||
<p>{{ error }}</p>
|
||||
<p class="help-text">{{ t('astrolabe', 'Ensure MCP server is running and accessible. Check config.php for correct mcp_server_url.') }}</p>
|
||||
<NcButton variant="primary" @click="retryConnection">
|
||||
<template #icon>
|
||||
<Refresh :size="20" />
|
||||
</template>
|
||||
{{ t('astrolabe', 'Retry Connection') }}
|
||||
</NcButton>
|
||||
</NcNoteCard>
|
||||
|
||||
<template v-else>
|
||||
<!-- Service Status -->
|
||||
<div class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Service Status') }}</h3>
|
||||
<div class="status-card">
|
||||
<p><strong>{{ t('astrolabe', 'Version') }}:</strong> {{ serverStatus?.version || 'Unknown' }}</p>
|
||||
<p v-if="serverStatus?.uptime_seconds">
|
||||
<strong>{{ t('astrolabe', 'Uptime') }}:</strong> {{ formatUptime(serverStatus.uptime_seconds) }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ t('astrolabe', 'Semantic Search') }}:</strong>
|
||||
<span v-if="vectorSyncEnabled" class="status-badge status-enabled">
|
||||
{{ t('astrolabe', 'Enabled') }}
|
||||
</span>
|
||||
<span v-else class="status-badge status-disabled">
|
||||
{{ t('astrolabe', 'Disabled') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indexing Metrics -->
|
||||
<div v-if="vectorSyncEnabled && vectorSyncStatus" class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Indexing Metrics') }}</h3>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Status') }}</div>
|
||||
<div class="metric-value" :class="`status-${vectorSyncStatus.status}`">
|
||||
{{ vectorSyncStatus.status }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Indexed Documents') }}</div>
|
||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.indexed_documents) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Pending Documents') }}</div>
|
||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.pending_documents) }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">{{ t('astrolabe', 'Processing Rate') }}</div>
|
||||
<div class="metric-value">{{ formatNumber(vectorSyncStatus.documents_per_second, 1) }} docs/sec</div>
|
||||
</div>
|
||||
</div>
|
||||
<NcButton variant="secondary" @click="refreshStatus">
|
||||
<template #icon>
|
||||
<Refresh :size="20" />
|
||||
</template>
|
||||
{{ t('astrolabe', 'Refresh Status') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Management -->
|
||||
<div v-if="vectorSyncEnabled" class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Webhook Management') }}</h3>
|
||||
<p class="section-description">
|
||||
{{ t('astrolabe', 'Configure real-time synchronization for Nextcloud apps using webhooks. Webhooks provide instant updates to the MCP server when content changes.') }}
|
||||
</p>
|
||||
|
||||
<div v-if="webhooksLoading" class="loading-indicator">
|
||||
<NcLoadingIcon :size="32" />
|
||||
<p>{{ t('astrolabe', 'Loading webhook presets...') }}</p>
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-else-if="webhooksError" type="warning">
|
||||
<p><strong>{{ t('astrolabe', 'Authorization Required') }}</strong></p>
|
||||
<p v-if="webhooksError.includes('authorization')">
|
||||
{{ t('astrolabe', 'To manage webhooks, you must first authorize Astrolabe with the MCP server in your Personal Settings.') }}
|
||||
</p>
|
||||
<p v-else>{{ webhooksError }}</p>
|
||||
<div class="webhook-auth-actions">
|
||||
<NcButton variant="primary" @click="openPersonalSettings">
|
||||
{{ t('astrolabe', 'Go to Personal Settings') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</NcNoteCard>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="webhookPresets.length === 0" class="empty-state">
|
||||
<NcNoteCard type="info">
|
||||
<p>{{ t('astrolabe', 'No webhook presets available. Install supported apps (Notes, Calendar, Tables, Forms) to enable webhooks.') }}</p>
|
||||
</NcNoteCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="webhook-presets-grid">
|
||||
<div v-for="preset in webhookPresets" :key="preset.id" class="webhook-preset-card">
|
||||
<div class="preset-header">
|
||||
<h4>{{ preset.name }}</h4>
|
||||
<span :class="`preset-status preset-status-${preset.enabled ? 'enabled' : 'disabled'}`">
|
||||
{{ preset.enabled ? t('astrolabe', 'Enabled') : t('astrolabe', 'Disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="preset-description">{{ preset.description }}</p>
|
||||
<div class="preset-meta">
|
||||
<span class="preset-app">{{ t('astrolabe', 'App') }}: {{ preset.app }}</span>
|
||||
<span class="preset-events">{{ preset.events.length }} {{ t('astrolabe', 'events') }}</span>
|
||||
</div>
|
||||
<div class="preset-actions">
|
||||
<NcButton
|
||||
:variant="preset.enabled ? 'secondary' : 'primary'"
|
||||
:disabled="preset.toggling"
|
||||
@click="toggleWebhookPreset(preset)">
|
||||
{{ preset.toggling ? t('astrolabe', 'Please wait...') : (preset.enabled ? t('astrolabe', 'Disable') : t('astrolabe', 'Enable')) }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NcNoteCard type="info" class="webhook-info">
|
||||
<p><strong>{{ t('astrolabe', 'How Webhooks Work') }}</strong></p>
|
||||
<ul>
|
||||
<li>{{ t('astrolabe', 'Enable a preset to register webhooks for that app with the MCP server') }}</li>
|
||||
<li>{{ t('astrolabe', 'When content changes in Nextcloud, webhooks notify the MCP server instantly') }}</li>
|
||||
<li>{{ t('astrolabe', 'The MCP server updates its vector index in real-time for semantic search') }}</li>
|
||||
<li>{{ t('astrolabe', 'Disable a preset to stop receiving updates for that app') }}</li>
|
||||
</ul>
|
||||
</NcNoteCard>
|
||||
|
||||
<NcNoteCard type="warning" class="webhook-requirements">
|
||||
<p><strong>{{ t('astrolabe', 'Requirements') }}</strong></p>
|
||||
<ul>
|
||||
<li>{{ t('astrolabe', 'The webhook_listeners app must be installed and enabled in Nextcloud') }}</li>
|
||||
<li>{{ t('astrolabe', 'The MCP server must be reachable from your Nextcloud instance') }}</li>
|
||||
<li>{{ t('astrolabe', 'You must have authorized Astrolabe with the MCP server (see Personal Settings)') }}</li>
|
||||
</ul>
|
||||
</NcNoteCard>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Search Settings -->
|
||||
<div v-if="vectorSyncEnabled" class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'AI Search Provider Settings') }}</h3>
|
||||
<p class="section-description">
|
||||
{{ t('astrolabe', 'Configure the default search parameters for the AI Search provider in Nextcloud unified search.') }}
|
||||
</p>
|
||||
|
||||
<div class="settings-form">
|
||||
<NcSelect
|
||||
:model-value="selectedAlgorithmOption"
|
||||
:options="algorithmOptions"
|
||||
:input-label="t('astrolabe', 'Search Algorithm')"
|
||||
class="form-field"
|
||||
@update:model-value="settings.algorithm = $event ? $event.id : 'hybrid'" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Hybrid combines semantic understanding with keyword matching. Semantic finds conceptually similar content. BM25 matches exact keywords.') }}
|
||||
</p>
|
||||
|
||||
<NcSelect
|
||||
:model-value="selectedFusionOption"
|
||||
:options="fusionOptions"
|
||||
:input-label="t('astrolabe', 'Fusion Method')"
|
||||
class="form-field"
|
||||
@update:model-value="settings.fusion = $event ? $event.id : 'rrf'" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Only applies to hybrid search. RRF balances results well for most queries. DBSF may work better when keyword matches are over/under-weighted.') }}
|
||||
</p>
|
||||
|
||||
<div class="form-field">
|
||||
<label>{{ t('astrolabe', 'Minimum Score Threshold') }}: {{ settings.scoreThreshold }}%</label>
|
||||
<input
|
||||
v-model="settings.scoreThreshold"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
class="score-slider" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Filter out results below this relevance score. Set to 0 to show all results.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NcTextField
|
||||
v-model="settings.limit"
|
||||
:label="t('astrolabe', 'Maximum Results')"
|
||||
type="number"
|
||||
:min="5"
|
||||
:max="100"
|
||||
:step="5"
|
||||
class="form-field" />
|
||||
<p class="help-text">
|
||||
{{ t('astrolabe', 'Maximum number of results to return per search query (5-100).') }}
|
||||
</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<NcButton variant="primary" :disabled="saving" @click="saveSettings">
|
||||
{{ saving ? t('astrolabe', 'Saving...') : t('astrolabe', 'Save Settings') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentation -->
|
||||
<div class="admin-section">
|
||||
<h3>{{ t('astrolabe', 'Documentation') }}</h3>
|
||||
<ul class="doc-links">
|
||||
<li>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">
|
||||
{{ t('astrolabe', 'Configuration Guide') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank">
|
||||
{{ t('astrolabe', 'GitHub Repository') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { showError, showSuccess } from '@nextcloud/dialogs'
|
||||
|
||||
import {
|
||||
NcLoadingIcon,
|
||||
NcNoteCard,
|
||||
NcButton,
|
||||
NcSelect,
|
||||
NcTextField,
|
||||
} from '@nextcloud/vue'
|
||||
|
||||
import Refresh from 'vue-material-design-icons/Refresh.vue'
|
||||
|
||||
// Reactive state
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const serverStatus = ref(null)
|
||||
const vectorSyncStatus = ref(null)
|
||||
const vectorSyncEnabled = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// Webhook management state
|
||||
const webhooksLoading = ref(false)
|
||||
const webhooksError = ref(null)
|
||||
const webhookPresets = ref([])
|
||||
|
||||
// Load initial state from PHP
|
||||
const initialData = loadState('astrolabe', 'admin-config', {})
|
||||
const settings = ref(initialData.searchSettings || {
|
||||
algorithm: 'hybrid',
|
||||
fusion: 'rrf',
|
||||
scoreThreshold: 0,
|
||||
limit: 20,
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const algorithmOptions = computed(() => [
|
||||
{ id: 'hybrid', label: t('astrolabe', 'Hybrid (Recommended)') },
|
||||
{ id: 'semantic', label: t('astrolabe', 'Semantic Only') },
|
||||
{ id: 'bm25', label: t('astrolabe', 'Keyword (BM25) Only') },
|
||||
])
|
||||
|
||||
const fusionOptions = computed(() => [
|
||||
{ id: 'rrf', label: t('astrolabe', 'RRF - Reciprocal Rank Fusion (Recommended)') },
|
||||
{ id: 'dbsf', label: t('astrolabe', 'DBSF - Distribution-Based Score Fusion') },
|
||||
])
|
||||
|
||||
// Computed properties for NcSelect (converts between stored ID and option object)
|
||||
const selectedAlgorithmOption = computed(() =>
|
||||
algorithmOptions.value.find(opt => opt.id === settings.value.algorithm) || algorithmOptions.value[0],
|
||||
)
|
||||
|
||||
const selectedFusionOption = computed(() =>
|
||||
fusionOptions.value.find(opt => opt.id === settings.value.fusion) || fusionOptions.value[0],
|
||||
)
|
||||
|
||||
// Methods
|
||||
async function loadServerStatus() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Fetch server status asynchronously
|
||||
const [statusResponse, syncResponse] = await Promise.all([
|
||||
axios.get(generateUrl('/apps/astrolabe/api/admin/server-status')),
|
||||
axios.get(generateUrl('/apps/astrolabe/api/admin/vector-status')),
|
||||
])
|
||||
|
||||
if (statusResponse.data.success) {
|
||||
serverStatus.value = statusResponse.data.status
|
||||
vectorSyncEnabled.value = statusResponse.data.status?.vector_sync_enabled ?? false
|
||||
}
|
||||
|
||||
if (syncResponse.data.success) {
|
||||
vectorSyncStatus.value = syncResponse.data.status
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load server status:', err)
|
||||
error.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
await loadServerStatus()
|
||||
showSuccess(t('astrolabe', 'Status refreshed'))
|
||||
}
|
||||
|
||||
async function retryConnection() {
|
||||
// Clear error and retry loading server status
|
||||
error.value = null
|
||||
loading.value = true
|
||||
await loadServerStatus()
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
generateUrl('/apps/astrolabe/api/admin/search-settings'),
|
||||
settings.value,
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
showSuccess(t('astrolabe', 'Settings saved successfully'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save settings:', err)
|
||||
showError(t('astrolabe', 'Failed to save settings'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhookPresets() {
|
||||
webhooksLoading.value = true
|
||||
webhooksError.value = null
|
||||
|
||||
try {
|
||||
const response = await axios.get(generateUrl('/apps/astrolabe/api/admin/webhooks/presets'))
|
||||
|
||||
if (response.data.success) {
|
||||
// Convert presets object to array with IDs
|
||||
const presetsObj = response.data.presets
|
||||
webhookPresets.value = Object.keys(presetsObj).map(id => ({
|
||||
id,
|
||||
...presetsObj[id],
|
||||
toggling: false,
|
||||
}))
|
||||
} else {
|
||||
webhooksError.value = response.data.error || t('astrolabe', 'Failed to load webhook presets')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load webhook presets:', err)
|
||||
webhooksError.value = err.response?.data?.error || err.message || t('astrolabe', 'Network error')
|
||||
} finally {
|
||||
webhooksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWebhookPreset(preset) {
|
||||
preset.toggling = true
|
||||
|
||||
const endpoint = preset.enabled
|
||||
? `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/disable`
|
||||
: `/apps/astrolabe/api/admin/webhooks/presets/${preset.id}/enable`
|
||||
|
||||
try {
|
||||
const response = await axios.post(generateUrl(endpoint))
|
||||
|
||||
if (response.data.success) {
|
||||
// Toggle the enabled state
|
||||
preset.enabled = !preset.enabled
|
||||
showSuccess(response.data.message || (preset.enabled ? t('astrolabe', 'Webhook preset enabled') : t('astrolabe', 'Webhook preset disabled')))
|
||||
} else {
|
||||
showError(response.data.error || t('astrolabe', 'Failed to toggle webhook preset'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle webhook preset:', err)
|
||||
showError(err.response?.data?.error || err.message || t('astrolabe', 'Network error'))
|
||||
} finally {
|
||||
preset.toggling = false
|
||||
}
|
||||
}
|
||||
|
||||
function openPersonalSettings() {
|
||||
window.location.href = generateUrl('/settings/user/astrolabe')
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return t('astrolabe', '{hours} hours, {minutes} minutes', { hours, minutes })
|
||||
}
|
||||
|
||||
function formatNumber(value, decimals = 0) {
|
||||
if (value === undefined || value === null) return '0'
|
||||
return Number(value).toLocaleString(undefined, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(async () => {
|
||||
await loadServerStatus()
|
||||
// Load webhook presets if vector sync is enabled
|
||||
if (vectorSyncEnabled.value) {
|
||||
await loadWebhookPresets()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-settings {
|
||||
padding: 20px;
|
||||
max-width: 900px;
|
||||
|
||||
// Fix NcNoteCard icon sizing issues in Vue 3/@nextcloud/vue 9
|
||||
:deep(.notecard) {
|
||||
max-width: 100%;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.notecard__icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
margin: 40px auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
margin-bottom: 32px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&.status-enabled {
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.status-disabled {
|
||||
background: var(--color-background-dark);
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
|
||||
&.status-idle {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&.status-syncing {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
|
||||
.score-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.doc-links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary-element);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook management styles
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.webhook-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.webhook-preset-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 16px;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.preset-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-status {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
&.preset-status-enabled {
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.preset-status-disabled {
|
||||
background: var(--color-background-dark);
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.preset-description {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preset-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: 12px;
|
||||
|
||||
.preset-app {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.preset-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.webhook-info,
|
||||
.webhook-requirements {
|
||||
margin-top: 16px;
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Add translation methods globally
|
||||
app.config.globalProperties.t = t
|
||||
app.config.globalProperties.n = n
|
||||
|
||||
app.mount('#astrolabe')
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Personal settings page JavaScript for Astrolabe.
|
||||
*
|
||||
* Loads styles for the personal settings page and handles form interactions.
|
||||
*/
|
||||
|
||||
import './styles/settings.css'
|
||||
|
||||
// Wait for DOM to be ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Helper function to show error notifications
|
||||
function showError(message) {
|
||||
if (typeof OC !== 'undefined' && OC.Notification) {
|
||||
OC.Notification.showTemporary(message, { type: 'error' })
|
||||
} else {
|
||||
alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
if (typeof OC !== 'undefined' && OC.Notification) {
|
||||
OC.Notification.showTemporary(message, { type: 'success' })
|
||||
} else {
|
||||
alert(message)
|
||||
}
|
||||
}
|
||||
|
||||
// App password form with error handling
|
||||
const appPasswordForm = document.getElementById('mcp-app-password-form')
|
||||
if (appPasswordForm) {
|
||||
appPasswordForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault()
|
||||
const submitButton = document.getElementById('mcp-save-app-password-button')
|
||||
const originalText = submitButton.textContent
|
||||
|
||||
try {
|
||||
submitButton.disabled = true
|
||||
submitButton.textContent = t('astrolabe', 'Saving...')
|
||||
|
||||
const formData = new FormData(appPasswordForm)
|
||||
const response = await fetch(appPasswordForm.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showSuccess(t('astrolabe', 'Background sync access successfully provisioned!'))
|
||||
setTimeout(() => window.location.reload(), 1000)
|
||||
} else {
|
||||
showError(result.error || t('astrolabe', 'Failed to save app password. Please check that it is valid.'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('App password provisioning error:', error)
|
||||
showError(t('astrolabe', 'Unable to connect to server. Please check that the MCP server is running and try again.'))
|
||||
} finally {
|
||||
submitButton.disabled = false
|
||||
submitButton.textContent = originalText
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke form confirmation
|
||||
const revokeForm = document.getElementById('mcp-revoke-form')
|
||||
if (revokeForm) {
|
||||
revokeForm.addEventListener('submit', function(e) {
|
||||
if (!confirm(t('astrolabe', 'Are you sure you want to disable indexing? Your content will be removed from semantic search.'))) {
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Disconnect form confirmation
|
||||
const disconnectForm = document.getElementById('mcp-disconnect-form')
|
||||
if (disconnectForm) {
|
||||
disconnectForm.addEventListener('submit', function(e) {
|
||||
if (!confirm(t('astrolabe', 'Are you sure you want to disconnect from Astrolabe? You will need to re-authorize to use semantic search.'))) {
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke background access form with error handling
|
||||
const revokeBackgroundForm = document.getElementById('mcp-revoke-background-form')
|
||||
if (revokeBackgroundForm) {
|
||||
revokeBackgroundForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!confirm(t('astrolabe', 'Are you sure you want to revoke background sync access? The MCP server will no longer be able to access your Nextcloud data for background operations.'))) {
|
||||
return
|
||||
}
|
||||
|
||||
const submitButton = revokeBackgroundForm.querySelector('button[type="submit"]')
|
||||
const originalText = submitButton.textContent
|
||||
|
||||
try {
|
||||
submitButton.disabled = true
|
||||
submitButton.textContent = t('astrolabe', 'Revoking...')
|
||||
|
||||
const formData = new FormData(revokeBackgroundForm)
|
||||
const response = await fetch(revokeBackgroundForm.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showSuccess(t('astrolabe', 'Background sync access revoked successfully.'))
|
||||
setTimeout(() => window.location.reload(), 1000)
|
||||
} else {
|
||||
showError(result.error || t('astrolabe', 'Failed to revoke background sync access.'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Revoke error:', error)
|
||||
showError(t('astrolabe', 'Unable to connect to server. Your access may already be revoked, or the server may be down.'))
|
||||
} finally {
|
||||
submitButton.disabled = false
|
||||
submitButton.textContent = originalText
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
-290
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Astrolabe settings styles
|
||||
* Relies on Nextcloud's core .section class for layout
|
||||
*/
|
||||
|
||||
/* Info tables */
|
||||
.mcp-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: calc(var(--default-grid-baseline) * 3) 0;
|
||||
}
|
||||
|
||||
.mcp-info-table tr {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.mcp-info-table tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mcp-info-table td {
|
||||
padding: calc(var(--default-grid-baseline) * 2) 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.mcp-info-table td:first-child {
|
||||
width: 200px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-weight: 600;
|
||||
padding-inline-end: calc(var(--default-grid-baseline) * 4);
|
||||
}
|
||||
|
||||
.mcp-info-table td:last-child {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--default-grid-baseline) * 1.5);
|
||||
padding: calc(var(--default-grid-baseline) * 1.5) calc(var(--default-grid-baseline) * 3);
|
||||
border-radius: calc(var(--border-radius-element) * 1.5);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: var(--color-warning);
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background: var(--color-background-dark);
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--color-primary-element);
|
||||
color: var(--color-primary-element-text);
|
||||
}
|
||||
|
||||
/* Input groups */
|
||||
.mcp-input-group {
|
||||
display: flex;
|
||||
gap: calc(var(--default-grid-baseline) * 2);
|
||||
align-items: stretch;
|
||||
margin-top: calc(var(--default-grid-baseline) * 2);
|
||||
}
|
||||
|
||||
.mcp-input-group input[type='password'],
|
||||
.mcp-input-group input[type='text'] {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Revoke/warning sections */
|
||||
.mcp-revoke-section {
|
||||
margin-top: calc(var(--default-grid-baseline) * 4);
|
||||
padding: calc(var(--default-grid-baseline) * 4);
|
||||
background: var(--color-warning);
|
||||
border-radius: var(--border-radius-element);
|
||||
border-inline-start: calc(var(--default-grid-baseline)) solid var(--color-warning-text);
|
||||
}
|
||||
|
||||
/* Feature lists */
|
||||
.mcp-feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: calc(var(--default-grid-baseline) * 3) 0;
|
||||
}
|
||||
|
||||
.mcp-feature-list li {
|
||||
display: flex;
|
||||
gap: calc(var(--default-grid-baseline) * 3);
|
||||
padding: calc(var(--default-grid-baseline) * 2) 0;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mcp-feature-list .icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mcp-feature-list div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mcp-feature-list strong {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: calc(var(--default-grid-baseline));
|
||||
}
|
||||
|
||||
.mcp-feature-list p {
|
||||
margin: 0;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
/* Responsive tables */
|
||||
@media (max-width: 768px) {
|
||||
.mcp-info-table td:first-child,
|
||||
.mcp-info-table td:last-child {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mcp-info-table td:first-child {
|
||||
padding-bottom: calc(var(--default-grid-baseline));
|
||||
}
|
||||
|
||||
.mcp-info-table td:last-child {
|
||||
padding-top: calc(var(--default-grid-baseline));
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin settings forms */
|
||||
.mcp-settings-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.mcp-form-group {
|
||||
margin-bottom: calc(var(--default-grid-baseline) * 5);
|
||||
}
|
||||
|
||||
.mcp-form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: calc(var(--default-grid-baseline) * 2);
|
||||
}
|
||||
|
||||
.mcp-range {
|
||||
width: 100%;
|
||||
margin-top: calc(var(--default-grid-baseline) * 2);
|
||||
accent-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.mcp-form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--default-grid-baseline) * 4);
|
||||
margin-top: calc(var(--default-grid-baseline) * 6);
|
||||
padding-top: calc(var(--default-grid-baseline) * 5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Webhook preset cards */
|
||||
.mcp-preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: calc(var(--default-grid-baseline) * 4);
|
||||
margin: calc(var(--default-grid-baseline) * 4) 0;
|
||||
}
|
||||
|
||||
.mcp-preset-card {
|
||||
background: var(--color-background-dark);
|
||||
border-radius: var(--border-radius-container);
|
||||
padding: calc(var(--default-grid-baseline) * 4);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color var(--animation-slow), box-shadow var(--animation-slow);
|
||||
}
|
||||
|
||||
.mcp-preset-card:hover {
|
||||
border-color: var(--color-border-dark);
|
||||
box-shadow: 0 2px 8px var(--color-box-shadow);
|
||||
}
|
||||
|
||||
.mcp-preset-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(var(--default-grid-baseline) * 3);
|
||||
}
|
||||
|
||||
.mcp-preset-header h4 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mcp-preset-status {
|
||||
padding: calc(var(--default-grid-baseline)) calc(var(--default-grid-baseline) * 2.5);
|
||||
border-radius: calc(var(--border-radius-element) * 1.5);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mcp-status-enabled {
|
||||
background: var(--color-success);
|
||||
color: var(--color-success-text);
|
||||
}
|
||||
|
||||
.mcp-status-disabled {
|
||||
background: var(--color-background-darker);
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.mcp-preset-description {
|
||||
color: var(--color-text-maxcontrast);
|
||||
margin-bottom: calc(var(--default-grid-baseline) * 3);
|
||||
}
|
||||
|
||||
.mcp-preset-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: calc(var(--default-grid-baseline) * 3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-bottom: calc(var(--default-grid-baseline) * 3);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.mcp-preset-actions {
|
||||
display: flex;
|
||||
gap: calc(var(--default-grid-baseline) * 2);
|
||||
}
|
||||
|
||||
.mcp-preset-toggle {
|
||||
flex: 1;
|
||||
padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 4);
|
||||
border-radius: var(--border-radius-element);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--animation-quick);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mcp-preset-toggle.primary {
|
||||
background: var(--color-primary-element);
|
||||
color: var(--color-primary-element-text);
|
||||
}
|
||||
|
||||
.mcp-preset-toggle.primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-element-hover);
|
||||
}
|
||||
|
||||
.mcp-preset-toggle.secondary {
|
||||
background: var(--color-background-darker);
|
||||
color: var(--color-main-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.mcp-preset-toggle.secondary:hover:not(:disabled) {
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
.mcp-preset-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mcp-loading {
|
||||
text-align: center;
|
||||
padding: calc(var(--default-grid-baseline) * 5);
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-style: italic;
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: '@nextcloud/stylelint-config',
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCP\Util;
|
||||
|
||||
Util::addScript(Application::APP_ID, Application::APP_ID . '-main');
|
||||
Util::addStyle(Application::APP_ID, Application::APP_ID . '-main');
|
||||
|
||||
?>
|
||||
|
||||
<div id="astrolabe"></div>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin settings template for Astrolabe.
|
||||
*
|
||||
* Mounts the Vue.js admin settings component for async loading
|
||||
* and improved UX.
|
||||
*/
|
||||
|
||||
script('astrolabe', 'astrolabe-adminSettings');
|
||||
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
|
||||
?>
|
||||
|
||||
<div id="astrolabe-admin-settings" class="section">
|
||||
<!-- Vue component will be mounted here -->
|
||||
</div>
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Error template for MCP Server UI settings.
|
||||
*
|
||||
* Displayed when the MCP server cannot be reached or returns an error.
|
||||
*
|
||||
* @var array $_ Template parameters
|
||||
* @var string $_['error'] Error title
|
||||
* @var string $_['details'] Error details/message
|
||||
* @var string $_['server_url'] Configured server URL (optional)
|
||||
* @var string $_['help_text'] Additional help text (optional)
|
||||
*/
|
||||
?>
|
||||
|
||||
<div class="mcp-settings-error">
|
||||
<div class="notecard notecard-error">
|
||||
<h3>
|
||||
<span class="icon icon-error"></span>
|
||||
<?php p($_['error'] ?? 'Error'); ?>
|
||||
</h3>
|
||||
|
||||
<?php if (isset($_['details'])): ?>
|
||||
<p><strong><?php p($l->t('Details:')); ?></strong></p>
|
||||
<p><code><?php p($_['details']); ?></code></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_['server_url'])): ?>
|
||||
<p><strong><?php p($l->t('Server URL:')); ?></strong></p>
|
||||
<p><code><?php p($_['server_url']); ?></code></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_['help_text'])): ?>
|
||||
<p class="mcp-help-text"><?php p($_['help_text']); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<h4><?php p($l->t('Troubleshooting Steps:')); ?></h4>
|
||||
<ol>
|
||||
<li><?php p($l->t('Verify the MCP server is running and accessible')); ?></li>
|
||||
<li><?php p($l->t('Check that mcp_server_url in config.php is correct')); ?></li>
|
||||
<li><?php p($l->t('Ensure mcp_server_api_key matches the server configuration')); ?></li>
|
||||
<li><?php p($l->t('Check firewall rules and network connectivity')); ?></li>
|
||||
<li><?php p($l->t('Review MCP server logs for errors')); ?></li>
|
||||
</ol>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-018-nextcloud-php-app-for-settings-ui.md" target="_blank" class="button">
|
||||
<?php p($l->t('View Documentation')); ?>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* OAuth authorization required template.
|
||||
*
|
||||
* Shown when user needs to authorize Astrolabe for semantic search.
|
||||
* Implements OAuth 2.0 Authorization Code flow with PKCE.
|
||||
*
|
||||
* @var array $_ Template parameters
|
||||
* @var string $_['oauth_url'] URL to initiate OAuth flow
|
||||
* @var string $_['server_url'] Astrolabe service URL
|
||||
* @var bool $_['has_expired'] Whether token exists but is expired
|
||||
* @var string|null $_['error_message'] Optional error message to display
|
||||
*/
|
||||
|
||||
use OCP\Util;
|
||||
|
||||
Util::addStyle('astrolabe', 'astrolabe-personalSettings');
|
||||
?>
|
||||
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Astrolabe')); ?></h2>
|
||||
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content.')); ?></p>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_['error_message'])): ?>
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Session Expired')); ?></h2>
|
||||
<p><?php p($_['error_message']); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Enable Semantic Search')); ?></h2>
|
||||
|
||||
<?php if (isset($_['has_expired']) && $_['has_expired']): ?>
|
||||
<p>
|
||||
<?php p($l->t('Your authorization has expired. Please sign in again to continue using semantic search.')); ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p>
|
||||
<?php p($l->t('To search your content by meaning, Astrolabe needs permission to index your Nextcloud data.')); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<p>
|
||||
<strong><?php p($l->t('What happens next?')); ?></strong>
|
||||
</p>
|
||||
|
||||
<ol class="mcp-help-text">
|
||||
<li><?php p($l->t('Sign in to confirm your identity')); ?></li>
|
||||
<li><?php p($l->t('Grant permission to index your content')); ?></li>
|
||||
<li><?php p($l->t('Your content will be indexed for semantic search')); ?></li>
|
||||
<li><?php p($l->t('Start searching with natural language')); ?></li>
|
||||
</ol>
|
||||
|
||||
<h4><?php p($l->t('Content to be Indexed')); ?></h4>
|
||||
|
||||
<ul class="mcp-feature-list">
|
||||
<li>
|
||||
<span class="icon icon-files"></span>
|
||||
<div>
|
||||
<strong><?php p($l->t('Notes & Files')); ?></strong>
|
||||
<p><?php p($l->t('Your notes and documents will be searchable by meaning')); ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="icon icon-calendar"></span>
|
||||
<div>
|
||||
<strong><?php p($l->t('Calendar & Tasks')); ?></strong>
|
||||
<p><?php p($l->t('Find events and tasks with natural language queries')); ?></p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="icon icon-category-dashboard"></span>
|
||||
<div>
|
||||
<strong><?php p($l->t('Deck Cards')); ?></strong>
|
||||
<p><?php p($l->t('Search across your Deck boards and cards')); ?></p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="<?php p($_['oauth_url']); ?>" class="button primary">
|
||||
<span class="icon icon-play"></span>
|
||||
<?php if (isset($_['has_expired']) && $_['has_expired']): ?>
|
||||
<?php p($l->t('Sign In Again')); ?>
|
||||
<?php else: ?>
|
||||
<?php p($l->t('Enable Semantic Search')); ?>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<?php p($l->t('You can disable indexing at any time from this settings page.')); ?>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('About Astrolabe')); ?></h2>
|
||||
|
||||
<p>
|
||||
<?php p($l->t('Astrolabe enables semantic search - finding content by meaning rather than exact keywords. Ask questions like "meeting notes from last week" or "recipes with chicken" to find relevant documents.')); ?>
|
||||
</p>
|
||||
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Your content is processed to understand its meaning, enabling powerful natural language search across all your Nextcloud data.')); ?>
|
||||
</p>
|
||||
|
||||
<ul class="mcp-links">
|
||||
<li>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server" target="_blank" rel="noopener noreferrer">
|
||||
<span class="icon icon-external"></span>
|
||||
<?php p($l->t('Learn More')); ?>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,303 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Personal settings template for Astrolabe.
|
||||
*
|
||||
* Displays semantic search status, background indexing access,
|
||||
* and provides controls for managing content indexing.
|
||||
*
|
||||
* @var array $_ Template parameters
|
||||
* @var string $_['userId'] Current user ID
|
||||
* @var array $_['serverStatus'] Server status from API
|
||||
* @var array $_['session'] User session details from API
|
||||
* @var bool $_['vectorSyncEnabled'] Whether vector sync is enabled
|
||||
* @var bool $_['backgroundAccessGranted'] Whether user has granted background access
|
||||
* @var string $_['serverUrl'] Astrolabe service URL
|
||||
*/
|
||||
|
||||
// Get URL generator from Nextcloud's service container
|
||||
$urlGenerator = \OC::$server->getURLGenerator();
|
||||
|
||||
script('astrolabe', 'astrolabe-personalSettings');
|
||||
style('astrolabe', 'astrolabe-main'); // All CSS bundled into main
|
||||
?>
|
||||
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Astrolabe')); ?></h2>
|
||||
<p><?php p($l->t('AI-powered semantic search across your Nextcloud content. Find documents by meaning, not just keywords.')); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Service Status')); ?></h2>
|
||||
<table class="mcp-info-table">
|
||||
<tr>
|
||||
<td><?php p($l->t('Service URL')); ?></td>
|
||||
<td><code><?php p($_['serverUrl']); ?></code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><?php p($l->t('Version')); ?></td>
|
||||
<td><?php p($_['serverStatus']['version'] ?? 'Unknown'); ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Background Sync Access')); ?></h2>
|
||||
|
||||
<?php
|
||||
// Determine if hybrid mode (multi_user_basic + app passwords)
|
||||
// In hybrid mode, user needs BOTH OAuth AND app password to be "fully configured"
|
||||
$isHybridMode = ($_['authMode'] ?? '') === 'multi_user_basic' && !empty($_['supportsAppPasswords']);
|
||||
$hasOAuthToken = !empty($_['hasOAuthToken']);
|
||||
$hasBackgroundAccess = !empty($_['hasBackgroundAccess']) || !empty($_['backgroundAccessGranted']);
|
||||
|
||||
// In hybrid mode: both credentials required; otherwise just background access
|
||||
$isFullyConfigured = $isHybridMode ? ($hasOAuthToken && $hasBackgroundAccess) : $hasBackgroundAccess;
|
||||
?>
|
||||
<?php if ($isFullyConfigured): ?>
|
||||
<!-- Already configured -->
|
||||
<div class="mcp-background-status">
|
||||
<p>
|
||||
<span class="badge badge-success">
|
||||
<span class="icon icon-checkmark-white"></span>
|
||||
<?php p($l->t('Active')); ?>
|
||||
</span>
|
||||
</p>
|
||||
<table class="mcp-info-table">
|
||||
<tr>
|
||||
<td><?php p($l->t('Credential Type')); ?></td>
|
||||
<td>
|
||||
<?php if ($_['backgroundSyncType'] === 'app_password'): ?>
|
||||
<?php p($l->t('App Password')); ?>
|
||||
<?php else: ?>
|
||||
<?php p($l->t('OAuth Refresh Token')); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if ($_['backgroundSyncProvisionedAt']): ?>
|
||||
<tr>
|
||||
<td><?php p($l->t('Provisioned At')); ?></td>
|
||||
<td><?php p(date('c', $_['backgroundSyncProvisionedAt'])); ?></td>
|
||||
</tr>
|
||||
<?php elseif (isset($_['session']['background_access_details']['provisioned_at'])): ?>
|
||||
<tr>
|
||||
<td><?php p($l->t('Provisioned At')); ?></td>
|
||||
<td><?php p($_['session']['background_access_details']['provisioned_at']); ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_['session']['background_access_details']['scopes'])): ?>
|
||||
<tr>
|
||||
<td><?php p($l->t('Indexed Content')); ?></td>
|
||||
<td><code><?php p($_['session']['background_access_details']['scopes'] ?? 'N/A'); ?></code></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
|
||||
<div class="mcp-revoke-section">
|
||||
<?php if ($_['backgroundSyncType'] === 'app_password'): ?>
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.deleteCredentials')); ?>" id="mcp-revoke-background-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<button type="submit" class="button warning" id="mcp-revoke-background-button">
|
||||
<span class="icon icon-delete"></span>
|
||||
<?php p($l->t('Revoke Access')); ?>
|
||||
</button>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('This will revoke background sync access. The MCP server will no longer be able to access your Nextcloud data for background operations.')); ?>
|
||||
</p>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.api.revokeAccess')); ?>" id="mcp-revoke-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<button type="submit" class="button warning" id="mcp-revoke-button">
|
||||
<span class="icon icon-delete"></span>
|
||||
<?php p($l->t('Disable Indexing')); ?>
|
||||
</button>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('This will stop background indexing and remove your content from semantic search. You can re-enable it at any time.')); ?>
|
||||
</p>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Not configured - show provisioning options -->
|
||||
<?php if ($isHybridMode): ?>
|
||||
<!-- Hybrid mode: User needs BOTH OAuth AND app password -->
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('To use semantic search, you need to complete two setup steps:')); ?>
|
||||
</p>
|
||||
|
||||
<!-- Step 1: OAuth Authorization (for Astrolabe→MCP API calls) -->
|
||||
<div class="mcp-grant-section">
|
||||
<h4>
|
||||
<?php if (!empty($_['hasOAuthToken'])): ?>
|
||||
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php p($l->t('Step 1: Authorize Search Access')); ?>
|
||||
</h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Authorize Astrolabe to perform searches on your behalf.')); ?>
|
||||
</p>
|
||||
<?php if (empty($_['hasOAuthToken'])): ?>
|
||||
<a href="<?php p($_['oauthUrl']); ?>" class="button primary">
|
||||
<span class="icon icon-confirm"></span>
|
||||
<?php p($l->t('Authorize')); ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Search access authorized.')); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: App Password (for MCP→Nextcloud background sync) -->
|
||||
<div class="mcp-grant-section">
|
||||
<h4>
|
||||
<?php if (!empty($_['hasBackgroundAccess'])): ?>
|
||||
<span class="badge badge-success"><span class="icon icon-checkmark-white"></span> <?php p($l->t('Complete')); ?></span>
|
||||
<?php else: ?>
|
||||
<span class="badge badge-warning"><?php p($l->t('Required')); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php p($l->t('Step 2: Enable Background Indexing')); ?>
|
||||
</h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Provide an app password to allow background indexing of your content.')); ?>
|
||||
</p>
|
||||
<?php if (empty($_['hasBackgroundAccess'])): ?>
|
||||
<div class="mcp-app-password-steps">
|
||||
<p>
|
||||
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
|
||||
<?php p($l->t('Generate app password in Security settings')); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<div class="mcp-input-group">
|
||||
<input type="password" name="appPassword" id="mcp-app-password-input"
|
||||
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
|
||||
required>
|
||||
<button type="submit" class="button primary" id="mcp-save-app-password-button">
|
||||
<span class="icon icon-checkmark"></span>
|
||||
<?php p($l->t('Save')); ?>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p><span class="icon icon-checkmark"></span> <?php p($l->t('Background indexing enabled.')); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Standard OAuth or BasicAuth mode -->
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Enable background sync to allow the MCP server to access your Nextcloud data for background operations like content indexing.')); ?>
|
||||
</p>
|
||||
|
||||
<div class="mcp-grant-section">
|
||||
<h4><?php p($l->t('Option 1: OAuth Refresh Token (Recommended for Future)')); ?></h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('When Nextcloud fully supports OAuth for app APIs. Currently waiting for upstream PR to merge.')); ?>
|
||||
</p>
|
||||
<a href="<?php p($_['oauthUrl']); ?>" class="button">
|
||||
<span class="icon icon-confirm"></span>
|
||||
<?php p($l->t('Authorize via OAuth')); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mcp-grant-section">
|
||||
<h4><?php p($l->t('Option 2: App Password (Works Today - Recommended)')); ?></h4>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('Generate an app password in Security settings and provide it below. This is the recommended interim solution.')); ?>
|
||||
</p>
|
||||
|
||||
<div class="mcp-app-password-steps">
|
||||
<p><strong><?php p($l->t('Step 1:')); ?></strong>
|
||||
<a href="<?php p($urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'security'])); ?>" target="_blank">
|
||||
<?php p($l->t('Generate app password in Security settings')); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p><strong><?php p($l->t('Step 2:')); ?></strong> <?php p($l->t('Enter the app password below:')); ?></p>
|
||||
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.credentials.storeAppPassword')); ?>" id="mcp-app-password-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<div class="mcp-input-group">
|
||||
<input type="password" name="appPassword" id="mcp-app-password-input"
|
||||
placeholder="xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
pattern="[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}"
|
||||
required>
|
||||
<button type="submit" class="button primary" id="mcp-save-app-password-button">
|
||||
<span class="icon icon-checkmark"></span>
|
||||
<?php p($l->t('Save')); ?>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('The app password will be validated and securely encrypted before storage.')); ?>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_['session']['idp_profile'])): ?>
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Identity Provider Profile')); ?></h2>
|
||||
<table class="mcp-info-table">
|
||||
<?php foreach ($_['session']['idp_profile'] as $key => $value): ?>
|
||||
<tr>
|
||||
<td><?php p(ucfirst(str_replace('_', ' ', $key))); ?></td>
|
||||
<td>
|
||||
<?php if (is_array($value)): ?>
|
||||
<?php p(implode(', ', $value)); ?>
|
||||
<?php else: ?>
|
||||
<?php p((string)$value); ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($_['vectorSyncEnabled']): ?>
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Search Your Content')); ?></h2>
|
||||
<p><?php p($l->t('Use natural language to search across your Notes, Files, Calendar, and Deck cards. Ask questions like "meeting notes from last week" or "recipes with chicken".')); ?></p>
|
||||
<a href="<?php p($urlGenerator->linkToRoute('astrolabe.page.index')); ?>" class="button primary">
|
||||
<span class="icon icon-search"></span>
|
||||
<?php p($l->t('Open Astrolabe')); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Semantic Search')); ?></h2>
|
||||
<p>
|
||||
<?php p($l->t('Semantic search is not enabled on this server. Contact your administrator to enable this feature.')); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="section">
|
||||
<h2><?php p($l->t('Manage Connection')); ?></h2>
|
||||
<p><?php p($l->t('You are connected to the Astrolabe service.')); ?></p>
|
||||
|
||||
<div class="mcp-revoke-section">
|
||||
<form method="post" action="<?php p($urlGenerator->linkToRoute('astrolabe.oauth.disconnect')); ?>" id="mcp-disconnect-form">
|
||||
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
|
||||
<button type="submit" class="button warning" id="mcp-disconnect-button">
|
||||
<span class="icon icon-close"></span>
|
||||
<?php p($l->t('Disconnect')); ?>
|
||||
</button>
|
||||
<p class="mcp-help-text">
|
||||
<?php p($l->t('This will disconnect from the Astrolabe service. You will need to re-authorize to use semantic search features.')); ?>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../tests/bootstrap.php';
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
\OC_App::loadApp(OCA\Astrolabe\AppInfo\Application::APP_ID);
|
||||
OC_Hook::clear();
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="bootstrap.php" timeoutForSmallTests="900" timeoutForMediumTests="900" timeoutForLargeTests="900" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.4/phpunit.xsd" cacheDirectory=".phpunit.cache">
|
||||
<testsuite name="M C P Server U I Tests">
|
||||
<directory suffix="Test.php">.</directory>
|
||||
</testsuite>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">../appinfo</directory>
|
||||
<directory suffix=".php">../lib</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -1,635 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Tests\Unit\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\BackgroundJob\RefreshUserTokens;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Lock\LockedException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for RefreshUserTokens background job.
|
||||
*
|
||||
* Tests proactive OAuth token refresh functionality.
|
||||
*/
|
||||
final class RefreshUserTokensTest extends TestCase {
|
||||
private ITimeFactory&MockObject $timeFactory;
|
||||
private McpTokenStorage&MockObject $tokenStorage;
|
||||
private IdpTokenRefresher&MockObject $tokenRefresher;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private RefreshUserTokens $job;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->timeFactory = $this->createMock(ITimeFactory::class);
|
||||
$this->tokenStorage = $this->createMock(McpTokenStorage::class);
|
||||
$this->tokenRefresher = $this->createMock(IdpTokenRefresher::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->job = new RefreshUserTokens(
|
||||
$this->timeFactory,
|
||||
$this->tokenStorage,
|
||||
$this->tokenRefresher,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up default withTokenLock behavior that executes the callback.
|
||||
* Call this in tests that need the lock to succeed.
|
||||
*/
|
||||
private function setupDefaultLockBehavior(): void {
|
||||
$this->tokenStorage->method('withTokenLock')
|
||||
->willReturnCallback(fn ($userId, $callback) => $callback());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Constructor Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testConstructorSetsInterval(): void {
|
||||
// Use reflection to access the protected interval property
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$property = $reflection->getProperty('interval');
|
||||
$property->setAccessible(true);
|
||||
|
||||
$this->assertEquals(900, $property->getValue($this->job));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// run() Method Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRunWithNoUsers(): void {
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn([]);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
$this->assertStringContainsString('Starting', $message);
|
||||
} else {
|
||||
$this->assertStringContainsString('total=0', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message);
|
||||
}
|
||||
});
|
||||
|
||||
// Call run() via reflection since it's protected
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunWithMultipleUsersAndMixedResults(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn(['alice', 'bob', 'charlie']);
|
||||
|
||||
// Alice: token with plenty of time (skipped)
|
||||
// Bob: token near expiry with refresh token (refreshed)
|
||||
// Charlie: token near expiry without refresh token (failed)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return match ($userId) {
|
||||
'alice' => [
|
||||
'access_token' => 'alice-token',
|
||||
'refresh_token' => 'alice-refresh',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime)
|
||||
'issued_at' => $now,
|
||||
],
|
||||
'bob' => [
|
||||
'access_token' => 'bob-token',
|
||||
'refresh_token' => 'bob-refresh',
|
||||
'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime)
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
'charlie' => [
|
||||
'access_token' => 'charlie-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
// Bob's refresh should succeed
|
||||
$this->tokenRefresher->method('refreshAccessToken')
|
||||
->with('bob-refresh')
|
||||
->willReturn([
|
||||
'access_token' => 'bob-new-token',
|
||||
'refresh_token' => 'bob-new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'bob',
|
||||
'bob-new-token',
|
||||
'bob-new-refresh',
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 2) {
|
||||
$this->assertStringContainsString('total=3', $message);
|
||||
$this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunProcessesUsersInBatches(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
// Simulate 150 users processed in 2 batches (100 + 50)
|
||||
$batch1 = array_map(fn ($i) => "user{$i}", range(1, 100));
|
||||
$batch2 = array_map(fn ($i) => "user{$i}", range(101, 150));
|
||||
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturnCallback(function (int $limit, int $offset) use (&$callCount, $batch1, $batch2) {
|
||||
$callCount++;
|
||||
// First call: offset 0, return 100 users (full batch)
|
||||
if ($offset === 0) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch1;
|
||||
}
|
||||
// Second call: offset 100, return 50 users (partial batch = last)
|
||||
if ($offset === 100) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch2;
|
||||
}
|
||||
// Should not be called again
|
||||
$this->fail("Unexpected getAllUsersWithTokens call with offset $offset");
|
||||
});
|
||||
|
||||
// All tokens have plenty of time (all skipped)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return [
|
||||
'access_token' => "{$userId}-token",
|
||||
'refresh_token' => "{$userId}-refresh",
|
||||
'expires_at' => $now + 3600,
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $infoCallCount = 0;
|
||||
$infoCallCount++;
|
||||
if ($infoCallCount === 2) {
|
||||
$this->assertStringContainsString('total=150', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=150', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
|
||||
// Verify getAllUsersWithTokens was called exactly twice (2 batches)
|
||||
$this->assertEquals(2, $callCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// refreshUserTokenIfNeeded() Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'valid-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining
|
||||
'issued_at' => $now,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshTriggeredWhenTokenNearExpiry(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s)
|
||||
'issued_at' => $now - 3300, // Issued 55 min ago
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenNoRefreshToken(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('no refresh token'));
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenRefresherReturnsNull(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'invalid-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('invalid-refresh')
|
||||
->willReturn(null);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('Refresh returned null'));
|
||||
|
||||
// Should NOT delete token - let on-demand refresh handle cleanup
|
||||
$this->tokenStorage->expects($this->never())
|
||||
->method('deleteUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesIssuedAtForLifetimeCalculation(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min)
|
||||
// 10/60 = 16.7% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 600, // 10 min remaining
|
||||
'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token without issued_at, uses default 3600s lifetime
|
||||
// 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 300, // 5 min remaining
|
||||
// No issued_at
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshStoresNewTokenWithIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'old-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Verify storeUserToken is called with issued_at parameter
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'new-refresh',
|
||||
$this->greaterThan($now), // expires_at = now + 3600
|
||||
$this->greaterThanOrEqual($now) // issued_at = now
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'original-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// IdP returns new access token but no new refresh token (no rotation)
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
// No refresh_token in response
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Should use the original refresh token
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'original-refresh', // Original refresh token preserved
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshHandlesException(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willThrowException(new \Exception('Network error'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Failed to refresh'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenNoToken(): void {
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn(null);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Locking Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenLockCannotBeAcquired(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100, // ~100s remaining (< 50% of default)
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// Lock acquisition fails (on-demand refresh is holding it)
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||
|
||||
// Token refresher should NOT be called when lock fails
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('Lock held for user testuser'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesLockForTokenRefresh(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->with('testuser', $this->isInstanceOf(\Closure::class))
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||
$now = time();
|
||||
|
||||
// First call (before lock): token is expiring
|
||||
// Calls inside lock callback: token is now fresh
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturnCallback(function () use (&$callCount, $now) {
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
// First check: token is expiring
|
||||
return [
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
];
|
||||
}
|
||||
// Inside lock: token was already refreshed
|
||||
return [
|
||||
'access_token' => 'already-refreshed-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_at' => $now + 3600, // Fresh token
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
// Token refresher should NOT be called since token is already fresh
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('already refreshed'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Invoke the protected run() method.
|
||||
*/
|
||||
private function invokeRun(): void {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('run');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->job, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the private refreshUserTokenIfNeeded() method.
|
||||
*/
|
||||
private function invokeRefreshUserTokenIfNeeded(string $userId): string {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('refreshUserTokenIfNeeded');
|
||||
$method->setAccessible(true);
|
||||
return $method->invoke($this->job, $userId);
|
||||
}
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Tests\Unit\Service;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCP\Http\Client\IClient;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\Http\Client\IResponse;
|
||||
use OCP\IConfig;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for IdpTokenRefresher.
|
||||
*
|
||||
* Tests the internal URL resolution logic and token refresh flows.
|
||||
*/
|
||||
final class IdpTokenRefresherTest extends TestCase {
|
||||
private IConfig&MockObject $config;
|
||||
private IClientService&MockObject $clientService;
|
||||
private IClient&MockObject $httpClient;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private McpServerClient&MockObject $mcpServerClient;
|
||||
private IdpTokenRefresher $refresher;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->clientService = $this->createMock(IClientService::class);
|
||||
$this->httpClient = $this->createMock(IClient::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->mcpServerClient = $this->createMock(McpServerClient::class);
|
||||
|
||||
$this->clientService->method('newClient')->willReturn($this->httpClient);
|
||||
|
||||
$this->refresher = new IdpTokenRefresher(
|
||||
$this->config,
|
||||
$this->clientService,
|
||||
$this->logger,
|
||||
$this->mcpServerClient
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getNextcloudBaseUrl() tests
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @dataProvider provideBaseUrlTestCases
|
||||
*/
|
||||
public function testGetNextcloudBaseUrl(string $configValue, string $expected): void {
|
||||
$this->config->method('getSystemValue')
|
||||
->with('astrolabe_internal_url', '')
|
||||
->willReturn($configValue);
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new \ReflectionClass($this->refresher);
|
||||
$method = $reflection->getMethod('getNextcloudBaseUrl');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->refresher);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test cases for getNextcloudBaseUrl().
|
||||
*
|
||||
* @return array<string, array{string, string}>
|
||||
*/
|
||||
public static function provideBaseUrlTestCases(): array {
|
||||
return [
|
||||
'default - no config' => ['', 'http://localhost'],
|
||||
'custom internal url' => ['http://web:8080', 'http://web:8080'],
|
||||
'custom url with trailing slash' => ['http://web:8080/', 'http://web:8080'],
|
||||
'kubernetes service' => ['http://nextcloud.default.svc:80', 'http://nextcloud.default.svc:80'],
|
||||
'https internal url' => ['https://internal.example.com', 'https://internal.example.com'],
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// refreshAccessToken() tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshAccessTokenFailsWithoutClientSecret(): void {
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', ''],
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('no client secret configured'));
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenFailsWithoutMcpServerUrl(): void {
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'MCP server URL not configured'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenWithInternalNextcloudOidc(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
['astrolabe_internal_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response (no external IdP configured)
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'version' => '1.0.0',
|
||||
'auth_mode' => 'multi_user_oauth',
|
||||
// No 'oidc.discovery_url' = use internal Nextcloud OIDC
|
||||
]));
|
||||
|
||||
// Mock token endpoint response
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'access_token' => 'new-access-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
'token_type' => 'Bearer',
|
||||
]));
|
||||
|
||||
// Setup HTTP client to return appropriate responses
|
||||
$this->httpClient->method('get')
|
||||
->with('http://mcp-server:8000/api/v1/status')
|
||||
->willReturn($statusResponse);
|
||||
|
||||
$this->httpClient->method('post')
|
||||
->with(
|
||||
'http://localhost/apps/oidc/token',
|
||||
$this->callback(function ($options) {
|
||||
// Verify the POST body contains expected parameters
|
||||
$body = $options['body'] ?? '';
|
||||
return str_contains($body, 'grant_type=refresh_token')
|
||||
&& str_contains($body, 'client_id=test-client-id')
|
||||
&& str_contains($body, 'client_secret=test-secret')
|
||||
&& str_contains($body, 'refresh_token=test-refresh-token');
|
||||
})
|
||||
)
|
||||
->willReturn($tokenResponse);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertEquals('new-access-token', $result['access_token']);
|
||||
$this->assertEquals('new-refresh-token', $result['refresh_token']);
|
||||
$this->assertEquals(3600, $result['expires_in']);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenWithExternalIdp(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response (external IdP configured)
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'version' => '1.0.0',
|
||||
'auth_mode' => 'multi_user_oauth',
|
||||
'oidc' => [
|
||||
'discovery_url' => 'https://keycloak.example.com/realms/test/.well-known/openid-configuration',
|
||||
],
|
||||
]));
|
||||
|
||||
// Mock OIDC discovery response
|
||||
$discoveryResponse = $this->createMock(IResponse::class);
|
||||
$discoveryResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'issuer' => 'https://keycloak.example.com/realms/test',
|
||||
'token_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/token',
|
||||
'authorization_endpoint' => 'https://keycloak.example.com/realms/test/protocol/openid-connect/auth',
|
||||
]));
|
||||
|
||||
// Mock token endpoint response
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'access_token' => 'keycloak-access-token',
|
||||
'refresh_token' => 'keycloak-refresh-token',
|
||||
'expires_in' => 300,
|
||||
'token_type' => 'Bearer',
|
||||
]));
|
||||
|
||||
// Setup HTTP client calls in order
|
||||
$this->httpClient->method('get')
|
||||
->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) {
|
||||
if (str_contains($url, 'status')) {
|
||||
return $statusResponse;
|
||||
}
|
||||
if (str_contains($url, '.well-known/openid-configuration')) {
|
||||
return $discoveryResponse;
|
||||
}
|
||||
throw new \Exception("Unexpected URL: $url");
|
||||
});
|
||||
|
||||
$this->httpClient->method('post')
|
||||
->with(
|
||||
'https://keycloak.example.com/realms/test/protocol/openid-connect/token',
|
||||
$this->anything()
|
||||
)
|
||||
->willReturn($tokenResponse);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertEquals('keycloak-access-token', $result['access_token']);
|
||||
$this->assertEquals('keycloak-refresh-token', $result['refresh_token']);
|
||||
$this->assertEquals(300, $result['expires_in']);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenFailsOnMissingRefreshTokenInResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
['astrolabe_internal_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode(['version' => '1.0.0']));
|
||||
|
||||
// Mock token response WITHOUT refresh_token (token rotation failure)
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'access_token' => 'new-access-token',
|
||||
// Missing refresh_token!
|
||||
'expires_in' => 3600,
|
||||
]));
|
||||
|
||||
$this->httpClient->method('get')->willReturn($statusResponse);
|
||||
$this->httpClient->method('post')->willReturn($tokenResponse);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('No refresh token in response'),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesHttpException(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
// HTTP client throws exception
|
||||
$this->httpClient->method('get')
|
||||
->willThrowException(new \Exception('Connection refused'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Connection refused'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesInvalidStatusResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
// Mock invalid JSON response
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn('not valid json');
|
||||
|
||||
$this->httpClient->method('get')->willReturn($statusResponse);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid status response'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesInvalidDiscoveryResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response with external IdP
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'oidc' => [
|
||||
'discovery_url' => 'https://keycloak.example.com/.well-known/openid-configuration',
|
||||
],
|
||||
]));
|
||||
|
||||
// Mock invalid discovery response (missing token_endpoint)
|
||||
$discoveryResponse = $this->createMock(IResponse::class);
|
||||
$discoveryResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'issuer' => 'https://keycloak.example.com',
|
||||
// Missing token_endpoint!
|
||||
]));
|
||||
|
||||
$this->httpClient->method('get')
|
||||
->willReturnCallback(function ($url) use ($statusResponse, $discoveryResponse) {
|
||||
if (str_contains($url, 'status')) {
|
||||
return $statusResponse;
|
||||
}
|
||||
return $discoveryResponse;
|
||||
});
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid OIDC discovery response'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenHandlesInvalidTokenResponse(): void {
|
||||
// Setup config
|
||||
$this->config->method('getSystemValue')
|
||||
->willReturnMap([
|
||||
['astrolabe_client_secret', '', 'test-secret'],
|
||||
['mcp_server_url', '', 'http://mcp-server:8000'],
|
||||
['astrolabe_internal_url', '', ''],
|
||||
]);
|
||||
|
||||
$this->mcpServerClient->method('getClientId')
|
||||
->willReturn('test-client-id');
|
||||
|
||||
// Mock MCP server status response
|
||||
$statusResponse = $this->createMock(IResponse::class);
|
||||
$statusResponse->method('getBody')
|
||||
->willReturn(json_encode(['version' => '1.0.0']));
|
||||
|
||||
// Mock token response without access_token
|
||||
$tokenResponse = $this->createMock(IResponse::class);
|
||||
$tokenResponse->method('getBody')
|
||||
->willReturn(json_encode([
|
||||
'error' => 'invalid_grant',
|
||||
'error_description' => 'Refresh token expired',
|
||||
]));
|
||||
|
||||
$this->httpClient->method('get')->willReturn($statusResponse);
|
||||
$this->httpClient->method('post')->willReturn($tokenResponse);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with(
|
||||
$this->stringContains('Token refresh failed'),
|
||||
$this->callback(fn ($ctx) => str_contains($ctx['error'], 'Invalid token response'))
|
||||
);
|
||||
|
||||
$result = $this->refresher->refreshAccessToken('test-refresh-token');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
}
|
||||
@@ -1,829 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Tests\Unit\Service;
|
||||
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ICrypto;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for McpTokenStorage.
|
||||
*
|
||||
* Tests OAuth token storage and app password functionality for multi-user basic auth.
|
||||
*/
|
||||
final class McpTokenStorageTest extends TestCase {
|
||||
private IConfig&MockObject $config;
|
||||
private ICrypto&MockObject $crypto;
|
||||
private IDBConnection&MockObject $db;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private ILockingProvider&MockObject $lockingProvider;
|
||||
private McpTokenStorage $storage;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->crypto = $this->createMock(ICrypto::class);
|
||||
$this->db = $this->createMock(IDBConnection::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->lockingProvider = $this->createMock(ILockingProvider::class);
|
||||
|
||||
$this->storage = new McpTokenStorage(
|
||||
$this->config,
|
||||
$this->crypto,
|
||||
$this->db,
|
||||
$this->logger,
|
||||
$this->lockingProvider
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// OAuth Token Storage Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testStoreUserToken(): void {
|
||||
$userId = 'testuser';
|
||||
$accessToken = 'access-token-123';
|
||||
$refreshToken = 'refresh-token-456';
|
||||
$expiresAt = time() + 3600;
|
||||
|
||||
$this->crypto->expects($this->once())
|
||||
->method('encrypt')
|
||||
->with($this->callback(function (string $json) use ($accessToken, $refreshToken, $expiresAt) {
|
||||
$data = json_decode($json, true);
|
||||
return $data['access_token'] === $accessToken
|
||||
&& $data['refresh_token'] === $refreshToken
|
||||
&& $data['expires_at'] === $expiresAt
|
||||
&& isset($data['issued_at']); // issued_at should be set (defaults to time())
|
||||
}))
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->config->expects($this->once())
|
||||
->method('setUserValue')
|
||||
->with($userId, 'astrolabe', 'oauth_tokens', 'encrypted-data');
|
||||
|
||||
$this->storage->storeUserToken($userId, $accessToken, $refreshToken, $expiresAt);
|
||||
}
|
||||
|
||||
public function testGetUserTokenReturnsTokenData(): void {
|
||||
$userId = 'testuser';
|
||||
$tokenData = [
|
||||
'access_token' => 'access-token-123',
|
||||
'refresh_token' => 'refresh-token-456',
|
||||
'expires_at' => time() + 3600,
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->with($userId, 'astrolabe', 'oauth_tokens', '')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->with('encrypted-data')
|
||||
->willReturn(json_encode($tokenData));
|
||||
|
||||
$result = $this->storage->getUserToken($userId);
|
||||
|
||||
$this->assertEquals($tokenData, $result);
|
||||
}
|
||||
|
||||
public function testGetUserTokenReturnsNullWhenNoTokenStored(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->with($userId, 'astrolabe', 'oauth_tokens', '')
|
||||
->willReturn('');
|
||||
|
||||
$result = $this->storage->getUserToken($userId);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetUserTokenReturnsNullOnDecryptionFailure(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willThrowException(new \Exception('Decryption failed'));
|
||||
|
||||
$result = $this->storage->getUserToken($userId);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testDeleteUserToken(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->expects($this->once())
|
||||
->method('deleteUserValue')
|
||||
->with($userId, 'astrolabe', 'oauth_tokens');
|
||||
|
||||
$this->storage->deleteUserToken($userId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Expiration Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testIsExpiredReturnsTrueWhenNoExpiresAt(): void {
|
||||
$token = ['access_token' => 'test'];
|
||||
|
||||
$this->assertTrue($this->storage->isExpired($token));
|
||||
}
|
||||
|
||||
public function testIsExpiredReturnsTrueWhenExpired(): void {
|
||||
$token = [
|
||||
'access_token' => 'test',
|
||||
'expires_at' => time() - 100, // Expired 100 seconds ago
|
||||
];
|
||||
|
||||
$this->assertTrue($this->storage->isExpired($token));
|
||||
}
|
||||
|
||||
public function testIsExpiredReturnsTrueWhenAboutToExpire(): void {
|
||||
$token = [
|
||||
'access_token' => 'test',
|
||||
'expires_at' => time() + 30, // Expires in 30 seconds (within 60s buffer)
|
||||
];
|
||||
|
||||
$this->assertTrue($this->storage->isExpired($token));
|
||||
}
|
||||
|
||||
public function testIsExpiredReturnsFalseWhenValid(): void {
|
||||
$token = [
|
||||
'access_token' => 'test',
|
||||
'expires_at' => time() + 3600, // Expires in 1 hour
|
||||
];
|
||||
|
||||
$this->assertFalse($this->storage->isExpired($token));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getAccessToken with Refresh Callback Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAccessTokenReturnsNullWhenNoToken(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('');
|
||||
|
||||
$result = $this->storage->getAccessToken($userId);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenReturnsTokenWhenValid(): void {
|
||||
$userId = 'testuser';
|
||||
$tokenData = [
|
||||
'access_token' => 'valid-access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($tokenData));
|
||||
|
||||
$result = $this->storage->getAccessToken($userId);
|
||||
|
||||
$this->assertEquals('valid-access-token', $result);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenRefreshesExpiredToken(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$newTokenData = [
|
||||
'access_token' => 'new-access-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
];
|
||||
|
||||
// First call returns expired token, subsequent calls for storing new token
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
// Encrypt is called when storing the new token
|
||||
$this->crypto->method('encrypt')
|
||||
->willReturn('new-encrypted-data');
|
||||
|
||||
$this->config->expects($this->once())
|
||||
->method('setUserValue')
|
||||
->with($userId, 'astrolabe', 'oauth_tokens', 'new-encrypted-data');
|
||||
|
||||
// Refresh callback
|
||||
$refreshCallback = function (string $refreshToken) use ($newTokenData) {
|
||||
$this->assertEquals('old-refresh-token', $refreshToken);
|
||||
return $newTokenData;
|
||||
};
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertEquals('new-access-token', $result);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenReturnsNullWhenRefreshFailsAndDeletesToken(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
// Expect stale token to be deleted when refresh fails
|
||||
$this->config->expects($this->once())
|
||||
->method('deleteUserValue')
|
||||
->with($userId, 'astrolabe', 'oauth_tokens');
|
||||
|
||||
// Refresh callback returns null (failure)
|
||||
$refreshCallback = fn (string $refreshToken) => null;
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenReturnsNullWhenExpiredAndNoCallbackAndDeletesToken(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
// Expect stale token to be deleted when expired with no callback
|
||||
$this->config->expects($this->once())
|
||||
->method('deleteUserValue')
|
||||
->with($userId, 'astrolabe', 'oauth_tokens');
|
||||
|
||||
// No refresh callback provided
|
||||
$result = $this->storage->getAccessToken($userId, null);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Refresh Locking Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAccessTokenAcquiresLockWhenRefreshing(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$newTokenData = [
|
||||
'access_token' => 'new-access-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
$this->crypto->method('encrypt')
|
||||
->willReturn('new-encrypted-data');
|
||||
|
||||
// Verify lock is acquired and released
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock')
|
||||
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('releaseLock')
|
||||
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||
|
||||
$refreshCallback = fn (string $refreshToken) => $newTokenData;
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertEquals('new-access-token', $result);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenReturnsStaleTokenOnLockedException(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
// Lock acquisition fails
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock')
|
||||
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||
|
||||
// Refresh callback should NOT be called when lock fails
|
||||
$refreshCallbackCalled = false;
|
||||
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||
$refreshCallbackCalled = true;
|
||||
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||
};
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
// Should return stale token instead of failing
|
||||
$this->assertEquals('expired-access-token', $result);
|
||||
$this->assertFalse($refreshCallbackCalled);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenSkipsRefreshWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
// After lock is acquired, token appears fresh (another process refreshed it)
|
||||
$freshTokenData = [
|
||||
'access_token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||
];
|
||||
|
||||
$callCount = 0;
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
// First call returns expired, subsequent calls return fresh
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturnCallback(function () use (&$callCount, $expiredTokenData, $freshTokenData) {
|
||||
$callCount++;
|
||||
return $callCount === 1
|
||||
? json_encode($expiredTokenData)
|
||||
: json_encode($freshTokenData);
|
||||
});
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock');
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('releaseLock');
|
||||
|
||||
// Refresh callback should NOT be called since token is already fresh
|
||||
$refreshCallbackCalled = false;
|
||||
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||
$refreshCallbackCalled = true;
|
||||
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||
};
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertEquals('fresh-access-token', $result);
|
||||
$this->assertFalse($refreshCallbackCalled);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenNoLockRequiredWhenNotExpired(): void {
|
||||
$userId = 'testuser';
|
||||
$validTokenData = [
|
||||
'access_token' => 'valid-access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($validTokenData));
|
||||
|
||||
// Lock should NOT be acquired for valid tokens
|
||||
$this->lockingProvider->expects($this->never())
|
||||
->method('acquireLock');
|
||||
|
||||
$this->lockingProvider->expects($this->never())
|
||||
->method('releaseLock');
|
||||
|
||||
$result = $this->storage->getAccessToken($userId);
|
||||
|
||||
$this->assertEquals('valid-access-token', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Password Storage Tests (Multi-User Basic Auth)
|
||||
// =========================================================================
|
||||
|
||||
public function testStoreBackgroundSyncPassword(): void {
|
||||
$userId = 'testuser';
|
||||
$appPassword = 'app-password-secret';
|
||||
|
||||
$this->crypto->expects($this->once())
|
||||
->method('encrypt')
|
||||
->with($appPassword)
|
||||
->willReturn('encrypted-password');
|
||||
|
||||
// Expect three setUserValue calls: password, type, timestamp
|
||||
$this->config->expects($this->exactly(3))
|
||||
->method('setUserValue')
|
||||
->willReturnCallback(function ($uid, $app, $key, $value) use ($userId) {
|
||||
$this->assertEquals($userId, $uid);
|
||||
$this->assertEquals('astrolabe', $app);
|
||||
$this->assertContains($key, [
|
||||
'background_sync_password',
|
||||
'background_sync_type',
|
||||
'background_sync_provisioned_at'
|
||||
]);
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->storage->storeBackgroundSyncPassword($userId, $appPassword);
|
||||
}
|
||||
|
||||
public function testGetBackgroundSyncPasswordReturnsPassword(): void {
|
||||
$userId = 'testuser';
|
||||
$appPassword = 'app-password-secret';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->with($userId, 'astrolabe', 'background_sync_password', '')
|
||||
->willReturn('encrypted-password');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->with('encrypted-password')
|
||||
->willReturn($appPassword);
|
||||
|
||||
$result = $this->storage->getBackgroundSyncPassword($userId);
|
||||
|
||||
$this->assertEquals($appPassword, $result);
|
||||
}
|
||||
|
||||
public function testGetBackgroundSyncPasswordReturnsNullWhenNotSet(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->with($userId, 'astrolabe', 'background_sync_password', '')
|
||||
->willReturn('');
|
||||
|
||||
$result = $this->storage->getBackgroundSyncPassword($userId);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testGetBackgroundSyncPasswordReturnsNullOnDecryptionFailure(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-password');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willThrowException(new \Exception('Decryption failed'));
|
||||
|
||||
$result = $this->storage->getBackgroundSyncPassword($userId);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
public function testDeleteBackgroundSyncPassword(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
// Expect three deleteUserValue calls
|
||||
$this->config->expects($this->exactly(3))
|
||||
->method('deleteUserValue')
|
||||
->willReturnCallback(function ($uid, $app, $key) use ($userId) {
|
||||
$this->assertEquals($userId, $uid);
|
||||
$this->assertEquals('astrolabe', $app);
|
||||
$this->assertContains($key, [
|
||||
'background_sync_password',
|
||||
'background_sync_type',
|
||||
'background_sync_provisioned_at'
|
||||
]);
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->storage->deleteBackgroundSyncPassword($userId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Background Sync Access Check Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testHasBackgroundSyncAccessReturnsTrueWithOAuthToken(): void {
|
||||
$userId = 'testuser';
|
||||
$tokenData = [
|
||||
'access_token' => 'access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturnCallback(function ($uid, $app, $key, $default) use ($tokenData) {
|
||||
if ($key === 'oauth_tokens') {
|
||||
return 'encrypted-oauth-data';
|
||||
}
|
||||
return $default;
|
||||
});
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($tokenData));
|
||||
|
||||
$result = $this->storage->hasBackgroundSyncAccess($userId);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testHasBackgroundSyncAccessReturnsTrueWithAppPassword(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturnCallback(function ($uid, $app, $key, $default) {
|
||||
if ($key === 'oauth_tokens') {
|
||||
return ''; // No OAuth tokens
|
||||
}
|
||||
if ($key === 'background_sync_password') {
|
||||
return 'encrypted-password';
|
||||
}
|
||||
return $default;
|
||||
});
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn('decrypted-app-password');
|
||||
|
||||
$result = $this->storage->hasBackgroundSyncAccess($userId);
|
||||
|
||||
$this->assertTrue($result);
|
||||
}
|
||||
|
||||
public function testHasBackgroundSyncAccessReturnsFalseWithNeither(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn(''); // No tokens or passwords
|
||||
|
||||
$result = $this->storage->hasBackgroundSyncAccess($userId);
|
||||
|
||||
$this->assertFalse($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Background Sync Type Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetBackgroundSyncTypeReturnsAppPassword(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturnCallback(function ($uid, $app, $key, $default) {
|
||||
if ($key === 'background_sync_type') {
|
||||
return 'app_password';
|
||||
}
|
||||
return $default;
|
||||
});
|
||||
|
||||
$result = $this->storage->getBackgroundSyncType($userId);
|
||||
|
||||
$this->assertEquals('app_password', $result);
|
||||
}
|
||||
|
||||
public function testGetBackgroundSyncTypeFallsBackToOAuth(): void {
|
||||
$userId = 'testuser';
|
||||
$tokenData = [
|
||||
'access_token' => 'access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturnCallback(function ($uid, $app, $key, $default) {
|
||||
if ($key === 'background_sync_type') {
|
||||
return ''; // Type not explicitly set
|
||||
}
|
||||
if ($key === 'oauth_tokens') {
|
||||
return 'encrypted-oauth-data';
|
||||
}
|
||||
return $default;
|
||||
});
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($tokenData));
|
||||
|
||||
$result = $this->storage->getBackgroundSyncType($userId);
|
||||
|
||||
$this->assertEquals('oauth', $result);
|
||||
}
|
||||
|
||||
public function testGetBackgroundSyncTypeReturnsNullWhenNotProvisioned(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('');
|
||||
|
||||
$result = $this->storage->getBackgroundSyncType($userId);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Background Sync Provisioned Timestamp Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetBackgroundSyncProvisionedAtReturnsTimestamp(): void {
|
||||
$userId = 'testuser';
|
||||
$timestamp = time();
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->with($userId, 'astrolabe', 'background_sync_provisioned_at', '')
|
||||
->willReturn((string)$timestamp);
|
||||
|
||||
$result = $this->storage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
$this->assertEquals($timestamp, $result);
|
||||
}
|
||||
|
||||
public function testGetBackgroundSyncProvisionedAtReturnsNullWhenNotSet(): void {
|
||||
$userId = 'testuser';
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->with($userId, 'astrolabe', 'background_sync_provisioned_at', '')
|
||||
->willReturn('');
|
||||
|
||||
$result = $this->storage->getBackgroundSyncProvisionedAt($userId);
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getAllUsersWithTokens Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAllUsersWithTokensReturnsUserIds(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set with multiple users
|
||||
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
['userid' => 'admin'],
|
||||
['userid' => 'alice'],
|
||||
['userid' => 'bob'],
|
||||
false // End of results
|
||||
);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens();
|
||||
|
||||
$this->assertEquals(['admin', 'alice', 'bob'], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensReturnsEmptyArrayWhenNoTokens(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock empty result set
|
||||
$result->method('fetch')->willReturn(false);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens();
|
||||
|
||||
$this->assertEquals([], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensWithLimitAndOffset(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Verify setMaxResults and setFirstResult are called with correct values
|
||||
$qb->expects($this->once())
|
||||
->method('setMaxResults')
|
||||
->with(50)
|
||||
->willReturnSelf();
|
||||
$qb->expects($this->once())
|
||||
->method('setFirstResult')
|
||||
->with(100)
|
||||
->willReturnSelf();
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set
|
||||
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
['userid' => 'user1'],
|
||||
['userid' => 'user2'],
|
||||
false
|
||||
);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens(50, 100);
|
||||
|
||||
$this->assertEquals(['user1', 'user2'], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensWithZeroLimitDoesNotSetMaxResults(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// setMaxResults should NOT be called when limit is 0
|
||||
$qb->expects($this->never())
|
||||
->method('setMaxResults');
|
||||
|
||||
// setFirstResult should NOT be called when offset is 0
|
||||
$qb->expects($this->never())
|
||||
->method('setFirstResult');
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set
|
||||
$result->method('fetch')->willReturn(false);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$this->storage->getAllUsersWithTokens(0, 0);
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Bootstrap for unit tests.
|
||||
*
|
||||
* Unit tests use mocked dependencies and don't require a full Nextcloud
|
||||
* environment. This bootstrap only loads the composer autoloader which
|
||||
* includes the OCP interface definitions needed for mocking.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
bootstrap="bootstrap.php"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnWarning="true"
|
||||
failOnRisky="true"
|
||||
cacheDirectory=".phpunit.cache">
|
||||
<testsuite name="Astrolabe Unit Tests">
|
||||
<directory suffix="Test.php">.</directory>
|
||||
</testsuite>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">../../lib</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"require-dev": {
|
||||
"nextcloud/coding-standard": "^1.2"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "59bdbac023efd7059e30cfd98dc00b94",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "kubawerlos/php-cs-fixer-custom-fixers",
|
||||
"version": "v3.35.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git",
|
||||
"reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/2a35f80ae24ca77443a7af1599c3a3db1b6bd395",
|
||||
"reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-filter": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.87",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6.24 || ^10.5.51 || ^11.5.32"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpCsFixerCustomFixers\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Kuba Werłos",
|
||||
"email": "werlos@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A set of custom fixers for PHP CS Fixer",
|
||||
"support": {
|
||||
"issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues",
|
||||
"source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.35.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/kubawerlos",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-28T18:43:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nextcloud/coding-standard",
|
||||
"version": "v1.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud/coding-standard.git",
|
||||
"reference": "8e06808c1423e9208d63d1bd205b9a38bd400011"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/8e06808c1423e9208d63d1bd205b9a38bd400011",
|
||||
"reference": "8e06808c1423e9208d63d1bd205b9a38bd400011",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"kubawerlos/php-cs-fixer-custom-fixers": "^3.22",
|
||||
"php": "^8.0",
|
||||
"php-cs-fixer/shim": "^3.17"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nextcloud\\CodingStandard\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christoph Wurst",
|
||||
"email": "christoph@winzerhof-wurst.at"
|
||||
}
|
||||
],
|
||||
"description": "Nextcloud coding standards for the php cs fixer",
|
||||
"keywords": [
|
||||
"dev"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nextcloud/coding-standard/issues",
|
||||
"source": "https://github.com/nextcloud/coding-standard/tree/v1.4.0"
|
||||
},
|
||||
"time": "2025-06-19T12:27:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-cs-fixer/shim",
|
||||
"version": "v3.92.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHP-CS-Fixer/shim.git",
|
||||
"reference": "79e39b0d57adfd84c402d7b171b925d1e638597f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/79e39b0d57adfd84c402d7b171b925d1e638597f",
|
||||
"reference": "79e39b0d57adfd84c402d7b171b925d1e638597f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"replace": {
|
||||
"friendsofphp/php-cs-fixer": "self.version"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "For handling output formats in XML",
|
||||
"ext-mbstring": "For handling non-UTF8 characters."
|
||||
},
|
||||
"bin": [
|
||||
"php-cs-fixer",
|
||||
"php-cs-fixer.phar"
|
||||
],
|
||||
"type": "application",
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Dariusz Rumiński",
|
||||
"email": "dariusz.ruminski@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A tool to automatically fix PHP code style",
|
||||
"support": {
|
||||
"issues": "https://github.com/PHP-CS-Fixer/shim/issues",
|
||||
"source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.92.0"
|
||||
},
|
||||
"time": "2025-12-12T10:29:50+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"platform-overrides": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"require-dev": {
|
||||
"nextcloud/openapi-extractor": "v1.8.7"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
-247
@@ -1,247 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "384d95db63f1a0aae08a0ae123ecf4bb",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "adhocore/cli",
|
||||
"version": "v1.9.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/adhocore/php-cli.git",
|
||||
"reference": "474dc3d7ab139796be98b104d891476e3916b6f4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/adhocore/php-cli/zipball/474dc3d7ab139796be98b104d891476e3916b6f4",
|
||||
"reference": "474dc3d7ab139796be98b104d891476e3916b6f4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Ahc\\Cli\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jitendra Adhikari",
|
||||
"email": "jiten.adhikary@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Command line interface library for PHP",
|
||||
"keywords": [
|
||||
"argument-parser",
|
||||
"argv-parser",
|
||||
"cli",
|
||||
"cli-action",
|
||||
"cli-app",
|
||||
"cli-color",
|
||||
"cli-option",
|
||||
"cli-writer",
|
||||
"command",
|
||||
"console",
|
||||
"console-app",
|
||||
"php-cli",
|
||||
"php8",
|
||||
"stream-input",
|
||||
"stream-output"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/adhocore/php-cli/issues",
|
||||
"source": "https://github.com/adhocore/php-cli/tree/v1.9.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/ji10",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/adhocore",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-05-11T13:23:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nextcloud/openapi-extractor",
|
||||
"version": "v1.8.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nextcloud-releases/openapi-extractor.git",
|
||||
"reference": "230f61925c362779652b0038a1314ce5f931e853"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nextcloud-releases/openapi-extractor/zipball/230f61925c362779652b0038a1314ce5f931e853",
|
||||
"reference": "230f61925c362779652b0038a1314ce5f931e853",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"adhocore/cli": "^1.7",
|
||||
"ext-simplexml": "*",
|
||||
"nikic/php-parser": "^5.0",
|
||||
"php": "^8.1",
|
||||
"phpstan/phpdoc-parser": "^2.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"nextcloud/coding-standard": "^1.4.0",
|
||||
"nextcloud/ocp": "dev-master",
|
||||
"rector/rector": "^2.2.8"
|
||||
},
|
||||
"bin": [
|
||||
"bin/generate-spec",
|
||||
"bin/merge-specs"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OpenAPIExtractor\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"AGPL-3.0-or-later"
|
||||
],
|
||||
"description": "A tool for extracting OpenAPI specifications from Nextcloud source code",
|
||||
"support": {
|
||||
"issues": "https://github.com/nextcloud-releases/openapi-extractor/issues",
|
||||
"source": "https://github.com/nextcloud-releases/openapi-extractor/tree/v1.8.7"
|
||||
},
|
||||
"time": "2025-12-02T09:52:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.7.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"ircmaxell/php-yacc": "^0.0.7",
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"bin": [
|
||||
"bin/php-parse"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpParser\\": "lib/PhpParser"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Nikita Popov"
|
||||
}
|
||||
],
|
||||
"description": "A PHP parser written in PHP",
|
||||
"keywords": [
|
||||
"parser",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
|
||||
},
|
||||
"time": "2025-12-06T11:56:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
"version": "2.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
|
||||
"reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/annotations": "^2.0",
|
||||
"nikic/php-parser": "^5.3.0",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"symfony/process": "^5.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PHPStan\\PhpDocParser\\": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
|
||||
},
|
||||
"time": "2025-08-30T15:50:23+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"platform-overrides": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
-1691
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"require-dev": {
|
||||
"vimeo/psalm": "^5.23"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
-2122
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"require-dev": {
|
||||
"rector/rector": "^1.2"
|
||||
}
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "eb58f3061bde78d58fa424c73947025f",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "1.12.32",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8",
|
||||
"reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan-shim": "*"
|
||||
},
|
||||
"bin": [
|
||||
"phpstan",
|
||||
"phpstan.phar"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHPStan - PHP Static Analysis Tool",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"static analysis"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://phpstan.org/user-guide/getting-started",
|
||||
"forum": "https://github.com/phpstan/phpstan/discussions",
|
||||
"issues": "https://github.com/phpstan/phpstan/issues",
|
||||
"security": "https://github.com/phpstan/phpstan/security/policy",
|
||||
"source": "https://github.com/phpstan/phpstan-src"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/ondrejmirtes",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/phpstan",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-30T10:16:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "1.2.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "40f9cf38c05296bd32f444121336a521a293fa61"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/40f9cf38c05296bd32f444121336a521a293fa61",
|
||||
"reference": "40f9cf38c05296bd32f444121336a521a293fa61",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2|^8.0",
|
||||
"phpstan/phpstan": "^1.12.5"
|
||||
},
|
||||
"conflict": {
|
||||
"rector/rector-doctrine": "*",
|
||||
"rector/rector-downgrade-php": "*",
|
||||
"rector/rector-phpunit": "*",
|
||||
"rector/rector-symfony": "*"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "To manipulate phpunit.xml via the custom-rule command"
|
||||
},
|
||||
"bin": [
|
||||
"bin/rector"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"bootstrap.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Instant Upgrade and Automated Refactoring of any PHP code",
|
||||
"keywords": [
|
||||
"automation",
|
||||
"dev",
|
||||
"migration",
|
||||
"refactoring"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/rectorphp/rector/issues",
|
||||
"source": "https://github.com/rectorphp/rector/tree/1.2.10"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/tomasvotruba",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-08T13:59:10+00:00"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
Vendored
-43
@@ -1,43 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
// Read app info from info.xml for @nextcloud/vue
|
||||
const infoXml = readFileSync(resolve(__dirname, 'appinfo/info.xml'), 'utf-8')
|
||||
const appName = infoXml.match(/<id>([^<]+)<\/id>/)?.[1] || 'astrolabe'
|
||||
const appVersion = infoXml.match(/<version>([^<]+)<\/version>/)?.[1] || ''
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
appName: JSON.stringify(appName),
|
||||
appVersion: JSON.stringify(appVersion),
|
||||
},
|
||||
build: {
|
||||
outDir: '.',
|
||||
emptyOutDir: false,
|
||||
cssCodeSplit: false, // Bundle all CSS into entry points (Nextcloud doesn't load CSS chunks)
|
||||
rollupOptions: {
|
||||
input: {
|
||||
'astrolabe-main': resolve(__dirname, 'src/main.js'),
|
||||
'astrolabe-adminSettings': resolve(__dirname, 'src/adminSettings.js'),
|
||||
'astrolabe-personalSettings': resolve(__dirname, 'src/personalSettings.js'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'js/[name].mjs',
|
||||
chunkFileNames: 'js/[name]-[hash].chunk.mjs',
|
||||
assetFileNames: (assetInfo) => {
|
||||
// With cssCodeSplit:false, all CSS goes to a single file
|
||||
// Name it astrolabe-main.css to match Nextcloud's Util::addStyle expectation
|
||||
if (assetInfo.name && assetInfo.name.endsWith('.css')) {
|
||||
return 'css/astrolabe-main.css';
|
||||
}
|
||||
return 'js/[name][extname]';
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
minify: 'terser',
|
||||
},
|
||||
})
|
||||
Vendored
-27
@@ -1,27 +0,0 @@
|
||||
const webpackConfig = require('@nextcloud/webpack-vue-config')
|
||||
const ESLintPlugin = require('eslint-webpack-plugin')
|
||||
const StyleLintPlugin = require('stylelint-webpack-plugin')
|
||||
const path = require('path')
|
||||
|
||||
webpackConfig.entry = {
|
||||
main: { import: path.join(__dirname, 'src', 'main.js'), filename: 'main.js' },
|
||||
}
|
||||
|
||||
webpackConfig.plugins.push(
|
||||
new ESLintPlugin({
|
||||
extensions: ['js', 'vue'],
|
||||
files: 'src',
|
||||
}),
|
||||
)
|
||||
webpackConfig.plugins.push(
|
||||
new StyleLintPlugin({
|
||||
files: 'src/**/*.{css,scss,vue}',
|
||||
}),
|
||||
)
|
||||
|
||||
webpackConfig.module.rules.push({
|
||||
test: /\.svg$/i,
|
||||
type: 'asset/source',
|
||||
})
|
||||
|
||||
module.exports = webpackConfig
|
||||
Reference in New Issue
Block a user