mirror of
https://github.com/nextcloud/server.git
synced 2026-05-28 04:32:30 -04:00
Merge branch 'master' into fix-ios-browsers
This commit is contained in:
commit
d8b9c6d6a0
4492 changed files with 58923 additions and 36730 deletions
13
.github/dependabot.yml
vendored
13
.github/dependabot.yml
vendored
|
|
@ -53,7 +53,14 @@ updates:
|
|||
- "feature: dependencies"
|
||||
# Disable automatic rebasing because without a build CI will likely fail anyway
|
||||
rebase-strategy: "disabled"
|
||||
cooldown:
|
||||
default-days: 4
|
||||
semver-major-days: 8
|
||||
groups:
|
||||
eslint:
|
||||
patterns:
|
||||
- "eslint*"
|
||||
- "@nextcloud/eslint-config"
|
||||
vite:
|
||||
patterns:
|
||||
- "vite"
|
||||
|
|
@ -101,6 +108,9 @@ updates:
|
|||
day: saturday
|
||||
time: "03:30"
|
||||
timezone: Europe/Paris
|
||||
cooldown:
|
||||
default-days: 4
|
||||
semver-major-days: 8
|
||||
open-pull-requests-limit: 20
|
||||
labels:
|
||||
- "3. to review"
|
||||
|
|
@ -155,6 +165,9 @@ updates:
|
|||
day: saturday
|
||||
time: "04:30"
|
||||
timezone: Europe/Paris
|
||||
cooldown:
|
||||
default-days: 4
|
||||
semver-major-days: 8
|
||||
open-pull-requests-limit: 20
|
||||
labels:
|
||||
- "3. to review"
|
||||
|
|
|
|||
2
.github/workflows/block-merge-eol.yml
vendored
2
.github/workflows/block-merge-eol.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Set server major version environment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
|
|
|
|||
2
.github/workflows/block-merge-freeze.yml
vendored
2
.github/workflows/block-merge-freeze.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Register server reference to fallback to master branch
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
echo "commit=$(git submodule status | grep ' 3rdparty' | egrep -o '[a-f0-9]{40}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Register server reference to fallback to master branch
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: webiny/action-conventional-commits@faccb24fc2550dd15c0390d944379d2d8ed9690e # v1.3.1
|
||||
- uses: webiny/action-conventional-commits@7f91b1595ca1951cdb671ddc9f07a49081ec5b69 # v1.4.2
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
2
.github/workflows/bug-report-labeler.yml
vendored
2
.github/workflows/bug-report-labeler.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
issues: write
|
||||
steps:
|
||||
- name: Extract version number and apply label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.issue.body || '';
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -37,13 +37,13 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
|
|||
4
.github/workflows/command-compile.yml
vendored
4
.github/workflows/command-compile.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Get repository from pull request comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
id: get-repository
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
|
|
@ -124,7 +124,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.package-engines-versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.package-engines-versions.outputs.nodeVersion }}
|
||||
cache: npm
|
||||
|
|
|
|||
2
.github/workflows/command-pull-3rdparty.yml
vendored
2
.github/workflows/command-pull-3rdparty.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
# must fetch the PR via the API. This also gives us base.ref for free, avoiding
|
||||
# a second API call. The GITHUB_TOKEN needs pull-requests:read (granted above).
|
||||
- name: Get pull request metadata
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
id: get-pr
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
12
.github/workflows/cypress.yml
vendored
12
.github/workflows/cypress.yml
vendored
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
|
||||
- name: Check composer.json
|
||||
id: check_composer
|
||||
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
|
||||
uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0
|
||||
with:
|
||||
files: 'composer.json'
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ jobs:
|
|||
path: ./
|
||||
|
||||
- name: Set up node ${{ needs.init.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ needs.init.outputs.nodeVersion }}
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ jobs:
|
|||
run: ./node_modules/cypress/bin/cypress install
|
||||
|
||||
- name: Run ${{ matrix.containers == 'component' && 'component' || 'E2E' }} cypress tests
|
||||
uses: cypress-io/github-action@783cb3f07983868532cabaedaa1e6c00ff4786a8 # v7.1.9
|
||||
uses: cypress-io/github-action@c495c3ddffba403ba11be95fffb67e25203b3799 # v7.1.10
|
||||
with:
|
||||
# We already installed the dependencies in the init job
|
||||
install: false
|
||||
|
|
@ -184,7 +184,7 @@ jobs:
|
|||
SETUP_TESTING: ${{ matrix.containers == 'setup' && 'true' || '' }}
|
||||
|
||||
- name: Upload snapshots and videos
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: snapshots_${{ matrix.containers }}
|
||||
|
|
@ -207,7 +207,7 @@ jobs:
|
|||
run: docker exec nextcloud-e2e-test-server_${{ env.APP_NAME }} tar -cvjf - data > data.tar
|
||||
|
||||
- name: Upload data archive
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: failure() && matrix.containers != 'component'
|
||||
with:
|
||||
name: nc_data_${{ matrix.containers }}
|
||||
|
|
|
|||
2
.github/workflows/integration-dav.yml
vendored
2
.github/workflows/integration-dav.yml
vendored
|
|
@ -71,7 +71,7 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: LizardByte/actions/actions/setup_python@0affa4f7bcb27562658960eee840eff8ff844578 # v2026.328.161128
|
||||
uses: LizardByte/actions/actions/setup_python@4125866b7b655a6fe038b0e22a43a4c5d259af79 # v2026.417.35446
|
||||
with:
|
||||
python-version: '2.7'
|
||||
|
||||
|
|
|
|||
1
.github/workflows/integration-s3-primary.yml
vendored
1
.github/workflows/integration-s3-primary.yml
vendored
|
|
@ -90,6 +90,7 @@ jobs:
|
|||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, redis, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: 'none'
|
||||
ini-file: development
|
||||
ini-values: disable_functions=""
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
12
.github/workflows/integration-sqlite.yml
vendored
12
.github/workflows/integration-sqlite.yml
vendored
|
|
@ -73,8 +73,10 @@ jobs:
|
|||
- 'sharing_features'
|
||||
- 'theming_features'
|
||||
- 'videoverification_features'
|
||||
- 'guests_features'
|
||||
|
||||
php-versions: ['8.4']
|
||||
guests-versions: ['main']
|
||||
spreed-versions: ['main']
|
||||
activity-versions: ['master']
|
||||
|
||||
|
|
@ -111,6 +113,15 @@ jobs:
|
|||
path: apps/spreed
|
||||
ref: ${{ matrix.spreed-versions }}
|
||||
|
||||
- name: Checkout Guests app
|
||||
if: ${{ matrix.test-suite == 'guests_features' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: nextcloud/guests
|
||||
path: apps/guests
|
||||
ref: ${{ matrix.guests-versions }}
|
||||
|
||||
- name: Checkout Activity app
|
||||
if: ${{ matrix.test-suite == 'sharing_features' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
|
@ -129,6 +140,7 @@ jobs:
|
|||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, imagick, intl, json, ldap, libxml, mbstring, openssl, pcntl, posix, redis, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
ini-values: disable_functions=""
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/lint-eslint.yml
vendored
2
.github/workflows/lint-eslint.yml
vendored
|
|
@ -68,7 +68,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/lint-stylelint.yml
vendored
2
.github/workflows/lint-stylelint.yml
vendored
|
|
@ -65,7 +65,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/node-test-handlebars.yml
vendored
2
.github/workflows/node-test-handlebars.yml
vendored
|
|
@ -71,7 +71,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/node-test.yml
vendored
2
.github/workflows/node-test.yml
vendored
|
|
@ -70,7 +70,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
|
|
|
|||
2
.github/workflows/node.yml
vendored
2
.github/workflows/node.yml
vendored
|
|
@ -68,7 +68,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
|
|
|
|||
4
.github/workflows/npm-audit-fix.yml
vendored
4
.github/workflows/npm-audit-fix.yml
vendored
|
|
@ -49,7 +49,7 @@ jobs:
|
|||
fallbackNpm: '^11.3'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ jobs:
|
|||
|
||||
- name: Create Pull Request
|
||||
if: steps.checkout.outcome == 'success'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'fix(deps): Fix npm audit'
|
||||
|
|
|
|||
4
.github/workflows/performance.yml
vendored
4
.github/workflows/performance.yml
vendored
|
|
@ -100,14 +100,14 @@ jobs:
|
|||
|
||||
- name: Upload profiles
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: profiles
|
||||
path: |
|
||||
before.json
|
||||
after.json
|
||||
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7
|
||||
if: failure() && steps.compare.outcome == 'failure'
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
|
|
|
|||
3
.github/workflows/phpunit-mariadb.yml
vendored
3
.github/workflows/phpunit-mariadb.yml
vendored
|
|
@ -129,6 +129,9 @@ jobs:
|
|||
|
||||
- name: PHPUnit
|
||||
run: composer run test:db -- --log-junit junit.xml ${{ matrix.coverage && '--coverage-clover ./clover.db.xml' || '' }}
|
||||
env:
|
||||
DB_ROOT_USER: root
|
||||
DB_ROOT_PASS: rootpassword
|
||||
|
||||
- name: Upload db code coverage
|
||||
if: ${{ !cancelled() && matrix.coverage }}
|
||||
|
|
|
|||
3
.github/workflows/phpunit-mysql.yml
vendored
3
.github/workflows/phpunit-mysql.yml
vendored
|
|
@ -129,6 +129,9 @@ jobs:
|
|||
|
||||
- name: PHPUnit
|
||||
run: composer run test:db -- --log-junit junit.xml ${{ matrix.coverage && '--coverage-clover ./clover.db.xml' || '' }}
|
||||
env:
|
||||
DB_ROOT_USER: root
|
||||
DB_ROOT_PASS: rootpassword
|
||||
|
||||
- name: Upload db code coverage
|
||||
if: ${{ !cancelled() && matrix.coverage }}
|
||||
|
|
|
|||
2
.github/workflows/rector-apply.yml
vendored
2
.github/workflows/rector-apply.yml
vendored
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
run: composer run rector
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'refactor: Apply rector changes'
|
||||
|
|
|
|||
2
.github/workflows/rector.yml
vendored
2
.github/workflows/rector.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
src: ${{ steps.changes.outputs.src}}
|
||||
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/static-code-analysis.yml
vendored
2
.github/workflows/static-code-analysis.yml
vendored
|
|
@ -114,7 +114,7 @@ jobs:
|
|||
|
||||
- name: Upload Security Analysis results to GitHub
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v3
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
|
|
|
|||
2
.github/workflows/update-cacert-bundle.yml
vendored
2
.github/workflows/update-cacert-bundle.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
run: curl --etag-compare build/ca-bundle-etag.txt --etag-save build/ca-bundle-etag.txt --output resources/config/ca-bundle.crt https://curl.se/ca/cacert.pem
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'fix(security): Update CA certificate bundle'
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
run: openssl crl -verify -in resources/codesigning/root.crl -CAfile resources/codesigning/root.crt -noout
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
commit-message: 'fix(security): Update code signing revocation list'
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
|
||||
if: steps.update-files.outputs.CHANGES_MADE == 'true'
|
||||
with:
|
||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||
|
|
|
|||
2
.github/workflows/update-stable-titles.yml
vendored
2
.github/workflows/update-stable-titles.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
run: sleep 15
|
||||
|
||||
- name: Get PR details and update title
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
|
|
|||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -17,16 +17,17 @@ node_modules/
|
|||
|
||||
# ignore all apps except core ones
|
||||
/apps*/*
|
||||
!/apps/admin_audit
|
||||
!/apps/appstore
|
||||
!/apps/cloud_federation_api
|
||||
!/apps/comments
|
||||
!/apps/contactsinteraction
|
||||
!/apps/dashboard
|
||||
!/apps/dav
|
||||
!/apps/files
|
||||
!/apps/encryption
|
||||
!/apps/federation
|
||||
!/apps/federatedfilesharing
|
||||
!/apps/sharebymail
|
||||
!/apps/encryption
|
||||
!/apps/files
|
||||
!/apps/files_external
|
||||
!/apps/files_reminders
|
||||
!/apps/files_sharing
|
||||
|
|
@ -38,9 +39,9 @@ node_modules/
|
|||
!/apps/profile
|
||||
!/apps/provisioning_api
|
||||
!/apps/settings
|
||||
!/apps/sharebymail
|
||||
!/apps/systemtags
|
||||
!/apps/testing
|
||||
!/apps/admin_audit
|
||||
!/apps/updatenotification
|
||||
!/apps/theming
|
||||
!/apps/twofactor_backupcodes
|
||||
|
|
@ -161,6 +162,8 @@ Vagrantfile
|
|||
/config/autoconfig.php
|
||||
clover.xml
|
||||
/coverage
|
||||
.vitest*/
|
||||
__screenshots__/
|
||||
|
||||
# Tests - dependencies
|
||||
tests/acceptance/vendor/
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ source_file = translationfiles/templates/admin_audit.pot
|
|||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:nextcloud:p:nextcloud:r:appstore]
|
||||
file_filter = translationfiles/<lang>/appstore.po
|
||||
source_file = translationfiles/templates/appstore.pot
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:nextcloud:p:nextcloud:r:cloud_federation_api]
|
||||
file_filter = translationfiles/<lang>/cloud_federation_api.po
|
||||
source_file = translationfiles/templates/cloud_federation_api.pot
|
||||
|
|
|
|||
2
3rdparty
2
3rdparty
|
|
@ -1 +1 @@
|
|||
Subproject commit f257bfe47eb6ed77a0f5f87ac420fe39020d9ed7
|
||||
Subproject commit 5d09a7f56e2d01b5f4083e65db77c4f7aa775252
|
||||
|
|
@ -79,6 +79,7 @@ class Application extends App implements IBootstrap {
|
|||
parent::__construct('admin_audit');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function register(IRegistrationContext $context): void {
|
||||
$context->registerService(IAuditLogger::class, function (ContainerInterface $c) {
|
||||
return new AuditLogger($c->get(ILogFactory::class), $c->get(IConfig::class));
|
||||
|
|
@ -132,6 +133,7 @@ class Application extends App implements IBootstrap {
|
|||
$context->registerEventListener(CacheEntryRemovedEvent::class, CacheEventListener::class);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function boot(IBootContext $context): void {
|
||||
/** @var IAuditLogger $logger */
|
||||
$logger = $context->getAppContainer()->get(IAuditLogger::class);
|
||||
|
|
|
|||
|
|
@ -35,38 +35,47 @@ class AuditLogger implements IAuditLogger {
|
|||
$this->parentLogger = $logFactory->getCustomPsrLogger($logFile, $auditType, $auditTag);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function emergency($message, array $context = []): void {
|
||||
$this->parentLogger->emergency($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function alert($message, array $context = []): void {
|
||||
$this->parentLogger->alert($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function critical($message, array $context = []): void {
|
||||
$this->parentLogger->critical($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function error($message, array $context = []): void {
|
||||
$this->parentLogger->error($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function warning($message, array $context = []): void {
|
||||
$this->parentLogger->warning($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function notice($message, array $context = []): void {
|
||||
$this->parentLogger->notice($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function info($message, array $context = []): void {
|
||||
$this->parentLogger->info($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function debug($message, array $context = []): void {
|
||||
$this->parentLogger->debug($message, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function log($level, $message, array $context = []): void {
|
||||
$this->parentLogger->log($level, $message, $context);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class Rotate extends TimedJob {
|
|||
$this->setInterval(60 * 60 * 3);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function run($argument): void {
|
||||
$default = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/audit.log';
|
||||
$this->filePath = $this->config->getAppValue('admin_audit', 'logfile', $default);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use OCP\EventDispatcher\IEventListener;
|
|||
* @template-implements IEventListener<AppEnableEvent|AppDisableEvent|AppUpdateEvent>
|
||||
*/
|
||||
class AppManagementEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof AppEnableEvent) {
|
||||
$this->appEnable($event);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use OCP\User\Events\UserLoggedInWithCookieEvent;
|
|||
* @template-implements IEventListener<BeforeUserLoggedInEvent|UserLoggedInWithCookieEvent|UserLoggedInEvent|BeforeUserLoggedOutEvent|AnyLoginFailedEvent>
|
||||
*/
|
||||
class AuthEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof BeforeUserLoggedInEvent) {
|
||||
$this->beforeUserLoggedIn($event);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use OCP\Files\Cache\CacheEntryRemovedEvent;
|
|||
* @template-implements IEventListener<CacheEntryInsertedEvent|CacheEntryRemovedEvent>
|
||||
*/
|
||||
class CacheEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof CacheEntryInsertedEvent) {
|
||||
$this->entryInserted($event);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use OCP\EventDispatcher\IEventListener;
|
|||
* @template-implements IEventListener<ConsoleEvent>
|
||||
*/
|
||||
class ConsoleEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof ConsoleEvent) {
|
||||
$this->runCommand($event);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use OCP\Log\Audit\CriticalActionPerformedEvent;
|
|||
|
||||
/** @template-implements IEventListener<CriticalActionPerformedEvent> */
|
||||
class CriticalActionPerformedEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!($event instanceof CriticalActionPerformedEvent)) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use Psr\Log\LoggerInterface;
|
|||
* @template-implements IEventListener<BeforePreviewFetchedEvent|VersionRestoredEvent>
|
||||
*/
|
||||
class FileEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof BeforePreviewFetchedEvent) {
|
||||
$this->beforePreviewFetched($event);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use OCP\Group\Events\UserRemovedEvent;
|
|||
* @template-implements IEventListener<UserAddedEvent|UserRemovedEvent|GroupCreatedEvent|GroupDeletedEvent>
|
||||
*/
|
||||
class GroupManagementEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof UserAddedEvent) {
|
||||
$this->userAdded($event);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use OCP\EventDispatcher\IEventListener;
|
|||
* @template-implements IEventListener<TwoFactorProviderChallengePassed|TwoFactorProviderChallengeFailed>
|
||||
*/
|
||||
class SecurityEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof TwoFactorProviderChallengePassed) {
|
||||
$this->twoFactorProviderChallengePassed($event);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use OCP\Share\IShare;
|
|||
* @template-implements IEventListener<ShareCreatedEvent|ShareDeletedEvent>
|
||||
*/
|
||||
class SharingEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof ShareCreatedEvent) {
|
||||
$this->shareCreated($event);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ use OCP\User\Events\UserIdUnassignedEvent;
|
|||
* @template-implements IEventListener<UserCreatedEvent|UserDeletedEvent|UserChangedEvent|PasswordUpdatedEvent|UserIdAssignedEvent|UserIdUnassignedEvent>
|
||||
*/
|
||||
class UserManagementEventListener extends Action implements IEventListener {
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if ($event instanceof UserCreatedEvent) {
|
||||
$this->userCreated($event);
|
||||
|
|
|
|||
16
apps/appstore/REUSE.toml
Normal file
16
apps/appstore/REUSE.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
version = 1
|
||||
SPDX-PackageName = "nextcloud"
|
||||
SPDX-PackageSupplier = "Nextcloud <info@nextcloud.com>"
|
||||
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/server"
|
||||
|
||||
[[annotations]]
|
||||
path = ["tests/fixtures/categories.json", "tests/fixtures/categories-api-response.json"]
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors"
|
||||
SPDX-License-Identifier = "CC-BY-SA-4.0"
|
||||
|
||||
[[annotations]]
|
||||
path = ["img/app.svg"]
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "2018-2024 Google LLC"
|
||||
SPDX-License-Identifier = "Apache-2.0"
|
||||
22
apps/appstore/appinfo/info.xml
Normal file
22
apps/appstore/appinfo/info.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0"?>
|
||||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="../../../resources/app-info-shipped.xsd">
|
||||
<id>appstore</id>
|
||||
<name>Nextcloud Appstore</name>
|
||||
<summary>Nextcloud Appstore</summary>
|
||||
<description>Nextcloud Appstore</description>
|
||||
<version>1.0.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Nextcloud</author>
|
||||
<namespace>Appstore</namespace>
|
||||
|
||||
<category>customization</category>
|
||||
<bugs>https://github.com/nextcloud/server/issues</bugs>
|
||||
<dependencies>
|
||||
<nextcloud min-version="34" max-version="34"/>
|
||||
</dependencies>
|
||||
</info>
|
||||
22
apps/appstore/composer/autoload.php
Normal file
22
apps/appstore/composer/autoload.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
// autoload.php @generated by Composer
|
||||
|
||||
if (PHP_VERSION_ID < 50600) {
|
||||
if (!headers_sent()) {
|
||||
header('HTTP/1.1 500 Internal Server Error');
|
||||
}
|
||||
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
|
||||
if (!ini_get('display_errors')) {
|
||||
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
|
||||
fwrite(STDERR, $err);
|
||||
} elseif (!headers_sent()) {
|
||||
echo $err;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException($err);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
||||
return ComposerAutoloaderInitAppstore::getLoader();
|
||||
13
apps/appstore/composer/composer.json
Normal file
13
apps/appstore/composer/composer.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"config" : {
|
||||
"vendor-dir": ".",
|
||||
"optimize-autoloader": true,
|
||||
"classmap-authoritative": true,
|
||||
"autoloader-suffix": "Appstore"
|
||||
},
|
||||
"autoload" : {
|
||||
"psr-4": {
|
||||
"OCA\\Appstore\\": "../lib/"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/appstore/composer/composer.lock
generated
Normal file
18
apps/appstore/composer/composer.lock
generated
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"_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": "d751713988987e9331980363e24189ce",
|
||||
"packages": [],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
579
apps/appstore/composer/composer/ClassLoader.php
Normal file
579
apps/appstore/composer/composer/ClassLoader.php
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
/**
|
||||
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
|
||||
*
|
||||
* $loader = new \Composer\Autoload\ClassLoader();
|
||||
*
|
||||
* // register classes with namespaces
|
||||
* $loader->add('Symfony\Component', __DIR__.'/component');
|
||||
* $loader->add('Symfony', __DIR__.'/framework');
|
||||
*
|
||||
* // activate the autoloader
|
||||
* $loader->register();
|
||||
*
|
||||
* // to enable searching the include path (eg. for PEAR packages)
|
||||
* $loader->setUseIncludePath(true);
|
||||
*
|
||||
* In this example, if you try to use a class in the Symfony\Component
|
||||
* namespace or one of its children (Symfony\Component\Console for instance),
|
||||
* the autoloader will first look for the class under the component/
|
||||
* directory, and it will then fallback to the framework/ directory if not
|
||||
* found before giving up.
|
||||
*
|
||||
* This class is loosely based on the Symfony UniversalClassLoader.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Jordi Boggiano <j.boggiano@seld.be>
|
||||
* @see https://www.php-fig.org/psr/psr-0/
|
||||
* @see https://www.php-fig.org/psr/psr-4/
|
||||
*/
|
||||
class ClassLoader
|
||||
{
|
||||
/** @var \Closure(string):void */
|
||||
private static $includeFile;
|
||||
|
||||
/** @var string|null */
|
||||
private $vendorDir;
|
||||
|
||||
// PSR-4
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
private $prefixLengthsPsr4 = array();
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private $prefixDirsPsr4 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr4 = array();
|
||||
|
||||
// PSR-0
|
||||
/**
|
||||
* List of PSR-0 prefixes
|
||||
*
|
||||
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
|
||||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
private $prefixesPsr0 = array();
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private $fallbackDirsPsr0 = array();
|
||||
|
||||
/** @var bool */
|
||||
private $useIncludePath = false;
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private $classMap = array();
|
||||
|
||||
/** @var bool */
|
||||
private $classMapAuthoritative = false;
|
||||
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private $missingClasses = array();
|
||||
|
||||
/** @var string|null */
|
||||
private $apcuPrefix;
|
||||
|
||||
/**
|
||||
* @var array<string, self>
|
||||
*/
|
||||
private static $registeredLoaders = array();
|
||||
|
||||
/**
|
||||
* @param string|null $vendorDir
|
||||
*/
|
||||
public function __construct($vendorDir = null)
|
||||
{
|
||||
$this->vendorDir = $vendorDir;
|
||||
self::initializeIncludeClosure();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixes()
|
||||
{
|
||||
if (!empty($this->prefixesPsr0)) {
|
||||
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
|
||||
}
|
||||
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function getPrefixesPsr4()
|
||||
{
|
||||
return $this->prefixDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirs()
|
||||
{
|
||||
return $this->fallbackDirsPsr0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getFallbackDirsPsr4()
|
||||
{
|
||||
return $this->fallbackDirsPsr4;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> Array of classname => path
|
||||
*/
|
||||
public function getClassMap()
|
||||
{
|
||||
return $this->classMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $classMap Class to filename map
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addClassMap(array $classMap)
|
||||
{
|
||||
if ($this->classMap) {
|
||||
$this->classMap = array_merge($this->classMap, $classMap);
|
||||
} else {
|
||||
$this->classMap = $classMap;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix, either
|
||||
* appending or prepending to the ones previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 root directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function add($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr0
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr0 = array_merge(
|
||||
$this->fallbackDirsPsr0,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$first = $prefix[0];
|
||||
if (!isset($this->prefixesPsr0[$first][$prefix])) {
|
||||
$this->prefixesPsr0[$first][$prefix] = $paths;
|
||||
|
||||
return;
|
||||
}
|
||||
if ($prepend) {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixesPsr0[$first][$prefix]
|
||||
);
|
||||
} else {
|
||||
$this->prefixesPsr0[$first][$prefix] = array_merge(
|
||||
$this->prefixesPsr0[$first][$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace, either
|
||||
* appending or prepending to the ones previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
* @param bool $prepend Whether to prepend the directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function addPsr4($prefix, $paths, $prepend = false)
|
||||
{
|
||||
$paths = (array) $paths;
|
||||
if (!$prefix) {
|
||||
// Register directories for the root namespace.
|
||||
if ($prepend) {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$paths,
|
||||
$this->fallbackDirsPsr4
|
||||
);
|
||||
} else {
|
||||
$this->fallbackDirsPsr4 = array_merge(
|
||||
$this->fallbackDirsPsr4,
|
||||
$paths
|
||||
);
|
||||
}
|
||||
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
|
||||
// Register directories for a new namespace.
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = $paths;
|
||||
} elseif ($prepend) {
|
||||
// Prepend directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$paths,
|
||||
$this->prefixDirsPsr4[$prefix]
|
||||
);
|
||||
} else {
|
||||
// Append directories for an already registered namespace.
|
||||
$this->prefixDirsPsr4[$prefix] = array_merge(
|
||||
$this->prefixDirsPsr4[$prefix],
|
||||
$paths
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-0 directories for a given prefix,
|
||||
* replacing any others previously set for this prefix.
|
||||
*
|
||||
* @param string $prefix The prefix
|
||||
* @param list<string>|string $paths The PSR-0 base directories
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr0 = (array) $paths;
|
||||
} else {
|
||||
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of PSR-4 directories for a given namespace,
|
||||
* replacing any others previously set for this namespace.
|
||||
*
|
||||
* @param string $prefix The prefix/namespace, with trailing '\\'
|
||||
* @param list<string>|string $paths The PSR-4 base directories
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setPsr4($prefix, $paths)
|
||||
{
|
||||
if (!$prefix) {
|
||||
$this->fallbackDirsPsr4 = (array) $paths;
|
||||
} else {
|
||||
$length = strlen($prefix);
|
||||
if ('\\' !== $prefix[$length - 1]) {
|
||||
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
|
||||
}
|
||||
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
|
||||
$this->prefixDirsPsr4[$prefix] = (array) $paths;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on searching the include path for class files.
|
||||
*
|
||||
* @param bool $useIncludePath
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setUseIncludePath($useIncludePath)
|
||||
{
|
||||
$this->useIncludePath = $useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to check if the autoloader uses the include path to check
|
||||
* for classes.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function getUseIncludePath()
|
||||
{
|
||||
return $this->useIncludePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off searching the prefix and fallback directories for classes
|
||||
* that have not been registered with the class map.
|
||||
*
|
||||
* @param bool $classMapAuthoritative
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setClassMapAuthoritative($classMapAuthoritative)
|
||||
{
|
||||
$this->classMapAuthoritative = $classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should class lookup fail if not found in the current class map?
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isClassMapAuthoritative()
|
||||
{
|
||||
return $this->classMapAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
|
||||
*
|
||||
* @param string|null $apcuPrefix
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setApcuPrefix($apcuPrefix)
|
||||
{
|
||||
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The APCu prefix in use, or null if APCu caching is not enabled.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getApcuPrefix()
|
||||
{
|
||||
return $this->apcuPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers this instance as an autoloader.
|
||||
*
|
||||
* @param bool $prepend Whether to prepend the autoloader or not
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register($prepend = false)
|
||||
{
|
||||
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
|
||||
|
||||
if (null === $this->vendorDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($prepend) {
|
||||
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
|
||||
} else {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
self::$registeredLoaders[$this->vendorDir] = $this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters this instance as an autoloader.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function unregister()
|
||||
{
|
||||
spl_autoload_unregister(array($this, 'loadClass'));
|
||||
|
||||
if (null !== $this->vendorDir) {
|
||||
unset(self::$registeredLoaders[$this->vendorDir]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the given class or interface.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
* @return true|null True if loaded, null otherwise
|
||||
*/
|
||||
public function loadClass($class)
|
||||
{
|
||||
if ($file = $this->findFile($class)) {
|
||||
$includeFile = self::$includeFile;
|
||||
$includeFile($file);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the path to the file where the class is defined.
|
||||
*
|
||||
* @param string $class The name of the class
|
||||
*
|
||||
* @return string|false The path if found, false otherwise
|
||||
*/
|
||||
public function findFile($class)
|
||||
{
|
||||
// class map lookup
|
||||
if (isset($this->classMap[$class])) {
|
||||
return $this->classMap[$class];
|
||||
}
|
||||
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
|
||||
return false;
|
||||
}
|
||||
if (null !== $this->apcuPrefix) {
|
||||
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
|
||||
if ($hit) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
$file = $this->findFileWithExtension($class, '.php');
|
||||
|
||||
// Search for Hack files if we are running on HHVM
|
||||
if (false === $file && defined('HHVM_VERSION')) {
|
||||
$file = $this->findFileWithExtension($class, '.hh');
|
||||
}
|
||||
|
||||
if (null !== $this->apcuPrefix) {
|
||||
apcu_add($this->apcuPrefix.$class, $file);
|
||||
}
|
||||
|
||||
if (false === $file) {
|
||||
// Remember that this class does not exist.
|
||||
$this->missingClasses[$class] = true;
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently registered loaders keyed by their corresponding vendor directories.
|
||||
*
|
||||
* @return array<string, self>
|
||||
*/
|
||||
public static function getRegisteredLoaders()
|
||||
{
|
||||
return self::$registeredLoaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class
|
||||
* @param string $ext
|
||||
* @return string|false
|
||||
*/
|
||||
private function findFileWithExtension($class, $ext)
|
||||
{
|
||||
// PSR-4 lookup
|
||||
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
|
||||
|
||||
$first = $class[0];
|
||||
if (isset($this->prefixLengthsPsr4[$first])) {
|
||||
$subPath = $class;
|
||||
while (false !== $lastPos = strrpos($subPath, '\\')) {
|
||||
$subPath = substr($subPath, 0, $lastPos);
|
||||
$search = $subPath . '\\';
|
||||
if (isset($this->prefixDirsPsr4[$search])) {
|
||||
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
|
||||
foreach ($this->prefixDirsPsr4[$search] as $dir) {
|
||||
if (file_exists($file = $dir . $pathEnd)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-4 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr4 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 lookup
|
||||
if (false !== $pos = strrpos($class, '\\')) {
|
||||
// namespaced class name
|
||||
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
|
||||
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
|
||||
} else {
|
||||
// PEAR-like class name
|
||||
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
|
||||
}
|
||||
|
||||
if (isset($this->prefixesPsr0[$first])) {
|
||||
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
|
||||
if (0 === strpos($class, $prefix)) {
|
||||
foreach ($dirs as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 fallback dirs
|
||||
foreach ($this->fallbackDirsPsr0 as $dir) {
|
||||
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// PSR-0 include paths.
|
||||
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
private static function initializeIncludeClosure()
|
||||
{
|
||||
if (self::$includeFile !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope isolated include.
|
||||
*
|
||||
* Prevents access to $this/self from included files.
|
||||
*
|
||||
* @param string $file
|
||||
* @return void
|
||||
*/
|
||||
self::$includeFile = \Closure::bind(static function($file) {
|
||||
include $file;
|
||||
}, null, null);
|
||||
}
|
||||
}
|
||||
396
apps/appstore/composer/composer/InstalledVersions.php
Normal file
396
apps/appstore/composer/composer/InstalledVersions.php
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Composer.
|
||||
*
|
||||
* (c) Nils Adermann <naderman@naderman.de>
|
||||
* Jordi Boggiano <j.boggiano@seld.be>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Composer;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Composer\Semver\VersionParser;
|
||||
|
||||
/**
|
||||
* This class is copied in every Composer installed project and available to all
|
||||
*
|
||||
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
|
||||
*
|
||||
* To require its presence, you can require `composer-runtime-api ^2.0`
|
||||
*
|
||||
* @final
|
||||
*/
|
||||
class InstalledVersions
|
||||
{
|
||||
/**
|
||||
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
|
||||
* @internal
|
||||
*/
|
||||
private static $selfDir = null;
|
||||
|
||||
/**
|
||||
* @var mixed[]|null
|
||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||
*/
|
||||
private static $installed;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private static $installedIsLocalDir;
|
||||
|
||||
/**
|
||||
* @var bool|null
|
||||
*/
|
||||
private static $canGetVendors;
|
||||
|
||||
/**
|
||||
* @var array[]
|
||||
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static $installedByVendor = array();
|
||||
|
||||
/**
|
||||
* Returns a list of all package names which are present, either by being installed, replaced or provided
|
||||
*
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackages()
|
||||
{
|
||||
$packages = array();
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
$packages[] = array_keys($installed['versions']);
|
||||
}
|
||||
|
||||
if (1 === \count($packages)) {
|
||||
return $packages[0];
|
||||
}
|
||||
|
||||
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all package names with a specific type e.g. 'library'
|
||||
*
|
||||
* @param string $type
|
||||
* @return string[]
|
||||
* @psalm-return list<string>
|
||||
*/
|
||||
public static function getInstalledPackagesByType($type)
|
||||
{
|
||||
$packagesByType = array();
|
||||
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
foreach ($installed['versions'] as $name => $package) {
|
||||
if (isset($package['type']) && $package['type'] === $type) {
|
||||
$packagesByType[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $packagesByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package is installed
|
||||
*
|
||||
* This also returns true if the package name is provided or replaced by another package
|
||||
*
|
||||
* @param string $packageName
|
||||
* @param bool $includeDevRequirements
|
||||
* @return bool
|
||||
*/
|
||||
public static function isInstalled($packageName, $includeDevRequirements = true)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (isset($installed['versions'][$packageName])) {
|
||||
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given package satisfies a version constraint
|
||||
*
|
||||
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
|
||||
*
|
||||
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
|
||||
*
|
||||
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
|
||||
* @param string $packageName
|
||||
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
|
||||
* @return bool
|
||||
*/
|
||||
public static function satisfies(VersionParser $parser, $packageName, $constraint)
|
||||
{
|
||||
$constraint = $parser->parseConstraints((string) $constraint);
|
||||
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
|
||||
|
||||
return $provided->matches($constraint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a version constraint representing all the range(s) which are installed for a given package
|
||||
*
|
||||
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
|
||||
* whether a given version of a package is installed, and not just whether it exists
|
||||
*
|
||||
* @param string $packageName
|
||||
* @return string Version constraint usable with composer/semver
|
||||
*/
|
||||
public static function getVersionRanges($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ranges = array();
|
||||
if (isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
|
||||
}
|
||||
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
|
||||
}
|
||||
if (array_key_exists('provided', $installed['versions'][$packageName])) {
|
||||
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
|
||||
}
|
||||
|
||||
return implode(' || ', $ranges);
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
|
||||
*/
|
||||
public static function getPrettyVersion($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['pretty_version'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
|
||||
*/
|
||||
public static function getReference($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($installed['versions'][$packageName]['reference'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $installed['versions'][$packageName]['reference'];
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $packageName
|
||||
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
|
||||
*/
|
||||
public static function getInstallPath($packageName)
|
||||
{
|
||||
foreach (self::getInstalled() as $installed) {
|
||||
if (!isset($installed['versions'][$packageName])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
|
||||
}
|
||||
|
||||
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
|
||||
*/
|
||||
public static function getRootPackage()
|
||||
{
|
||||
$installed = self::getInstalled();
|
||||
|
||||
return $installed[0]['root'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw installed.php data for custom implementations
|
||||
*
|
||||
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
|
||||
* @return array[]
|
||||
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
|
||||
*/
|
||||
public static function getRawData()
|
||||
{
|
||||
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
self::$installed = include __DIR__ . '/installed.php';
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
return self::$installed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw data of all installed.php which are currently loaded for custom implementations
|
||||
*
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
public static function getAllRawData()
|
||||
{
|
||||
return self::getInstalled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets you reload the static array from another file
|
||||
*
|
||||
* This is only useful for complex integrations in which a project needs to use
|
||||
* this class but then also needs to execute another project's autoloader in process,
|
||||
* and wants to ensure both projects have access to their version of installed.php.
|
||||
*
|
||||
* A typical case would be PHPUnit, where it would need to make sure it reads all
|
||||
* the data it needs from this class, then call reload() with
|
||||
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
|
||||
* the project in which it runs can then also use this class safely, without
|
||||
* interference between PHPUnit's dependencies and the project's dependencies.
|
||||
*
|
||||
* @param array[] $data A vendor/composer/installed.php data set
|
||||
* @return void
|
||||
*
|
||||
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
|
||||
*/
|
||||
public static function reload($data)
|
||||
{
|
||||
self::$installed = $data;
|
||||
self::$installedByVendor = array();
|
||||
|
||||
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
|
||||
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
|
||||
// so we have to assume it does not, and that may result in duplicate data being returned when listing
|
||||
// all installed packages for example
|
||||
self::$installedIsLocalDir = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private static function getSelfDir()
|
||||
{
|
||||
if (self::$selfDir === null) {
|
||||
self::$selfDir = strtr(__DIR__, '\\', '/');
|
||||
}
|
||||
|
||||
return self::$selfDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
*/
|
||||
private static function getInstalled()
|
||||
{
|
||||
if (null === self::$canGetVendors) {
|
||||
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
|
||||
}
|
||||
|
||||
$installed = array();
|
||||
$copiedLocalDir = false;
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
$selfDir = self::getSelfDir();
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
$installed[] = self::$installedByVendor[$vendorDir];
|
||||
} elseif (is_file($vendorDir.'/composer/installed.php')) {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require $vendorDir.'/composer/installed.php';
|
||||
self::$installedByVendor[$vendorDir] = $required;
|
||||
$installed[] = $required;
|
||||
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
|
||||
self::$installed = $required;
|
||||
self::$installedIsLocalDir = true;
|
||||
}
|
||||
}
|
||||
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
|
||||
$copiedLocalDir = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === self::$installed) {
|
||||
// only require the installed.php file if this file is loaded from its dumped location,
|
||||
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
|
||||
if (substr(__DIR__, -8, 1) !== 'C') {
|
||||
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
|
||||
$required = require __DIR__ . '/installed.php';
|
||||
self::$installed = $required;
|
||||
} else {
|
||||
self::$installed = array();
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$installed !== array() && !$copiedLocalDir) {
|
||||
$installed[] = self::$installed;
|
||||
}
|
||||
|
||||
return $installed;
|
||||
}
|
||||
}
|
||||
21
apps/appstore/composer/composer/LICENSE
Normal file
21
apps/appstore/composer/composer/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
|
||||
Copyright (c) Nils Adermann, Jordi Boggiano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
15
apps/appstore/composer/composer/autoload_classmap.php
Normal file
15
apps/appstore/composer/composer/autoload_classmap.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
// autoload_classmap.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = $vendorDir;
|
||||
|
||||
return array(
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
'OCA\\Appstore\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
|
||||
'OCA\\Appstore\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php',
|
||||
'OCA\\Appstore\\Controller\\DiscoverController' => $baseDir . '/../lib/Controller/DiscoverController.php',
|
||||
'OCA\\Appstore\\Controller\\PageController' => $baseDir . '/../lib/Controller/PageController.php',
|
||||
'OCA\\Appstore\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
|
||||
);
|
||||
9
apps/appstore/composer/composer/autoload_namespaces.php
Normal file
9
apps/appstore/composer/composer/autoload_namespaces.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
// autoload_namespaces.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = $vendorDir;
|
||||
|
||||
return array(
|
||||
);
|
||||
10
apps/appstore/composer/composer/autoload_psr4.php
Normal file
10
apps/appstore/composer/composer/autoload_psr4.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
// autoload_psr4.php @generated by Composer
|
||||
|
||||
$vendorDir = dirname(__DIR__);
|
||||
$baseDir = $vendorDir;
|
||||
|
||||
return array(
|
||||
'OCA\\Appstore\\' => array($baseDir . '/../lib'),
|
||||
);
|
||||
37
apps/appstore/composer/composer/autoload_real.php
Normal file
37
apps/appstore/composer/composer/autoload_real.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
// autoload_real.php @generated by Composer
|
||||
|
||||
class ComposerAutoloaderInitAppstore
|
||||
{
|
||||
private static $loader;
|
||||
|
||||
public static function loadClassLoader($class)
|
||||
{
|
||||
if ('Composer\Autoload\ClassLoader' === $class) {
|
||||
require __DIR__ . '/ClassLoader.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Composer\Autoload\ClassLoader
|
||||
*/
|
||||
public static function getLoader()
|
||||
{
|
||||
if (null !== self::$loader) {
|
||||
return self::$loader;
|
||||
}
|
||||
|
||||
spl_autoload_register(array('ComposerAutoloaderInitAppstore', 'loadClassLoader'), true, true);
|
||||
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
|
||||
spl_autoload_unregister(array('ComposerAutoloaderInitAppstore', 'loadClassLoader'));
|
||||
|
||||
require __DIR__ . '/autoload_static.php';
|
||||
call_user_func(\Composer\Autoload\ComposerStaticInitAppstore::getInitializer($loader));
|
||||
|
||||
$loader->setClassMapAuthoritative(true);
|
||||
$loader->register(true);
|
||||
|
||||
return $loader;
|
||||
}
|
||||
}
|
||||
41
apps/appstore/composer/composer/autoload_static.php
Normal file
41
apps/appstore/composer/composer/autoload_static.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
// autoload_static.php @generated by Composer
|
||||
|
||||
namespace Composer\Autoload;
|
||||
|
||||
class ComposerStaticInitAppstore
|
||||
{
|
||||
public static $prefixLengthsPsr4 = array (
|
||||
'O' =>
|
||||
array (
|
||||
'OCA\\Appstore\\' => 13,
|
||||
),
|
||||
);
|
||||
|
||||
public static $prefixDirsPsr4 = array (
|
||||
'OCA\\Appstore\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/../lib',
|
||||
),
|
||||
);
|
||||
|
||||
public static $classMap = array (
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
'OCA\\Appstore\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
|
||||
'OCA\\Appstore\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php',
|
||||
'OCA\\Appstore\\Controller\\DiscoverController' => __DIR__ . '/..' . '/../lib/Controller/DiscoverController.php',
|
||||
'OCA\\Appstore\\Controller\\PageController' => __DIR__ . '/..' . '/../lib/Controller/PageController.php',
|
||||
'OCA\\Appstore\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
|
||||
);
|
||||
|
||||
public static function getInitializer(ClassLoader $loader)
|
||||
{
|
||||
return \Closure::bind(function () use ($loader) {
|
||||
$loader->prefixLengthsPsr4 = ComposerStaticInitAppstore::$prefixLengthsPsr4;
|
||||
$loader->prefixDirsPsr4 = ComposerStaticInitAppstore::$prefixDirsPsr4;
|
||||
$loader->classMap = ComposerStaticInitAppstore::$classMap;
|
||||
|
||||
}, null, ClassLoader::class);
|
||||
}
|
||||
}
|
||||
5
apps/appstore/composer/composer/installed.json
Normal file
5
apps/appstore/composer/composer/installed.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"packages": [],
|
||||
"dev": false,
|
||||
"dev-package-names": []
|
||||
}
|
||||
23
apps/appstore/composer/composer/installed.php
Normal file
23
apps/appstore/composer/composer/installed.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php return array(
|
||||
'root' => array(
|
||||
'name' => '__root__',
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => '3efb1d80e9851e0c33311a7722f523e020654691',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../',
|
||||
'aliases' => array(),
|
||||
'dev' => false,
|
||||
),
|
||||
'versions' => array(
|
||||
'__root__' => array(
|
||||
'pretty_version' => 'dev-master',
|
||||
'version' => 'dev-master',
|
||||
'reference' => '3efb1d80e9851e0c33311a7722f523e020654691',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B |
0
apps/appstore/l10n/.gitkeep
Normal file
0
apps/appstore/l10n/.gitkeep
Normal file
31
apps/appstore/lib/AppInfo/Application.php
Normal file
31
apps/appstore/lib/AppInfo/Application.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Appstore\AppInfo;
|
||||
|
||||
use OCA\Appstore\Search\AppSearch;
|
||||
use OCP\AppFramework\App;
|
||||
use OCP\AppFramework\Bootstrap\IBootContext;
|
||||
use OCP\AppFramework\Bootstrap\IBootstrap;
|
||||
use OCP\AppFramework\Bootstrap\IRegistrationContext;
|
||||
|
||||
final class Application extends App implements IBootstrap {
|
||||
public const APP_ID = 'appstore';
|
||||
|
||||
public function __construct(array $urlParams = []) {
|
||||
parent::__construct(self::APP_ID, $urlParams);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function register(IRegistrationContext $context): void {
|
||||
$context->registerSearchProvider(AppSearch::class);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function boot(IBootContext $context): void {
|
||||
}
|
||||
}
|
||||
518
apps/appstore/lib/Controller/ApiController.php
Normal file
518
apps/appstore/lib/Controller/ApiController.php
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Appstore\Controller;
|
||||
|
||||
use OC\App\AppManager;
|
||||
use OC\App\AppStore\Bundles\BundleFetcher;
|
||||
use OC\App\AppStore\Fetcher\AppFetcher;
|
||||
use OC\App\AppStore\Fetcher\CategoryFetcher;
|
||||
use OC\App\AppStore\Version\VersionParser;
|
||||
use OC\App\DependencyAnalyzer;
|
||||
use OC\Installer;
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\App\AppPathNotFoundException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCS\OCSException;
|
||||
use OCP\AppFramework\OCS\OCSNotFoundException;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\IConfig;
|
||||
use OCP\IGroup;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\L10N\IFactory;
|
||||
use OCP\Server;
|
||||
use OCP\Support\Subscription\IRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION)]
|
||||
class ApiController extends OCSController {
|
||||
|
||||
/** @var array */
|
||||
private $allApps = [];
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private readonly IConfig $config,
|
||||
private readonly IAppConfig $appConfig,
|
||||
private readonly AppManager $appManager,
|
||||
private readonly DependencyAnalyzer $dependencyAnalyzer,
|
||||
private readonly CategoryFetcher $categoryFetcher,
|
||||
private readonly AppFetcher $appFetcher,
|
||||
private readonly IFactory $l10nFactory,
|
||||
private readonly BundleFetcher $bundleFetcher,
|
||||
private readonly Installer $installer,
|
||||
private readonly IRegistry $subscriptionRegistry,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available categories
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<array{id: string, displayName: string}>, array{}>
|
||||
*
|
||||
* 200: The categories were found successfully
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/v1/apps/categories')]
|
||||
public function listCategories(): DataResponse {
|
||||
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
|
||||
|
||||
$categories = $this->categoryFetcher->get();
|
||||
$categories = array_map(fn (array $category): array => [
|
||||
'id' => $category['id'],
|
||||
'displayName' => $category['translations'][$currentLanguage]['name'] ?? $category['translations']['en']['name'],
|
||||
], $categories);
|
||||
|
||||
return new DataResponse($categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available apps
|
||||
*
|
||||
* @param bool $details - Whether to include detailed appstore information about the app
|
||||
* @return DataResponse<Http::STATUS_OK, list<array{id: string, name: string, groups: list<string>, internal: bool, isCompatible: bool, missingDependencies?: list<string>, missingMaxNextcloudVersion: bool, missingMinNextcloudVersion: bool, ...<array-key, mixed>}>, array{}>
|
||||
*
|
||||
* 200: The apps were found successfully
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/v1/apps')]
|
||||
public function listApps(bool $details = false): DataResponse {
|
||||
$apps = $this->getAllApps();
|
||||
|
||||
/** @var array<string>|mixed $ignoreMaxApps */
|
||||
$ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []);
|
||||
if (!is_array($ignoreMaxApps)) {
|
||||
$this->logger->warning('The value given for app_install_overwrite is not an array. Ignoring...');
|
||||
$ignoreMaxApps = [];
|
||||
}
|
||||
|
||||
// Extend existing app details
|
||||
$apps = array_map(function (array $appData) use ($ignoreMaxApps, $details): array {
|
||||
if (isset($appData['appstoreData'])) {
|
||||
$appstoreData = $appData['appstoreData'];
|
||||
$appData['screenshot'] = $this->createProxyPreviewUrl($appstoreData['screenshots'][0]['url'] ?? '');
|
||||
$appData['category'] = $appstoreData['categories'];
|
||||
$appData['releases'] = $appstoreData['releases'];
|
||||
|
||||
if (!$details) {
|
||||
unset($appData['appstoreData']);
|
||||
}
|
||||
}
|
||||
|
||||
$newVersion = $this->installer->isUpdateAvailable($appData['id']);
|
||||
if ($newVersion !== false) {
|
||||
$appData['update'] = $newVersion;
|
||||
}
|
||||
|
||||
// fix groups to be an array
|
||||
$groups = [];
|
||||
if (is_string($appData['groups'])) {
|
||||
/** @var list<string>|string $groups */
|
||||
$groups = json_decode($appData['groups']);
|
||||
// ensure 'groups' is an array
|
||||
if (!is_array($groups)) {
|
||||
$groups = [$groups];
|
||||
}
|
||||
}
|
||||
|
||||
$appData['groups'] = $groups;
|
||||
// analyze dependencies
|
||||
$ignoreMax = in_array($appData['id'], $ignoreMaxApps);
|
||||
$missing = $this->dependencyAnalyzer->analyze($appData, $ignoreMax);
|
||||
$appData['missingDependencies'] = $missing;
|
||||
|
||||
$appData['missingMinNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['min-version']);
|
||||
$appData['missingMaxNextcloudVersion'] = !isset($appData['dependencies']['nextcloud']['@attributes']['max-version']);
|
||||
$appData['isCompatible'] = $this->dependencyAnalyzer->isMarkedCompatible($appData);
|
||||
$appData['internal'] = in_array($appData['id'], $this->appManager->getAlwaysEnabledApps());
|
||||
|
||||
return $appData;
|
||||
}, $apps);
|
||||
|
||||
/**
|
||||
* @var list<array{id: string, name: string, groups: list<string>, internal: bool, isCompatible: bool, missingDependencies?: list<string>, missingMaxNextcloudVersion: bool, missingMinNextcloudVersion: bool, ...<array-key, mixed>}> $apps
|
||||
*/
|
||||
usort($apps, $this->sortApps(...));
|
||||
return new DataResponse($apps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable one apps
|
||||
*
|
||||
* App will be enabled for specific groups only if $groups is defined
|
||||
*
|
||||
* @param string $appId - The app to enable
|
||||
* @param list<string> $groups - The groups to enable the app for
|
||||
* @param bool $force - Whether to force enable the app even if Nextcloud version requirements are not met
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{update_required: bool}, array{}>
|
||||
* @throws OCSException - if the app could not be enabled
|
||||
*
|
||||
* 200: App successfully enabled
|
||||
*/
|
||||
#[PasswordConfirmationRequired(strict: true)]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/v1/apps/enable')]
|
||||
public function enableApp(string $appId, array $groups = [], bool $force = false): DataResponse {
|
||||
try {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
if ($force) {
|
||||
$this->appManager->overwriteNextcloudRequirement($appId);
|
||||
}
|
||||
|
||||
// Check if app is already downloaded
|
||||
if (!$this->installer->isDownloaded($appId)) {
|
||||
$this->installer->downloadApp($appId);
|
||||
}
|
||||
|
||||
$this->installer->installApp($appId);
|
||||
|
||||
if ($groups !== []) {
|
||||
$this->appManager->enableAppForGroups($appId, $this->getGroupList($groups));
|
||||
} else {
|
||||
$this->appManager->enableApp($appId);
|
||||
}
|
||||
|
||||
$updateRequired = $this->appManager->isUpgradeRequired($appId);
|
||||
return new DataResponse(['update_required' => $updateRequired]);
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->logger->error('could not enable app', ['exception' => $throwable]);
|
||||
throw new OCSException('could not enable app', Http::STATUS_INTERNAL_SERVER_ERROR, $throwable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable an app
|
||||
*
|
||||
* @param string $appId - The app to disable
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
|
||||
* @throws OCSException - if the app could not be disabled
|
||||
*
|
||||
* 200: App successfully disabled
|
||||
*/
|
||||
#[PasswordConfirmationRequired(strict: false)]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/v1/apps/disable')]
|
||||
public function disableApp(string $appId): DataResponse {
|
||||
try {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
$this->appManager->removeOverwriteNextcloudRequirement($appId);
|
||||
$this->appManager->disableApp($appId);
|
||||
return new DataResponse([]);
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->error('could not disable app', ['exception' => $exception]);
|
||||
throw new OCSException('could not disable app', Http::STATUS_INTERNAL_SERVER_ERROR, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app.
|
||||
*
|
||||
* @param string $appId - The app to uninstall
|
||||
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
|
||||
* @throws OCSException - if the app could not be uninstalled
|
||||
*
|
||||
* 200: App successfully uninstalled
|
||||
*/
|
||||
#[PasswordConfirmationRequired(strict: true)]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/v1/apps/uninstall')]
|
||||
public function uninstallApp(string $appId): DataResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
if ($this->appManager->isEnabledForAnyone($appId)) {
|
||||
$this->disableApp($appId);
|
||||
}
|
||||
|
||||
$result = $this->installer->removeApp($appId);
|
||||
if ($result !== false) {
|
||||
// If this app was force enabled, remove the force-enabled-state
|
||||
$this->appManager->removeOverwriteNextcloudRequirement($appId);
|
||||
$this->appManager->clearAppsCache();
|
||||
return new DataResponse([]);
|
||||
}
|
||||
|
||||
throw new OCSException('could not remove app', Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an app
|
||||
*
|
||||
* @param string $appId - The app to update
|
||||
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
|
||||
* @throws OCSException - if the app could not be updated
|
||||
*
|
||||
* 200: App successfully updated
|
||||
*/
|
||||
#[PasswordConfirmationRequired(strict: true)]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/v1/apps/update')]
|
||||
public function updateApp(string $appId): DataResponse {
|
||||
$appId = $this->appManager->cleanAppId($appId);
|
||||
|
||||
$this->config->setSystemValue('maintenance', true);
|
||||
try {
|
||||
$result = $this->installer->updateAppstoreApp($appId);
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
if ($result === false) {
|
||||
throw new \Exception('Update failed');
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
throw new OCSException('could not update app', Http::STATUS_INTERNAL_SERVER_ERROR, $exception);
|
||||
}
|
||||
|
||||
return new DataResponse([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all apps of a bundle
|
||||
*
|
||||
* @param string $bundleId - The bundle to enable
|
||||
* @return DataResponse<Http::STATUS_OK, array{}, array{}>
|
||||
* @throws OCSException - if the bundle, or one app within, could not be enabled
|
||||
*
|
||||
* 200: Bundle successfully enabled
|
||||
*/
|
||||
#[PasswordConfirmationRequired(strict: true)]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/v1/bundles/enable')]
|
||||
public function enableBundle(string $bundleId): DataResponse {
|
||||
try {
|
||||
$bundle = $this->bundleFetcher->getBundleByIdentifier($bundleId);
|
||||
$this->config->setSystemValue('maintenance', true);
|
||||
$this->installer->installAppBundle($bundle);
|
||||
} catch (\BadMethodCallException $e) {
|
||||
throw new OCSNotFoundException('Bundle not found', $e);
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->error('could not enable bundle', ['bundleId' => $bundleId, 'exception' => $exception]);
|
||||
throw new OCSException('could not enable bundle', Http::STATUS_INTERNAL_SERVER_ERROR, $exception);
|
||||
} finally {
|
||||
$this->config->setSystemValue('maintenance', false);
|
||||
}
|
||||
|
||||
return new DataResponse([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert URL to proxied URL so CSP is no problem
|
||||
*/
|
||||
private function createProxyPreviewUrl(string $url): string {
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://usercontent.apps.nextcloud.com/' . base64_encode($url);
|
||||
}
|
||||
|
||||
private function fetchApps(): void {
|
||||
$appClass = new \OC_App();
|
||||
$apps = $appClass->listAllApps();
|
||||
foreach ($apps as $app) {
|
||||
$app['installed'] = true;
|
||||
|
||||
if (isset($app['screenshot'][0])) {
|
||||
$appScreenshot = $app['screenshot'][0] ?? null;
|
||||
if (is_array($appScreenshot)) {
|
||||
// Screenshot with thumbnail
|
||||
$appScreenshot = $appScreenshot['@value'];
|
||||
}
|
||||
|
||||
$app['screenshot'] = $this->createProxyPreviewUrl($appScreenshot);
|
||||
}
|
||||
|
||||
$this->allApps[$app['id']] = $app;
|
||||
}
|
||||
|
||||
$apps = $this->getAppsForCategory('');
|
||||
$supportedApps = $this->subscriptionRegistry->delegateGetSupportedApps();
|
||||
foreach ($apps as $app) {
|
||||
$app['appstore'] = true;
|
||||
if (!array_key_exists($app['id'], $this->allApps)) {
|
||||
$this->allApps[$app['id']] = $app;
|
||||
} else {
|
||||
$this->allApps[$app['id']] = array_merge($app, $this->allApps[$app['id']]);
|
||||
}
|
||||
|
||||
if (in_array($app['id'], $supportedApps)) {
|
||||
$this->allApps[$app['id']]['level'] = \OC_App::supportedApp;
|
||||
}
|
||||
}
|
||||
|
||||
// add bundle information
|
||||
$bundles = $this->bundleFetcher->getBundles();
|
||||
foreach ($bundles as $bundle) {
|
||||
foreach ($bundle->getAppIdentifiers() as $identifier) {
|
||||
foreach ($this->allApps as &$app) {
|
||||
if ($app['id'] === $identifier) {
|
||||
$app['bundleIds'][] = $bundle->getIdentifier();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getAllApps() {
|
||||
if (empty($this->allApps)) {
|
||||
$this->fetchApps();
|
||||
}
|
||||
|
||||
return $this->allApps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all apps for a category from the app store
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getAppsForCategory(string $requestedCategory = ''): array {
|
||||
$versionParser = new VersionParser();
|
||||
$formattedApps = [];
|
||||
$apps = $this->appFetcher->get();
|
||||
foreach ($apps as $app) {
|
||||
// Skip all apps not in the requested category
|
||||
if ($requestedCategory !== '') {
|
||||
$isInCategory = false;
|
||||
foreach ($app['categories'] as $category) {
|
||||
if ($category === $requestedCategory) {
|
||||
$isInCategory = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isInCategory) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($app['releases'][0]['rawPlatformVersionSpec'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nextcloudVersion = $versionParser->getVersion($app['releases'][0]['rawPlatformVersionSpec']);
|
||||
$nextcloudVersionDependencies = [];
|
||||
if ($nextcloudVersion->getMinimumVersion() !== '') {
|
||||
$nextcloudVersionDependencies['nextcloud']['@attributes']['min-version'] = $nextcloudVersion->getMinimumVersion();
|
||||
}
|
||||
|
||||
if ($nextcloudVersion->getMaximumVersion() !== '') {
|
||||
$nextcloudVersionDependencies['nextcloud']['@attributes']['max-version'] = $nextcloudVersion->getMaximumVersion();
|
||||
}
|
||||
|
||||
$phpVersion = $versionParser->getVersion($app['releases'][0]['rawPhpVersionSpec']);
|
||||
|
||||
try {
|
||||
$this->appManager->getAppPath($app['id']);
|
||||
$existsLocally = true;
|
||||
} catch (AppPathNotFoundException) {
|
||||
$existsLocally = false;
|
||||
}
|
||||
|
||||
$phpDependencies = [];
|
||||
if ($phpVersion->getMinimumVersion() !== '') {
|
||||
$phpDependencies['php']['@attributes']['min-version'] = $phpVersion->getMinimumVersion();
|
||||
}
|
||||
|
||||
if ($phpVersion->getMaximumVersion() !== '') {
|
||||
$phpDependencies['php']['@attributes']['max-version'] = $phpVersion->getMaximumVersion();
|
||||
}
|
||||
|
||||
if (isset($app['releases'][0]['minIntSize'])) {
|
||||
$phpDependencies['php']['@attributes']['min-int-size'] = $app['releases'][0]['minIntSize'];
|
||||
}
|
||||
|
||||
$authors = '';
|
||||
foreach ($app['authors'] as $key => $author) {
|
||||
$authors .= $author['name'];
|
||||
if ($key !== count($app['authors']) - 1) {
|
||||
$authors .= ', ';
|
||||
}
|
||||
}
|
||||
|
||||
$currentLanguage = substr($this->l10nFactory->findLanguage(), 0, 2);
|
||||
$enabledValue = $this->appConfig->getValueString($app['id'], 'enabled', 'no');
|
||||
$groups = null;
|
||||
if ($enabledValue !== 'no' && $enabledValue !== 'yes') {
|
||||
$groups = $enabledValue;
|
||||
}
|
||||
|
||||
$currentVersion = '';
|
||||
if ($this->appManager->isEnabledForAnyone($app['id'])) {
|
||||
$currentVersion = $this->appManager->getAppVersion($app['id']);
|
||||
} else {
|
||||
$currentVersion = $app['releases'][0]['version'];
|
||||
}
|
||||
|
||||
$formattedApps[] = [
|
||||
'id' => $app['id'],
|
||||
'app_api' => false,
|
||||
'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
|
||||
'description' => $app['translations'][$currentLanguage]['description'] ?? $app['translations']['en']['description'],
|
||||
'summary' => $app['translations'][$currentLanguage]['summary'] ?? $app['translations']['en']['summary'],
|
||||
'license' => $app['releases'][0]['licenses'],
|
||||
'author' => $authors,
|
||||
'shipped' => $this->appManager->isShipped($app['id']),
|
||||
'internal' => in_array($app['id'], $this->appManager->getAlwaysEnabledApps()),
|
||||
'version' => $currentVersion,
|
||||
'types' => [],
|
||||
'documentation' => [
|
||||
'admin' => $app['adminDocs'],
|
||||
'user' => $app['userDocs'],
|
||||
'developer' => $app['developerDocs']
|
||||
],
|
||||
'website' => $app['website'],
|
||||
'bugs' => $app['issueTracker'],
|
||||
'dependencies' => array_merge(
|
||||
$nextcloudVersionDependencies,
|
||||
$phpDependencies
|
||||
),
|
||||
'level' => ($app['isFeatured'] === true) ? 200 : 100,
|
||||
'missingMaxNextcloudVersion' => false,
|
||||
'missingMinNextcloudVersion' => false,
|
||||
'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
|
||||
'ratingOverall' => $app['ratingOverall'],
|
||||
'ratingNumOverall' => $app['ratingNumOverall'],
|
||||
'removable' => $existsLocally,
|
||||
'active' => $this->appManager->isEnabledForUser($app['id']),
|
||||
'needsDownload' => !$existsLocally,
|
||||
'groups' => $groups,
|
||||
'fromAppStore' => true,
|
||||
'appstoreData' => $app,
|
||||
];
|
||||
}
|
||||
|
||||
return $formattedApps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $groups - The group ids to fetch
|
||||
* @return list<IGroup> - The list of groups matching the given group ids
|
||||
*/
|
||||
private function getGroupList(array $groups): array {
|
||||
$groupManager = Server::get(IGroupManager::class);
|
||||
$groupsList = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupItem = $groupManager->get($group);
|
||||
if ($groupItem instanceof IGroup) {
|
||||
$groupsList[] = $groupItem;
|
||||
}
|
||||
}
|
||||
|
||||
return $groupsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{name: string, ...} $a
|
||||
* @param array{name: string, ...} $b
|
||||
*/
|
||||
private function sortApps(array $a, array $b): int {
|
||||
return $a['name'] <=> $b['name'];
|
||||
}
|
||||
}
|
||||
190
apps/appstore/lib/Controller/DiscoverController.php
Normal file
190
apps/appstore/lib/Controller/DiscoverController.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Appstore\Controller;
|
||||
|
||||
use OC\App\AppStore\Fetcher\AppDiscoverFetcher;
|
||||
use OC\App\AppStore\Fetcher\ResponseDefinitions;
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\AppFramework\OCS\OCSBadRequestException;
|
||||
use OCP\AppFramework\OCS\OCSNotFoundException;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\Files\AppData\IAppDataFactory;
|
||||
use OCP\Files\IAppData;
|
||||
use OCP\Files\NotFoundException;
|
||||
use OCP\Files\NotPermittedException;
|
||||
use OCP\Files\SimpleFS\ISimpleFile;
|
||||
use OCP\Files\SimpleFS\ISimpleFolder;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\IRequest;
|
||||
use OCP\IUserSession;
|
||||
use OCP\Security\RateLimiting\ILimiter;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @psalm-import-type AppStoreFetcherDiscoverElement from ResponseDefinitions
|
||||
*/
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
final class DiscoverController extends OCSController {
|
||||
|
||||
private readonly IAppData $appData;
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
IAppDataFactory $appDataFactory,
|
||||
private readonly IClientService $clientService,
|
||||
private readonly AppDiscoverFetcher $discoverFetcher,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
$this->appData = $appDataFactory->get(Application::APP_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active entries for the app discover section
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<AppStoreFetcherDiscoverElement>, array{}>
|
||||
*
|
||||
* 200: List of active entries for the app discover section
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[ApiRoute(verb: 'GET', url:'/api/v1/discover')]
|
||||
public function getAppDiscoverJSON(): DataResponse {
|
||||
$data = $this->discoverFetcher->get(true);
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a image for the app discover section - this is proxied for privacy and CSP reasons
|
||||
*
|
||||
* @param string $fileName - The image file name
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{'Content-Type': string}>
|
||||
* @throws OCSBadRequestException - if the media source is not trusted
|
||||
* @throws OCSNotFoundException - if the media file could not be found
|
||||
*
|
||||
* 200: The media file was found and is returned
|
||||
* 400: The media source is not trusted
|
||||
* 404: The media file could not be found
|
||||
*/
|
||||
#[NoCSRFRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/v1/discover/media')]
|
||||
public function getAppDiscoverMedia(string $fileName, ILimiter $limiter, IUserSession $session): FileDisplayResponse {
|
||||
$getEtag = $this->discoverFetcher->getETag() ?? date('Y-m');
|
||||
$etag = trim($getEtag, '"');
|
||||
|
||||
try {
|
||||
$folder = $this->appData->getFolder('app-discover-cache');
|
||||
$this->cleanUpImageCache($folder, $etag);
|
||||
} catch (\Throwable) {
|
||||
$folder = $this->appData->newFolder('app-discover-cache');
|
||||
}
|
||||
|
||||
// Get the current cache folder
|
||||
try {
|
||||
$folder = $folder->getFolder($etag);
|
||||
} catch (NotFoundException) {
|
||||
$folder = $folder->newFolder($etag);
|
||||
}
|
||||
|
||||
$info = pathinfo($fileName);
|
||||
$hashName = md5($fileName);
|
||||
$allFiles = $folder->getDirectoryListing();
|
||||
// Try to find the file
|
||||
$file = array_filter($allFiles, fn (ISimpleFile $file): bool => str_starts_with($file->getName(), $hashName));
|
||||
// Get the first entry
|
||||
$file = reset($file);
|
||||
// If not found request from Web
|
||||
if ($file === false) {
|
||||
$user = $session->getUser();
|
||||
// this route is not public thus we can assume a user is logged-in
|
||||
assert($user !== null);
|
||||
// Register a user request to throttle fetching external data
|
||||
// this will prevent using the server for DoS of other systems.
|
||||
$limiter->registerUserRequest(
|
||||
'settings-discover-media',
|
||||
// allow up to 24 media requests per hour
|
||||
// this should be a sane default when a completely new section is loaded
|
||||
// keep in mind browsers request all files from a source-set
|
||||
24,
|
||||
60 * 60,
|
||||
$user,
|
||||
);
|
||||
|
||||
if (!$this->checkCanDownloadMedia($fileName)) {
|
||||
$this->logger->warning('Tried to load media files for app discover section from untrusted source');
|
||||
throw new OCSBadRequestException('Untrusted media source');
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->clientService->newClient();
|
||||
$fileResponse = $client->get($fileName);
|
||||
$contentType = $fileResponse->getHeader('Content-Type');
|
||||
$extension = $info['extension'] ?? '';
|
||||
$file = $folder->newFile($hashName . '.' . base64_encode($contentType) . '.' . $extension, $fileResponse->getBody());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Could not load media file for app discover section', ['media_src' => $fileName, 'exception' => $e]);
|
||||
throw new OCSNotFoundException('Media file not found');
|
||||
}
|
||||
} else {
|
||||
// File was found so we can get the content type from the file name
|
||||
$contentType = base64_decode(explode('.', $file->getName())[1] ?? '');
|
||||
}
|
||||
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $contentType]);
|
||||
// cache for 7 days
|
||||
$response->cacheFor(604800, false, true);
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function checkCanDownloadMedia(string $filename): bool {
|
||||
$urlInfo = parse_url($filename);
|
||||
if (!isset($urlInfo['host']) || !isset($urlInfo['path'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always allowed hosts
|
||||
if ($urlInfo['host'] === 'nextcloud.com') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Hosts that need further verification
|
||||
// Github is only allowed if from our organization
|
||||
$ALLOWED_HOSTS = ['github.com', 'raw.githubusercontent.com'];
|
||||
if (!in_array($urlInfo['host'], $ALLOWED_HOSTS, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_starts_with($urlInfo['path'], '/nextcloud/') || str_starts_with($urlInfo['path'], '/nextcloud-gmbh/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove orphaned folders from the image cache that do not match the current etag
|
||||
* @param ISimpleFolder $folder The folder to clear
|
||||
* @param string $etag The etag (directory name) to keep
|
||||
*/
|
||||
private function cleanUpImageCache(ISimpleFolder $folder, string $etag): void {
|
||||
// Cleanup old cache folders
|
||||
$allFiles = $folder->getDirectoryListing();
|
||||
foreach ($allFiles as $dir) {
|
||||
try {
|
||||
if ($dir->getName() !== $etag) {
|
||||
$dir->delete();
|
||||
}
|
||||
} catch (NotPermittedException) {
|
||||
// ignore folder for now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
apps/appstore/lib/Controller/PageController.php
Normal file
115
apps/appstore/lib/Controller/PageController.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*!
|
||||
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Appstore\Controller;
|
||||
|
||||
use OC\App\AppManager;
|
||||
use OC\App\AppStore\Bundles\BundleFetcher;
|
||||
use OC\Installer;
|
||||
use OCA\AppAPI\Service\ExAppsPageService;
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
|
||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||
use OCP\AppFramework\Http\Attribute\OpenAPI;
|
||||
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Services\IInitialState;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\INavigationManager;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Server;
|
||||
use OCP\Util;
|
||||
|
||||
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
|
||||
final class PageController extends Controller {
|
||||
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private readonly IL10N $l10n,
|
||||
private readonly IConfig $config,
|
||||
private readonly Installer $installer,
|
||||
private readonly AppManager $appManager,
|
||||
private readonly IURLGenerator $urlGenerator,
|
||||
private readonly IInitialState $initialState,
|
||||
private readonly BundleFetcher $bundleFetcher,
|
||||
private readonly INavigationManager $navigationManager,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
#[NoCSRFRequired]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/settings/apps', root: '')]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/settings/apps/{category}', defaults: ['category' => ''], root: '')]
|
||||
#[FrontpageRoute(verb: 'GET', url: '/settings/apps/{category}/{id}', defaults: ['category' => '', 'id' => ''], root: '')]
|
||||
public function viewApps(): TemplateResponse {
|
||||
$this->navigationManager->setActiveEntry('core_apps');
|
||||
|
||||
$this->initialState->provideInitialState('appstoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
|
||||
$this->initialState->provideInitialState('appstoreBundles', $this->getBundles());
|
||||
$this->initialState->provideInitialState('appstoreDeveloperDocs', $this->urlGenerator->linkToDocs('developer-manual'));
|
||||
$this->initialState->provideInitialState('appstoreUpdateCount', count($this->getAppsWithUpdates()));
|
||||
|
||||
if ($this->appManager->isEnabledForAnyone('app_api')) {
|
||||
try {
|
||||
/**
|
||||
* @psalm-suppress UndefinedClass AppAPI is shipped since 30.0.1
|
||||
*/
|
||||
Server::get(ExAppsPageService::class)->provideAppApiState($this->initialState);
|
||||
} catch (\Psr\Container\NotFoundExceptionInterface|\Psr\Container\ContainerExceptionInterface) {
|
||||
// nop
|
||||
}
|
||||
}
|
||||
|
||||
$policy = new ContentSecurityPolicy();
|
||||
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
|
||||
|
||||
$templateResponse = new TemplateResponse(Application::APP_ID, 'empty', ['pageTitle' => $this->l10n->t('App store')]);
|
||||
$templateResponse->setContentSecurityPolicy($policy);
|
||||
|
||||
Util::addStyle(Application::APP_ID, 'main');
|
||||
Util::addScript(Application::APP_ID, 'main');
|
||||
|
||||
return $templateResponse;
|
||||
}
|
||||
|
||||
|
||||
private function getAppsWithUpdates(): array {
|
||||
$appClass = new \OC_App();
|
||||
$apps = $appClass->listAllApps();
|
||||
/** @var array{id: string, ...} $app */
|
||||
foreach ($apps as $key => $app) {
|
||||
$newVersion = $this->installer->isUpdateAvailable($app['id']);
|
||||
if ($newVersion === false) {
|
||||
unset($apps[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{name: string, id: string, appIdentifiers: list<string>}>
|
||||
*/
|
||||
private function getBundles(): array {
|
||||
$result = [];
|
||||
$bundles = $this->bundleFetcher->getBundles();
|
||||
foreach ($bundles as $bundle) {
|
||||
$result[] = [
|
||||
'name' => $bundle->getName(),
|
||||
'id' => $bundle->getIdentifier(),
|
||||
'appIdentifiers' => $bundle->getAppIdentifiers()
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,9 @@ declare(strict_types=1);
|
|||
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OCA\Settings\Search;
|
||||
namespace OCA\Appstore\Search;
|
||||
|
||||
use OCA\Appstore\AppInfo\Application;
|
||||
use OCP\IL10N;
|
||||
use OCP\INavigationManager;
|
||||
use OCP\IUser;
|
||||
|
|
@ -16,31 +17,35 @@ use OCP\Search\ISearchQuery;
|
|||
use OCP\Search\SearchResult;
|
||||
use OCP\Search\SearchResultEntry;
|
||||
|
||||
class AppSearch implements IProvider {
|
||||
final readonly class AppSearch implements IProvider {
|
||||
public function __construct(
|
||||
protected INavigationManager $navigationManager,
|
||||
protected IL10N $l,
|
||||
private INavigationManager $navigationManager,
|
||||
private IL10N $l,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getId(): string {
|
||||
return 'settings_apps';
|
||||
return Application::APP_ID;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getName(): string {
|
||||
return $this->l->t('Apps');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getOrder(string $route, array $routeParameters): int {
|
||||
return $route === 'settings.AppSettings.viewApps' ? -50 : 100;
|
||||
return $route === 'appstore.Page.viewApps' ? -50 : 100;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function search(IUser $user, ISearchQuery $query): SearchResult {
|
||||
$entries = $this->navigationManager->getAll('all');
|
||||
|
||||
$searchTitle = $this->l->t('Apps');
|
||||
$term = $query->getFilter('term')?->get();
|
||||
if (empty($term)) {
|
||||
$term = (string)$query->getFilter('term')?->get();
|
||||
if ($term === '') {
|
||||
return SearchResult::complete($searchTitle, []);
|
||||
}
|
||||
|
||||
2
apps/appstore/openapi-administration.json.license
Normal file
2
apps/appstore/openapi-administration.json.license
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
2
apps/appstore/openapi-full.json.license
Normal file
2
apps/appstore/openapi-full.json.license
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
1062
apps/appstore/openapi.json
Normal file
1062
apps/appstore/openapi.json
Normal file
File diff suppressed because it is too large
Load diff
2
apps/appstore/openapi.json.license
Normal file
2
apps/appstore/openapi.json.license
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
72
apps/appstore/src/AppstoreApp.vue
Normal file
72
apps/appstore/src/AppstoreApp.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
|
||||
import NcContent from '@nextcloud/vue/components/NcContent'
|
||||
import AppstoreNavigation from './views/AppstoreNavigation.vue'
|
||||
import AppstoreSidebar from './views/AppstoreSidebar.vue'
|
||||
import { APPSTORE_CATEGORY_NAMES } from './constants.ts'
|
||||
import { useAppsStore } from './store/apps.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useAppsStore()
|
||||
|
||||
const currentCategory = computed(() => {
|
||||
if (route.params.category) {
|
||||
return [route.params.category].flat()[0]!
|
||||
}
|
||||
if (route.name === 'apps-bundles') {
|
||||
return 'bundles'
|
||||
} else if (route.name === 'apps-search') {
|
||||
return 'search'
|
||||
}
|
||||
return 'discover'
|
||||
})
|
||||
|
||||
const heading = computed(() => {
|
||||
if (currentCategory.value in APPSTORE_CATEGORY_NAMES) {
|
||||
return APPSTORE_CATEGORY_NAMES[currentCategory.value]
|
||||
}
|
||||
return store.getCategoryById(currentCategory.value)?.displayName ?? currentCategory.value
|
||||
})
|
||||
const pageTitle = computed(() => `${heading.value} - ${t('appstore', 'App store')}`)
|
||||
|
||||
const showSidebar = computed(() => !!route.params.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcContent appName="appstore">
|
||||
<AppstoreNavigation />
|
||||
<NcAppContent
|
||||
:class="$style.appstoreApp__content"
|
||||
:pageHeading="t('appstore', 'App store')"
|
||||
:pageTitle>
|
||||
<h2 v-if="heading" :class="$style.appstoreApp__heading">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<router-view />
|
||||
</NcAppContent>
|
||||
<AppstoreSidebar v-if="showSidebar" />
|
||||
</NcContent>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreApp__content {
|
||||
padding-inline-end: var(--body-container-margin);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.appstoreApp__heading {
|
||||
margin-block-start: var(--app-navigation-padding);
|
||||
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
|
||||
min-height: var(--default-clickable-area);
|
||||
line-height: var(--default-clickable-area);
|
||||
vertical-align: center;
|
||||
}
|
||||
</style>
|
||||
24
apps/appstore/src/actions/actionDisable.ts
Normal file
24
apps/appstore/src/actions/actionDisable.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiClose } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canDisable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionDisable: AppAction = {
|
||||
id: 'disable',
|
||||
icon: mdiClose,
|
||||
order: 10,
|
||||
enabled: canDisable,
|
||||
label: () => t('appstore', 'Disable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.disableApp(app.id)
|
||||
},
|
||||
}
|
||||
27
apps/appstore/src/actions/actionEnable.ts
Normal file
27
apps/appstore/src/actions/actionEnable.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiCheck } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canEnable, canInstall } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionEnable: AppAction = {
|
||||
id: 'enable',
|
||||
icon: mdiCheck,
|
||||
order: 1,
|
||||
variant: 'primary',
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !canInstall(app) && canEnable(app)
|
||||
},
|
||||
label: () => t('appstore', 'Enable'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
}
|
||||
28
apps/appstore/src/actions/actionForceEnable.ts
Normal file
28
apps/appstore/src/actions/actionForceEnable.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiAlertCircleCheckOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canForceEnable, canInstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionForceEnable: AppAction = {
|
||||
id: 'force-enable',
|
||||
icon: mdiAlertCircleCheckOutline,
|
||||
order: 3,
|
||||
inline: false,
|
||||
variant: 'warning',
|
||||
label: () => t('appstore', 'Force enable'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !canInstall(app) && canForceEnable(app) && needForceEnable(app)
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.forceEnableApp(app.id)
|
||||
},
|
||||
}
|
||||
34
apps/appstore/src/actions/actionInstall.ts
Normal file
34
apps/appstore/src/actions/actionInstall.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiDownload } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canInstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionInstall: AppAction = {
|
||||
id: 'install',
|
||||
icon: mdiDownload,
|
||||
order: 5,
|
||||
enabled(app) {
|
||||
return canInstall(app) && !needForceEnable(app)
|
||||
},
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => {
|
||||
if (app.app_api) {
|
||||
return t('appstore', 'Deploy and enable')
|
||||
}
|
||||
if (app.needsDownload) {
|
||||
return t('appstore', 'Download and enable')
|
||||
}
|
||||
return t('appstore', 'Install and enable')
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id)
|
||||
},
|
||||
}
|
||||
35
apps/appstore/src/actions/actionInstallForced.ts
Normal file
35
apps/appstore/src/actions/actionInstallForced.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiDownload } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canInstall, needForceEnable } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionInstallForced: AppAction = {
|
||||
id: 'install-forced',
|
||||
icon: mdiDownload,
|
||||
order: 5,
|
||||
inline: false,
|
||||
enabled(app) {
|
||||
return canInstall(app) && needForceEnable(app)
|
||||
},
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => {
|
||||
if (app.app_api) {
|
||||
return t('appstore', 'Deploy and force enable')
|
||||
}
|
||||
if (app.needsDownload) {
|
||||
return t('appstore', 'Download and force enable')
|
||||
}
|
||||
return t('appstore', 'Install and force enable')
|
||||
},
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.enableApp(app.id, true)
|
||||
},
|
||||
}
|
||||
65
apps/appstore/src/actions/actionInteract.ts
Normal file
65
apps/appstore/src/actions/actionInteract.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiBugOutline, mdiForumOutline, mdiStarOutline, mdiWeb } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export const actionsInteract: AppAction[] = [
|
||||
{
|
||||
id: 'rate',
|
||||
icon: mdiStarOutline,
|
||||
order: 30,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Rate the app'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.fromAppStore
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return `https://apps.nextcloud.com/apps/${encodeURIComponent(app.id)}#comments`
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report-bug',
|
||||
icon: mdiBugOutline,
|
||||
order: 32,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Report a bug'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.bugs
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.bugs!
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'discussion',
|
||||
icon: mdiForumOutline,
|
||||
order: 35,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Ask questions or discuss the app'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.discussion
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.discussion!
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'website',
|
||||
icon: mdiWeb,
|
||||
order: 38,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Visit the website'),
|
||||
enabled(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return !!app.website
|
||||
},
|
||||
href(app: IAppstoreApp | IAppstoreExApp) {
|
||||
return app.website!
|
||||
},
|
||||
},
|
||||
]
|
||||
27
apps/appstore/src/actions/actionLimitToGroup.ts
Normal file
27
apps/appstore/src/actions/actionLimitToGroup.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiAccountGroup } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { spawnDialog } from '@nextcloud/vue'
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { canLimitToGroups } from '../utils/appStatus.ts'
|
||||
|
||||
const LimitToGroupDialog = defineAsyncComponent(() => import('../components/LimitToGroupDialog.vue'))
|
||||
|
||||
export const actionLimitToGroup: AppAction = {
|
||||
id: 'limit-to-group',
|
||||
icon: mdiAccountGroup,
|
||||
order: 16,
|
||||
inline: false,
|
||||
label: () => t('appstore', 'Limit to groups'),
|
||||
enabled: canLimitToGroups,
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
await spawnDialog(LimitToGroupDialog, { app })
|
||||
},
|
||||
}
|
||||
26
apps/appstore/src/actions/actionRemove.ts
Normal file
26
apps/appstore/src/actions/actionRemove.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiTrashCanOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useAppsStore } from '../store/apps.ts'
|
||||
import { canUninstall } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionRemove: AppAction = {
|
||||
id: 'remove',
|
||||
order: 20,
|
||||
icon: mdiTrashCanOutline,
|
||||
variant: 'error',
|
||||
inline: false,
|
||||
enabled: canUninstall,
|
||||
label: () => t('appstore', 'Remove'),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useAppsStore()
|
||||
await store.uninstallApp(app.id)
|
||||
},
|
||||
}
|
||||
38
apps/appstore/src/actions/actionUpdate.ts
Normal file
38
apps/appstore/src/actions/actionUpdate.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
import type { AppAction } from './index.ts'
|
||||
|
||||
import { mdiUpdate } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useExAppsStore } from '../store/exApps.ts'
|
||||
import { useUpdatesStore } from '../store/updates.ts'
|
||||
import { canUpdate } from '../utils/appStatus.ts'
|
||||
|
||||
export const actionUpdate: AppAction = {
|
||||
id: 'update',
|
||||
icon: mdiUpdate,
|
||||
variant: 'primary',
|
||||
order: 0,
|
||||
enabled(app) {
|
||||
if (!canUpdate(app)) {
|
||||
return false
|
||||
}
|
||||
if (app.app_api) {
|
||||
if (app.daemon && app.daemon?.accepts_deploy_id === 'manual-install') {
|
||||
return true
|
||||
}
|
||||
const exAppsStore = useExAppsStore()
|
||||
return exAppsStore.daemonAccessible
|
||||
}
|
||||
return true
|
||||
},
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => t('appstore', 'Update to {version}', { version: app.update! }),
|
||||
async callback(app: IAppstoreApp | IAppstoreExApp) {
|
||||
const store = useUpdatesStore()
|
||||
await store.updateApp(app.id)
|
||||
},
|
||||
}
|
||||
54
apps/appstore/src/actions/index.ts
Normal file
54
apps/appstore/src/actions/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
*/
|
||||
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { actionDisable } from './actionDisable.ts'
|
||||
import { actionEnable } from './actionEnable.ts'
|
||||
import { actionForceEnable } from './actionForceEnable.ts'
|
||||
import { actionInstall } from './actionInstall.ts'
|
||||
import { actionInstallForced } from './actionInstallForced.ts'
|
||||
import { actionsInteract } from './actionInteract.ts'
|
||||
import { actionLimitToGroup } from './actionLimitToGroup.ts'
|
||||
import { actionRemove } from './actionRemove.ts'
|
||||
import { actionUpdate } from './actionUpdate.ts'
|
||||
|
||||
interface AppActionBase {
|
||||
enabled: (app: IAppstoreApp | IAppstoreExApp) => boolean
|
||||
|
||||
id: string
|
||||
icon: string
|
||||
order: number
|
||||
label: (app: IAppstoreApp | IAppstoreExApp) => string
|
||||
variant?: 'primary' | 'error' | 'warning'
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
interface AppActionWithCallback extends AppActionBase {
|
||||
callback: (app: IAppstoreApp | IAppstoreExApp) => Promise<void>
|
||||
}
|
||||
|
||||
interface AppActionWithHref extends AppActionBase {
|
||||
href: (app: IAppstoreApp | IAppstoreExApp) => string
|
||||
}
|
||||
|
||||
interface AppActionWithRoute extends AppActionBase {
|
||||
to: (app: IAppstoreApp | IAppstoreExApp) => RouteLocationRaw
|
||||
}
|
||||
|
||||
export type AppAction = AppActionWithCallback | AppActionWithHref | AppActionWithRoute
|
||||
|
||||
export const actions = [
|
||||
actionUpdate,
|
||||
actionEnable,
|
||||
actionDisable,
|
||||
actionForceEnable,
|
||||
actionInstall,
|
||||
actionInstallForced,
|
||||
actionRemove,
|
||||
actionLimitToGroup,
|
||||
...actionsInteract,
|
||||
].sort((a, b) => a.order - b.order)
|
||||
112
apps/appstore/src/apps-discover.d.ts
vendored
Normal file
112
apps/appstore/src/apps-discover.d.ts
vendored
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper for localized values
|
||||
*/
|
||||
export type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
|
||||
|
||||
export interface IAppDiscoverElement {
|
||||
/**
|
||||
* Type of the element
|
||||
*/
|
||||
type: typeof APP_DISCOVER_KNOWN_TYPES[number]
|
||||
|
||||
/**
|
||||
* Identifier for this element
|
||||
*/
|
||||
id: string
|
||||
|
||||
/**
|
||||
* Order of this element to pin elements (smaller = shown on top)
|
||||
*/
|
||||
order?: number
|
||||
|
||||
/**
|
||||
* Optional, localized, headline for the element
|
||||
*/
|
||||
headline?: ILocalizedValue<string>
|
||||
|
||||
/**
|
||||
* Optional link target for the element
|
||||
*/
|
||||
link?: string
|
||||
|
||||
/**
|
||||
* Optional date when this element will get valid (only show since then)
|
||||
*/
|
||||
date?: number
|
||||
|
||||
/**
|
||||
* Optional date when this element will be invalid (only show until then)
|
||||
*/
|
||||
expiryDate?: number
|
||||
}
|
||||
|
||||
/** Wrapper for media source and MIME type */
|
||||
type MediaSource = { src: string, mime: string }
|
||||
|
||||
/**
|
||||
* Media content type for posts
|
||||
*/
|
||||
interface IAppDiscoverMediaContent {
|
||||
/**
|
||||
* The media source to show - either one or a list of sources with their MIME type for fallback options
|
||||
*/
|
||||
src: MediaSource | MediaSource[]
|
||||
|
||||
/**
|
||||
* Alternative text for the media
|
||||
*/
|
||||
alt: string
|
||||
|
||||
/**
|
||||
* Optional link target for the media (e.g. to the full video)
|
||||
*/
|
||||
link?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for post media
|
||||
*/
|
||||
interface IAppDiscoverMedia {
|
||||
/**
|
||||
* The alignment of the media element
|
||||
*/
|
||||
alignment?: 'start' | 'end' | 'center'
|
||||
|
||||
/**
|
||||
* The (localized) content
|
||||
*/
|
||||
content: ILocalizedValue<IAppDiscoverMediaContent>
|
||||
}
|
||||
|
||||
/**
|
||||
* An app element only used for the showcase type
|
||||
*/
|
||||
export interface IAppDiscoverApp {
|
||||
/** The App ID */
|
||||
type: 'app'
|
||||
appId: string
|
||||
}
|
||||
|
||||
export interface IAppDiscoverPost extends IAppDiscoverElement {
|
||||
type: 'post'
|
||||
text?: ILocalizedValue<string>
|
||||
media?: IAppDiscoverMedia
|
||||
}
|
||||
|
||||
export interface IAppDiscoverShowcase extends IAppDiscoverElement {
|
||||
type: 'showcase'
|
||||
content: (IAppDiscoverPost | IAppDiscoverApp)[]
|
||||
}
|
||||
|
||||
export interface IAppDiscoverCarousel extends IAppDiscoverElement {
|
||||
type: 'carousel'
|
||||
text?: ILocalizedValue<string>
|
||||
content: IAppDiscoverPost[]
|
||||
}
|
||||
|
||||
export type IAppDiscoverElements = IAppDiscoverPost | IAppDiscoverCarousel | IAppDiscoverShowcase
|
||||
186
apps/appstore/src/apps.d.ts
vendored
Normal file
186
apps/appstore/src/apps.d.ts
vendored
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface IAppstoreCategory {
|
||||
/**
|
||||
* The category ID
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The display name (can be localized)
|
||||
*/
|
||||
displayName: string
|
||||
/**
|
||||
* Inline SVG path
|
||||
*/
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface IAppstoreAppRelease {
|
||||
version: string
|
||||
lastModified?: string
|
||||
translations: {
|
||||
[key: string]: {
|
||||
changelog: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type IAppInfoTypes = 'prelogin' | 'filesystem' | 'authentication' | 'extended_authentication' | 'logging' | 'dav' | 'prevent_group_restriction' | 'session'
|
||||
|
||||
/**
|
||||
* The metadata that is available in the info.xml of an app.
|
||||
* This is sourced by the appstore but also available for already installed apps (e.g. shipped apps).
|
||||
*/
|
||||
interface IAppInfoData {
|
||||
id: string
|
||||
name: string
|
||||
summary: string
|
||||
description: string
|
||||
/** The license of the app */
|
||||
license: string
|
||||
/** The author(s) of the app (either list of names or object for XML nodes) */
|
||||
author: string[] | Record<string, string>
|
||||
/** The support level of this app (e.g. maintained by Nextcloud GmbH) */
|
||||
level: number
|
||||
/** The version of the app */
|
||||
version: string
|
||||
/** The category(s) this app belongs to */
|
||||
category: string | string[]
|
||||
/** The URL of the app's screenshot */
|
||||
screenshot?: string
|
||||
/** The types this app supports */
|
||||
types?: IAppInfoTypes[]
|
||||
|
||||
documentation?: {
|
||||
admin: string
|
||||
user: string
|
||||
developer: string
|
||||
}
|
||||
website?: string
|
||||
discussion?: string
|
||||
bugs?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata added when this app is sourced from the appstore.
|
||||
* It is not available for non-appstore apps.
|
||||
*/
|
||||
interface IAppstoreMetadata {
|
||||
fromAppStore: true
|
||||
/** List of appstore release information (e.g. changelog) */
|
||||
releases: IAppstoreAppRelease[]
|
||||
/** The overall rating of the app */
|
||||
ratingOverall: number
|
||||
/** The number of ratings for the app */
|
||||
ratingNumOverall: number
|
||||
}
|
||||
|
||||
export interface IAppstoreAppResponse extends IAppInfoData, Partial<IAppstoreMetadata> {
|
||||
/** The app icon to use */
|
||||
icon?: string
|
||||
|
||||
// App dependency information
|
||||
dependencies: unknown
|
||||
missingMaxNextcloudVersion: boolean
|
||||
missingMinNextcloudVersion: boolean
|
||||
|
||||
// App state information
|
||||
|
||||
/** Whether the app is an ExApp (docker based app) */
|
||||
app_api: false
|
||||
/** Whether the app is internal = always enabled an cannot be disabled */
|
||||
internal: boolean
|
||||
/** Whether the app is shipped / bundled with Nextcloud (not from appstore) */
|
||||
shipped: boolean
|
||||
/** Whether the app is currently active (enabled) */
|
||||
active: boolean
|
||||
/** Whether the app can be removed */
|
||||
removable: boolean
|
||||
/** Whether the app is installed */
|
||||
installed: boolean
|
||||
/** If all dependencies are met */
|
||||
isCompatible: boolean
|
||||
/** Whether the app needs to be downloaded (not locally available) */
|
||||
needsDownload: boolean
|
||||
/** List of missing dependencies */
|
||||
missingDependencies?: string[]
|
||||
/** Available update version */
|
||||
update?: string
|
||||
/** User groups this app is limited to */
|
||||
groups?: string[]
|
||||
}
|
||||
|
||||
export interface IAppstoreApp extends IAppstoreAppResponse {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export interface IComputeDevice {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface IDeployConfig {
|
||||
computeDevice: IComputeDevice
|
||||
net: string
|
||||
nextcloud_url: string
|
||||
}
|
||||
|
||||
export interface IDeployDaemon {
|
||||
accepts_deploy_id: string
|
||||
deploy_config: IDeployConfig
|
||||
display_name: string
|
||||
host: string
|
||||
id: number
|
||||
name: string
|
||||
protocol: string
|
||||
exAppsCount: number
|
||||
}
|
||||
|
||||
export interface IExAppStatus {
|
||||
action: string
|
||||
deploy: number
|
||||
deploy_start_time?: number
|
||||
error?: string
|
||||
init: number
|
||||
init_start_time?: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface IDeployEnv {
|
||||
envName: string
|
||||
displayName: string
|
||||
description: string
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface IDeployMount {
|
||||
hostPath: string
|
||||
containerPath: string
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
export interface IDeployOptions {
|
||||
environment_variables: IDeployEnv[]
|
||||
mounts: IDeployMount[]
|
||||
}
|
||||
|
||||
export interface IAppstoreExAppRelease extends IAppstoreAppRelease {
|
||||
environmentVariables?: IDeployEnv[]
|
||||
}
|
||||
|
||||
export interface IAppstoreExApp extends IAppstoreApp {
|
||||
app_api: true
|
||||
daemon: IDeployDaemon | null | undefined
|
||||
status: IExAppStatus | Record<string, never>
|
||||
error?: string
|
||||
releases: IAppstoreExAppRelease[]
|
||||
}
|
||||
|
||||
export interface IAppBundle {
|
||||
id: string
|
||||
name: string
|
||||
appIdentifiers: readonly string[]
|
||||
}
|
||||
103
apps/appstore/src/components/AppActions.vue
Normal file
103
apps/appstore/src/components/AppActions.vue
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AppAction } from '../actions/index.ts'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActionLink from '@nextcloud/vue/components/NcActionLink'
|
||||
import NcActionRouter from '@nextcloud/vue/components/NcActionRouter'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const { actions, maxInlineActions = 1 } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
actions: AppAction[]
|
||||
maxInlineActions?: number
|
||||
iconOnly?: boolean
|
||||
}>()
|
||||
|
||||
const inlineActions = computed(() => {
|
||||
if (actions.length <= maxInlineActions) {
|
||||
return actions
|
||||
}
|
||||
return actions
|
||||
.filter((action) => action.inline !== false)
|
||||
.slice(0, maxInlineActions)
|
||||
})
|
||||
|
||||
const menuActions = computed(() => actions
|
||||
.filter((action) => !inlineActions.value.includes(action)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appActions">
|
||||
<NcButton
|
||||
v-for="action in inlineActions"
|
||||
:key="action.id"
|
||||
:ariaLabel="iconOnly ? action.label(app) : undefined"
|
||||
:title="iconOnly ? action.label(app) : undefined"
|
||||
:variant="action.variant"
|
||||
:href="'href' in action ? action.href(app) : undefined"
|
||||
:to="'to' in action ? action.to(app) : undefined"
|
||||
:target="'href' in action ? '_blank' : undefined"
|
||||
@click="'callback' in action && action.callback(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
<template v-if="!iconOnly" #default>
|
||||
{{ action.label(app) }}
|
||||
</template>
|
||||
</NcButton>
|
||||
<NcActions forceMenu>
|
||||
<template v-for="action in menuActions">
|
||||
<NcActionButton
|
||||
v-if="'callback' in action"
|
||||
:key="'callback-' + action.id"
|
||||
closeAfterClick
|
||||
:variant="action.variant"
|
||||
@click="action.callback(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
{{ action.label(app) }}
|
||||
</NcActionButton>
|
||||
<NcActionLink
|
||||
v-else-if="'href' in action"
|
||||
:key="'link-' + action.id"
|
||||
closeAfterClick
|
||||
:variant="action.variant"
|
||||
:href="action.href(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
{{ action.label(app) }}
|
||||
</NcActionLink>
|
||||
<NcActionRouter
|
||||
v-else
|
||||
:key="'route-' + action.id"
|
||||
closeAfterClick
|
||||
:variant="action.variant"
|
||||
:to="action.to(app)">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="action.icon" />
|
||||
</template>
|
||||
{{ action.label(app) }}
|
||||
</NcActionRouter>
|
||||
</template>
|
||||
</NcActions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(2 * var(--default-grid-baseline));
|
||||
}
|
||||
</style>
|
||||
38
apps/appstore/src/components/AppGrid/AppGrid.vue
Normal file
38
apps/appstore/src/components/AppGrid/AppGrid.vue
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AppGridItem from './AppGridItem.vue'
|
||||
import { useUserSettingsStore } from '../../store/userSettings.ts'
|
||||
|
||||
defineProps<{
|
||||
apps: (IAppstoreApp | IAppstoreExApp)[]
|
||||
}>()
|
||||
|
||||
const userSettings = useUserSettingsStore()
|
||||
const gridSize = computed(() => userSettings.gridSizePx)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul :class="$style.appGrid">
|
||||
<AppGridItem
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appGrid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: calc(4 * var(--default-grid-baseline));
|
||||
grid-template-columns: repeat(auto-fit, minmax(v-bind(gridSize), 1fr));
|
||||
padding-inline-start: var(--app-navigation-padding);
|
||||
}
|
||||
</style>
|
||||
93
apps/appstore/src/components/AppGrid/AppGridItem.vue
Normal file
93
apps/appstore/src/components/AppGrid/AppGridItem.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppImage from '../AppImage.vue'
|
||||
import BadgeAppDaemon from '../BadgeAppDaemon.vue'
|
||||
import BadgeAppLevel from '../BadgeAppLevel.vue'
|
||||
import BadgeAppScore from '../BadgeAppScore.vue'
|
||||
import { useUserSettingsStore } from '../../store/userSettings.ts'
|
||||
|
||||
const { app } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
|
||||
const userSettingsStore = useUserSettingsStore()
|
||||
const route = useRoute()
|
||||
const routeToDetails = computed(() => ({
|
||||
...route,
|
||||
params: {
|
||||
...route.params,
|
||||
id: app.id,
|
||||
},
|
||||
query: userSettingsStore.getQuery(),
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="$style.appGridItem">
|
||||
<RouterLink :to="routeToDetails">
|
||||
<AppImage :app :class="$style.appGridItem__image" />
|
||||
<div :class="$style.appGridItem__content">
|
||||
<h3 :class="$style.appGridItem__name">
|
||||
{{ app.name }}
|
||||
</h3>
|
||||
<p>{{ app.summary }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<div :class="$style.appGridItem__badges">
|
||||
<BadgeAppScore :app />
|
||||
<BadgeAppLevel :level="app.level" />
|
||||
<BadgeAppDaemon v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appGridItem {
|
||||
background-color: var(--color-primary-element-light);
|
||||
color: var(--color-primary-element-light-text);
|
||||
border-radius: var(--border-radius-element);
|
||||
padding-block-end: var(--border-radius-element);;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: var(--default-grid-baseline);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-element-light-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.appGridItem__content {
|
||||
padding-inline: var(--border-radius-element);
|
||||
}
|
||||
|
||||
.appGridItem__image {
|
||||
aspect-ratio: 16 / 9;
|
||||
height: min-content;
|
||||
border-start-start-radius: var(--border-radius-element);
|
||||
border-start-end-radius: var(--border-radius-element);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appGridItem__name {
|
||||
font-size: 1.2em;
|
||||
font-weight: var(--font-weight-heading, bold);
|
||||
margin-block: var(--default-grid-baseline) calc(2 * var(--default-grid-baseline));
|
||||
}
|
||||
|
||||
.appGridItem__badges {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--default-grid-baseline);
|
||||
padding-inline: var(--border-radius-element);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
64
apps/appstore/src/components/AppIcon.vue
Normal file
64
apps/appstore/src/components/AppIcon.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiCogOutline } from '@mdi/js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const { app, noFallback, size = 20 } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
noFallback?: boolean
|
||||
size?: number
|
||||
}>()
|
||||
|
||||
const isSvg = computed(() => app.icon?.endsWith('.svg'))
|
||||
const svgIcon = ref<string>('')
|
||||
watch(() => app.icon, async () => {
|
||||
svgIcon.value = ''
|
||||
if (app.icon?.endsWith('.svg')) {
|
||||
const response = await fetch(app.icon)
|
||||
if (response.ok) {
|
||||
svgIcon.value = await response.text()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="$style.appIcon">
|
||||
<NcIconSvgWrapper
|
||||
v-if="svgIcon"
|
||||
:size
|
||||
:svg="svgIcon" />
|
||||
<img
|
||||
v-else-if="app.icon && !isSvg"
|
||||
:class="$style.appIcon__image"
|
||||
alt=""
|
||||
:src="app.icon"
|
||||
:height="size"
|
||||
:width="size">
|
||||
<NcIconSvgWrapper
|
||||
v-else-if="!noFallback"
|
||||
:path="mdiCogOutline"
|
||||
:size />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appIcon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.appIcon__image {
|
||||
filter: var(--invert-if-dark);
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
81
apps/appstore/src/components/AppImage.vue
Normal file
81
apps/appstore/src/components/AppImage.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts'
|
||||
|
||||
import { mdiCogOutline } from '@mdi/js'
|
||||
import { NcLoadingIcon } from '@nextcloud/vue'
|
||||
import PQueue from 'p-queue'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
||||
const props = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
|
||||
const isError = ref(false)
|
||||
const isLoading = ref(true)
|
||||
watchEffect(() => {
|
||||
if (props.app.screenshot) {
|
||||
isError.value = false
|
||||
isLoading.value = true
|
||||
queue.add(() => {
|
||||
const image = new Image()
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
image.onload = () => {
|
||||
isLoading.value = false
|
||||
resolve()
|
||||
}
|
||||
image.onerror = () => {
|
||||
isError.value = true
|
||||
isLoading.value = false
|
||||
resolve()
|
||||
}
|
||||
image.src = props.app.screenshot!
|
||||
return promise
|
||||
})
|
||||
} else {
|
||||
isLoading.value = false
|
||||
isError.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const queue = new PQueue({ concurrency: 4 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appImage">
|
||||
<NcIconSvgWrapper
|
||||
v-if="isError || !props.app.screenshot"
|
||||
:size="80"
|
||||
:path="mdiCogOutline" />
|
||||
|
||||
<NcLoadingIcon v-else-if="isLoading" :size="80" />
|
||||
|
||||
<img
|
||||
v-else
|
||||
:class="$style.appImage__image"
|
||||
:src="props.app.screenshot"
|
||||
alt="">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appImage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.appImage__image {
|
||||
object-fit: cover;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
83
apps/appstore/src/components/AppLink.vue
Normal file
83
apps/appstore/src/components/AppLink.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* This component either shows a native link to the installed app or external size
|
||||
* or a router link to the appstore page of the app if not installed
|
||||
*/
|
||||
|
||||
import type { RouterLinkProps } from 'vue-router'
|
||||
import type { INavigationEntry } from '../../../../core/src/types/navigation.d.ts'
|
||||
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
href: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const knownRoutes = Object.fromEntries(loadState<INavigationEntry[]>('core', 'apps').map((app) => [app.app ?? app.id, app.href]))
|
||||
|
||||
const routerProps = ref<RouterLinkProps>()
|
||||
const linkProps = ref<Record<string, string>>()
|
||||
|
||||
watchEffect(() => {
|
||||
const match = props.href.match(/^app:(\/\/)?([^/]+)(\/.+)?$/)
|
||||
routerProps.value = undefined
|
||||
linkProps.value = undefined
|
||||
|
||||
// not an app url
|
||||
if (match === null) {
|
||||
linkProps.value = {
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const appId = match[2]!
|
||||
// Check if specific route was requested
|
||||
if (match[3]) {
|
||||
// we do no know anything about app internal path so we only allow generic app paths
|
||||
linkProps.value = {
|
||||
href: generateUrl(`/apps/${appId}${match[3]}`),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If we know any route for that app we open it
|
||||
if (appId in knownRoutes) {
|
||||
linkProps.value = {
|
||||
href: knownRoutes[appId]!,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to show the app store entry
|
||||
routerProps.value = {
|
||||
to: {
|
||||
name: 'apps-discover',
|
||||
params: {
|
||||
category: route.params?.category ?? 'discover',
|
||||
id: appId,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a v-if="linkProps" v-bind="linkProps">
|
||||
<slot />
|
||||
</a>
|
||||
<RouterLink v-else-if="routerProps" v-bind="routerProps">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
</template>
|
||||
77
apps/appstore/src/components/AppTable/AppTable.vue
Normal file
77
apps/appstore/src/components/AppTable/AppTable.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import AppTableRow from './AppTableRow.vue'
|
||||
|
||||
defineProps<{
|
||||
apps: (IAppstoreApp | IAppstoreExApp)[]
|
||||
}>()
|
||||
|
||||
const tableElement = useTemplateRef('table')
|
||||
const { width: tableWidth } = useElementSize(tableElement)
|
||||
|
||||
const isNarrow = computed(() => tableWidth.value < 768)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table ref="table" :class="[$style.appTable, { [$style.appTable_narrow]: isNarrow }]">
|
||||
<colgroup>
|
||||
<col :class="$style.appTable__colName">
|
||||
<col :class="$style.appTable__colVersion">
|
||||
<col v-if="!isNarrow" :class="$style.appTable__colSupport">
|
||||
<col :class="$style.appTable__colActions">
|
||||
</colgroup>
|
||||
<thead hidden>
|
||||
<tr>
|
||||
<th>{{ t('appstore', 'App name') }}</th>
|
||||
<th>{{ t('appstore', 'Version') }}</th>
|
||||
<th v-if="!isNarrow">
|
||||
{{ t('appstore', 'Support level') }}
|
||||
</th>
|
||||
<th>{{ t('appstore', 'Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AppTableRow
|
||||
v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app
|
||||
:isNarrow />
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appTable {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appTable__colName {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.appTable_narrow .appTable__colName {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.appTable__colSupport {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.appTable__colActions {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.appTable_narrow .appTable__colActions {
|
||||
width: calc(3 * var(--default-grid-baseline) + 2 * var(--default-clickable-area));
|
||||
}
|
||||
</style>
|
||||
130
apps/appstore/src/components/AppTable/AppTableRow.vue
Normal file
130
apps/appstore/src/components/AppTable/AppTableRow.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AppAction } from '../../actions/index.ts'
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiInformationOutline } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||||
import AppActions from '../AppActions.vue'
|
||||
import AppIcon from '../AppIcon.vue'
|
||||
import BadgeAppDaemon from '../BadgeAppDaemon.vue'
|
||||
import BadgeAppLevel from '../BadgeAppLevel.vue'
|
||||
import { useActions } from '../../composables/useActions.ts'
|
||||
|
||||
const { app, isNarrow } = defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
isNarrow?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const detailsRoute = computed(() => ({
|
||||
...route,
|
||||
params: {
|
||||
...route.params,
|
||||
id: app.id,
|
||||
},
|
||||
query: {
|
||||
...route.query,
|
||||
},
|
||||
}))
|
||||
|
||||
const detailsAction = computed<AppAction>(() => ({
|
||||
id: 'details',
|
||||
order: 99,
|
||||
enabled: () => true,
|
||||
label: () => t('appstore', 'Show details'),
|
||||
icon: mdiInformationOutline,
|
||||
to: () => detailsRoute.value,
|
||||
inline: false,
|
||||
}))
|
||||
|
||||
const rawActions = useActions(() => app)
|
||||
const actions = computed(() => [
|
||||
...rawActions.value,
|
||||
detailsAction.value,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr :class="$style.appTableRow">
|
||||
<td :class="$style.appTableRow__nameCell">
|
||||
<NcButton
|
||||
alignment="start"
|
||||
:title="t('appstore', 'Show details')"
|
||||
:to="detailsRoute"
|
||||
variant="tertiary-no-background"
|
||||
wide>
|
||||
<template #icon>
|
||||
<NcLoadingIcon v-if="app.loading" :size="24" />
|
||||
<AppIcon v-else :app :size="24" />
|
||||
</template>
|
||||
{{ app.name }}
|
||||
<span v-if="app.loading" class="hidden-visually">({{ t('appstore', 'is loading…') }})</span>
|
||||
<span class="hidden-visually">({{ t('appstore', 'Show details') }})</span>
|
||||
</NcButton>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="$style.appTableRow__versionCell">{{ app.version }}</span>
|
||||
</td>
|
||||
<td v-if="!isNarrow">
|
||||
<div :class="$style.appTableRow__levelCell">
|
||||
<BadgeAppLevel v-if="app.level" :level="app.level" />
|
||||
<BadgeAppDaemon v-if="'daemon' in app && app.daemon" :daemon="app.daemon" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.appTableRow__actionsCell">
|
||||
<AppActions
|
||||
:class="$style.appTableRow__actionsCellActions"
|
||||
:app
|
||||
:actions
|
||||
:iconOnly="isNarrow" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appTableRow {
|
||||
height: calc(var(--default-clickable-area) + var(--default-grid-baseline));
|
||||
}
|
||||
|
||||
.appTableRow td {
|
||||
padding-block: var(--default-grid-baseline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.appTableRow__nameCell {
|
||||
/* Padding is needed to have proper focus-visible */
|
||||
padding-inline: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
.appTableRow__levelCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--default-grid-baseline)
|
||||
}
|
||||
|
||||
.appTableRow__versionCell {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.appTableRow__actionsCell {
|
||||
display: flex;
|
||||
gap: var(--default-grid-baseline);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.appTableRow__actionsCellActions {
|
||||
width: 100%;
|
||||
justify-content: end;
|
||||
}
|
||||
</style>
|
||||
136
apps/appstore/src/components/AppToolbar.vue
Normal file
136
apps/appstore/src/components/AppToolbar.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiFilterVariant, mdiSizeL, mdiSizeM, mdiSizeS, mdiViewGrid } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import NcActionButtonGroup from '@nextcloud/vue/components/NcActionButtonGroup'
|
||||
import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
|
||||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import { useUserSettingsStore } from '../store/userSettings.ts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userSettingsStore = useUserSettingsStore()
|
||||
|
||||
watch(() => userSettingsStore.isGridView, (enabled: boolean) => {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
grid: enabled ? null : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => userSettingsStore.defaultGridSize, (newSize) => {
|
||||
if (userSettingsStore.isGridView) {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
grid: newSize || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => userSettingsStore.showIncompatible, (showIncompatible) => {
|
||||
if (showIncompatible) {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
compatible: undefined,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
router.replace({
|
||||
...route,
|
||||
query: {
|
||||
...route.query,
|
||||
compatible: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.appToolbar">
|
||||
<NcActions :class="$style.appToolbar__filterButton" :aria-label="t('appstore', 'Filter view')" forceMenu>
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFilterVariant" />
|
||||
</template>
|
||||
<NcActionButtonGroup v-if="userSettingsStore.isGridView" :name="t('appstore', 'Grid size')">
|
||||
<NcActionButton
|
||||
:aria-label="t('appstore', 'Small grid size')"
|
||||
:modelValue="userSettingsStore.defaultGridSize === ''"
|
||||
type="radio"
|
||||
value=""
|
||||
@update:modelValue="userSettingsStore.defaultGridSize = ''">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiSizeS" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('appstore', 'Medium grid size')"
|
||||
:modelValue="userSettingsStore.defaultGridSize === 'm'"
|
||||
type="radio"
|
||||
value="m"
|
||||
@update:modelValue="userSettingsStore.defaultGridSize = 'm'">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiSizeM" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
<NcActionButton
|
||||
:aria-label="t('appstore', 'Large grid size')"
|
||||
:modelValue="userSettingsStore.defaultGridSize === 'l'"
|
||||
type="radio"
|
||||
value="l"
|
||||
@update:modelValue="userSettingsStore.defaultGridSize = 'l'">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiSizeL" />
|
||||
</template>
|
||||
</NcActionButton>
|
||||
</NcActionButtonGroup>
|
||||
|
||||
<NcActionCheckbox v-model="userSettingsStore.showIncompatible">
|
||||
{{ t('appstore', 'Show incompatible') }}
|
||||
</NcActionCheckbox>
|
||||
</NcActions>
|
||||
|
||||
<NcButton
|
||||
v-model:pressed="userSettingsStore.isGridView"
|
||||
:aria-label="t('appstore', 'Grid view')"
|
||||
variant="tertiary">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiViewGrid" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appToolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(2 * var(--default-grid-baseline));
|
||||
position: absolute;
|
||||
inset-block-start: var(--app-navigation-padding);
|
||||
inset-inline-end: var(--app-sidebar-padding);
|
||||
|
||||
z-index: 999;
|
||||
|
||||
button:not([aria-pressed="true"]):not(:hover) {
|
||||
background-color: var(--color-main-background) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { showConfirmation } from '@nextcloud/dialogs'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { ref, watch } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import OfficeSuiteSwitcherItem from './OfficeSuiteSwitcherItem.vue'
|
||||
import { OFFICE_SUITES } from '../../service/OfficeSuites.ts'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
import { canDisable, needForceEnable } from '../../utils/appStatus.ts'
|
||||
|
||||
const store = useAppsStore()
|
||||
const isAllInOne = loadState('appstore', 'isAllInOne', false)
|
||||
|
||||
const isProcessing = ref(false)
|
||||
const selectedSuiteId = ref<string | null>(getInitialSuite())
|
||||
watch(selectedSuiteId, onSuiteChanged)
|
||||
|
||||
/**
|
||||
* Get the initially selected office suite based on the installed apps
|
||||
*/
|
||||
function getInitialSuite() {
|
||||
for (const suite of OFFICE_SUITES) {
|
||||
const app = store.apps.find((a) => a.id === suite.appId && a.installed)
|
||||
if (app && app.active) {
|
||||
return suite.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all office suites
|
||||
*/
|
||||
function disableSuites() {
|
||||
selectedSuiteId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a specific office suite
|
||||
*
|
||||
* @param suite - The suite to disable
|
||||
*/
|
||||
async function disableSuite(suite: typeof OFFICE_SUITES[number]) {
|
||||
const app = store.getAppById(suite.appId)
|
||||
if (!app) {
|
||||
return
|
||||
}
|
||||
|
||||
if (canDisable(app)) {
|
||||
await store.disableApp(suite.appId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle office suite changes. Enables the selected suite and disables others.
|
||||
*
|
||||
* @param newSuiteId - The new selected suite ID
|
||||
* @param oldSuiteId - The previously selected suite ID
|
||||
*/
|
||||
async function onSuiteChanged(newSuiteId: string | null, oldSuiteId: string | null) {
|
||||
if (isProcessing.value || newSuiteId === oldSuiteId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessing.value = true
|
||||
const suite = OFFICE_SUITES.find((s) => s.id === newSuiteId)
|
||||
if (!suite) {
|
||||
// No suite selected, disable all suites
|
||||
for (const s of OFFICE_SUITES) {
|
||||
await disableSuite(s)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const app = store.getAppById(suite.appId)!
|
||||
if (needForceEnable(app)) {
|
||||
const result = await showConfirmation({
|
||||
name: t('appstore', 'Force enable {suite}?', { suite: suite.name }),
|
||||
text: t('appstore', 'Enabling {suite} requires force enabling the app. This may cause issues with your Nextcloud instance. Are you sure you want to proceed?', { suite: suite.name }),
|
||||
labelConfirm: t('appstore', 'Force enable'),
|
||||
labelReject: t('appstore', 'Cancel'),
|
||||
severity: 'warning',
|
||||
})
|
||||
|
||||
if (result) {
|
||||
await store.forceEnableApp(suite.appId)
|
||||
} else {
|
||||
// Revert selection
|
||||
selectedSuiteId.value = oldSuiteId
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the selected suite and disable others
|
||||
for (const s of OFFICE_SUITES) {
|
||||
if (s.id === newSuiteId) {
|
||||
await store.enableApp(s.appId)
|
||||
} else {
|
||||
await disableSuite(s)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isProcessing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcNoteCard v-if="isAllInOne" type="info">
|
||||
<p>{{ t('appstore', 'Office suite switching is managed through the Nextcloud All-in-One interface.') }}</p>
|
||||
<p>{{ t('appstore', 'Please use the AIO interface to switch between office suites.') }}</p>
|
||||
</NcNoteCard>
|
||||
|
||||
<section v-else :class="$style.officeSuiteSwitcher">
|
||||
<h3 :class="$style.officeSuiteSwitcher__title">
|
||||
{{ t('appstore', 'Select your preferred office suite.') }}
|
||||
</h3>
|
||||
<p>{{ t('appstore', 'Please note that installing requires manual server setup.') }}</p>
|
||||
<fieldset :class="$style.officeSuiteSwitcher__cards">
|
||||
<OfficeSuiteSwitcherItem
|
||||
v-for="suite in OFFICE_SUITES"
|
||||
:key="suite.id"
|
||||
v-model:selected="selectedSuiteId"
|
||||
:class="$style.officeSuiteSwitcher__cardsItem"
|
||||
:suite="suite"
|
||||
:loading="isProcessing" />
|
||||
</fieldset>
|
||||
<div :class="$style.officeSuiteSwitcher__actions">
|
||||
<NcButton :disabled="!selectedSuiteId" @click="disableSuites">
|
||||
{{ t('appstore', 'Disable office suites') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.officeSuiteSwitcher {
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
h3 {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
|
||||
&:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__cards {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__cardsItem {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__disableButton {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-main-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__disableButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcher__disableButton:hover:not(:disabled) {
|
||||
border-color: var(--color-primary-element);
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.officeSuiteSwitcher__cards {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { OFFICE_SUITES } from '../../service/OfficeSuites.ts'
|
||||
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, useId } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
import { canInstall } from '../../utils/appStatus.ts'
|
||||
|
||||
const selectedSuiteId = defineModel<string | null>('selected')
|
||||
|
||||
const { suite } = defineProps<{
|
||||
suite: typeof OFFICE_SUITES[number]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const headerId = useId()
|
||||
const store = useAppsStore()
|
||||
|
||||
const app = computed(() => store.getAppById(suite.appId))
|
||||
const isInstalled = computed(() => !!app.value?.installed)
|
||||
const cannotInstall = computed(() => !app.value || (!isInstalled.value && !canInstall(app.value!)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[$style.officeSuiteSwitcherItem, {
|
||||
[$style.officeSuiteSwitcherItem_selected]: selectedSuiteId === suite.id,
|
||||
}]"
|
||||
@click="selectedSuiteId = suite.id">
|
||||
<div :class="$style.officeSuiteSwitcherItem__header">
|
||||
<h3 :id="headerId" :class="$style.officeSuiteSwitcherItem__title">
|
||||
{{ suite.name }}
|
||||
<span v-if="isInstalled">({{ t('appstore', 'installed') }})</span>
|
||||
</h3>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="selectedSuiteId"
|
||||
:aria-labelledby="headerId"
|
||||
:disabled="cannotInstall"
|
||||
:loading="loading"
|
||||
type="radio"
|
||||
name="office-suite"
|
||||
:value="suite.id"
|
||||
@click.stop />
|
||||
</div>
|
||||
<ul :aria-label="t('appstore', 'Features')" :class="$style.officeSuiteSwitcherItem__features">
|
||||
<li v-for="(feature, index) in suite.features" :key="index">
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
<NcButton :href="suite.learnMoreUrl" @click.stop>
|
||||
{{ t('appstore', 'Learn more') }}↗
|
||||
</NcButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.officeSuiteSwitcherItem {
|
||||
flex: 1;
|
||||
background-color: var(--color-main-background);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem_selected {
|
||||
background: linear-gradient(135deg, var(--color-primary-element-light) 0%, var(--color-main-background) 100%);
|
||||
color: var(--color-main-text);
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__features {
|
||||
list-style: disc;
|
||||
padding: 0;
|
||||
margin: 0 0 1em 0;
|
||||
flex-grow: 1;
|
||||
|
||||
li {
|
||||
padding-block: var(--default-grid-baseline) 0;
|
||||
padding-inline-start: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-main-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-top: auto;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.officeSuiteSwitcherItem_selected .officeSuiteSwitcherItem__link {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,30 +3,11 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="app?.daemon"
|
||||
id="daemon"
|
||||
:name="t('settings', 'Daemon')"
|
||||
:order="3">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
|
||||
</template>
|
||||
<div class="daemon">
|
||||
<h4>{{ t('settings', 'Deploy Daemon') }}</h4>
|
||||
<p><b>{{ t('settings', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
|
||||
<p><b>{{ t('settings', 'Name') }}</b>: {{ app?.daemon.name }}</p>
|
||||
<p><b>{{ t('settings', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
|
||||
<p><b>{{ t('settings', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
|
||||
<p><b>{{ t('settings', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreExApp } from '../../app-types.ts'
|
||||
import type { IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiFileChart } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { ref } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
|
|
@ -38,13 +19,33 @@ const props = defineProps<{
|
|||
const gpuSupport = ref(props.app?.daemon?.deploy_config?.computeDevice?.id !== 'cpu' || false)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.daemon {
|
||||
padding: 20px;
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="app?.daemon"
|
||||
id="daemon"
|
||||
:name="t('appstore', 'Daemon')"
|
||||
:order="5">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiFileChart" :size="24" />
|
||||
</template>
|
||||
<div :class="$style.appDeployDaemonTab">
|
||||
<h4>{{ t('appstore', 'Deploy Daemon') }}</h4>
|
||||
<p><b>{{ t('appstore', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
|
||||
<p><b>{{ t('appstore', 'Name') }}</b>: {{ app?.daemon.name }}</p>
|
||||
<p><b>{{ t('appstore', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
|
||||
<p><b>{{ t('appstore', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
|
||||
<p><b>{{ t('appstore', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin: 10px auto;
|
||||
}
|
||||
<style module>
|
||||
.appDeployDaemonTab {
|
||||
padding: 20px;
|
||||
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,17 +7,17 @@
|
|||
<NcDialog
|
||||
:open="show"
|
||||
size="normal"
|
||||
:name="t('settings', 'Advanced deploy options')"
|
||||
:name="t('appstore', 'Advanced deploy options')"
|
||||
@update:open="$emit('update:show', $event)">
|
||||
<div class="modal__content">
|
||||
<p class="deploy-option__hint">
|
||||
{{ configuredDeployOptions === null ? t('settings', 'Edit ExApp deploy options before installation') : t('settings', 'Configured ExApp deploy options. Can be set only during installation') }}.
|
||||
{{ configuredDeployOptions === null ? t('appstore', 'Edit ExApp deploy options before installation') : t('appstore', 'Configured ExApp deploy options. Can be set only during installation') }}.
|
||||
<a v-if="deployOptionsDocsUrl" :href="deployOptionsDocsUrl">
|
||||
{{ t('settings', 'Learn more') }}
|
||||
{{ t('appstore', 'Learn more') }}
|
||||
</a>
|
||||
</p>
|
||||
<h3 v-if="environmentVariables.length > 0 || (configuredDeployOptions !== null && configuredDeployOptions.environment_variables.length > 0)">
|
||||
{{ t('settings', 'Environment variables') }}
|
||||
{{ t('appstore', 'Environment variables') }}
|
||||
</h3>
|
||||
<template v-if="configuredDeployOptions === null">
|
||||
<div
|
||||
|
|
@ -34,40 +34,40 @@
|
|||
v-else-if="Object.keys(configuredDeployOptions).length > 0"
|
||||
class="envs">
|
||||
<legend class="deploy-option__hint">
|
||||
{{ t('settings', 'ExApp container environment variables') }}
|
||||
{{ t('appstore', 'ExApp container environment variables') }}
|
||||
</legend>
|
||||
<NcTextField
|
||||
v-for="(value, key) in configuredDeployOptions.environment_variables"
|
||||
:key="key"
|
||||
:key
|
||||
:label="value.displayName ?? key"
|
||||
:helper-text="value.description"
|
||||
:model-value="value.value"
|
||||
:helperText="value.description"
|
||||
:modelValue="value.value"
|
||||
readonly />
|
||||
</fieldset>
|
||||
<template v-else>
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'No environment variables defined') }}
|
||||
{{ t('appstore', 'No environment variables defined') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<h3>{{ t('settings', 'Mounts') }}</h3>
|
||||
<h3>{{ t('appstore', 'Mounts') }}</h3>
|
||||
<template v-if="configuredDeployOptions === null">
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'Define host folder mounts to bind to the ExApp container') }}
|
||||
{{ t('appstore', 'Define host folder mounts to bind to the ExApp container') }}
|
||||
</p>
|
||||
<NcNoteCard type="info" :text="t('settings', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
|
||||
<NcNoteCard type="info" :text="t('appstore', 'Must exist on the Deploy daemon host prior to installing the ExApp')" />
|
||||
<div
|
||||
v-for="mount in deployOptions.mounts"
|
||||
:key="mount.hostPath"
|
||||
class="deploy-option"
|
||||
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" />
|
||||
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" />
|
||||
<NcCheckboxRadioSwitch v-model="mount.readonly">
|
||||
{{ t('settings', 'Read-only') }}
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Remove mount')"
|
||||
:aria-label="t('appstore', 'Remove mount')"
|
||||
style="margin-top: 6px;"
|
||||
@click="removeMount(mount)">
|
||||
<template #icon>
|
||||
|
|
@ -77,73 +77,73 @@
|
|||
</div>
|
||||
<div v-if="addingMount" class="deploy-option">
|
||||
<h4>
|
||||
{{ t('settings', 'New mount') }}
|
||||
{{ t('appstore', 'New mount') }}
|
||||
</h4>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField
|
||||
ref="newMountHostPath"
|
||||
v-model="newMountPoint.hostPath"
|
||||
:label="t('settings', 'Host path')"
|
||||
:aria-label="t('settings', 'Enter path to host folder')" />
|
||||
:label="t('appstore', 'Host path')"
|
||||
:aria-label="t('appstore', 'Enter path to host folder')" />
|
||||
<NcTextField
|
||||
v-model="newMountPoint.containerPath"
|
||||
:label="t('settings', 'Container path')"
|
||||
:aria-label="t('settings', 'Enter path to container folder')" />
|
||||
:label="t('appstore', 'Container path')"
|
||||
:aria-label="t('appstore', 'Enter path to container folder')" />
|
||||
<NcCheckboxRadioSwitch
|
||||
v-model="newMountPoint.readonly"
|
||||
:aria-label="t('settings', 'Toggle read-only mode')">
|
||||
{{ t('settings', 'Read-only') }}
|
||||
:aria-label="t('appstore', 'Toggle read-only mode')">
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; margin-top: 4px;">
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Confirm adding new mount')"
|
||||
:aria-label="t('appstore', 'Confirm adding new mount')"
|
||||
@click="addMountPoint">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiCheck" />
|
||||
</template>
|
||||
{{ t('settings', 'Confirm') }}
|
||||
{{ t('appstore', 'Confirm') }}
|
||||
</NcButton>
|
||||
<NcButton
|
||||
:aria-label="t('settings', 'Cancel adding mount')"
|
||||
:aria-label="t('appstore', 'Cancel adding mount')"
|
||||
style="margin-left: 4px;"
|
||||
@click="cancelAddMountPoint">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiClose" />
|
||||
</template>
|
||||
{{ t('settings', 'Cancel') }}
|
||||
{{ t('appstore', 'Cancel') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
<NcButton
|
||||
v-if="!addingMount"
|
||||
:aria-label="t('settings', 'Add mount')"
|
||||
:aria-label="t('appstore', 'Add mount')"
|
||||
style="margin-top: 5px;"
|
||||
@click="startAddingMount">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiPlus" />
|
||||
</template>
|
||||
{{ t('settings', 'Add mount') }}
|
||||
{{ t('appstore', 'Add mount') }}
|
||||
</NcButton>
|
||||
</template>
|
||||
<template v-else-if="configuredDeployOptions.mounts.length > 0">
|
||||
<p class="deploy-option__hint">
|
||||
{{ t('settings', 'ExApp container mounts') }}
|
||||
{{ t('appstore', 'ExApp container mounts') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="mount in configuredDeployOptions.mounts"
|
||||
:key="mount.hostPath"
|
||||
class="deploy-option"
|
||||
style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;">
|
||||
<NcTextField v-model="mount.hostPath" :label="t('settings', 'Host path')" readonly />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('settings', 'Container path')" readonly />
|
||||
<NcTextField v-model="mount.hostPath" :label="t('appstore', 'Host path')" readonly />
|
||||
<NcTextField v-model="mount.containerPath" :label="t('appstore', 'Container path')" readonly />
|
||||
<NcCheckboxRadioSwitch v-model="mount.readonly" disabled>
|
||||
{{ t('settings', 'Read-only') }}
|
||||
{{ t('appstore', 'Read-only') }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="deploy-option__hint">
|
||||
{{ t('settings', 'No mounts defined') }}
|
||||
{{ t('appstore', 'No mounts defined') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -165,6 +165,7 @@ import { mdiCheck, mdiClose, mdiDeleteOutline, mdiPlus } from '@mdi/js'
|
|||
import axios from '@nextcloud/axios'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { computed, ref } from 'vue'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
|
|
@ -201,6 +202,8 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
emits: ['update:show'],
|
||||
|
||||
setup(props) {
|
||||
// for AppManagement mixin
|
||||
const store = useAppsStore()
|
||||
|
|
@ -222,6 +225,8 @@ export default {
|
|||
})
|
||||
|
||||
return {
|
||||
t,
|
||||
|
||||
environmentVariables,
|
||||
deployOptions,
|
||||
store,
|
||||
|
|
@ -244,7 +249,7 @@ export default {
|
|||
|
||||
addingPortBinding: false,
|
||||
configuredDeployOptions: null,
|
||||
deployOptionsDocsUrl: loadState('settings', 'deployOptionsDocsUrl', null),
|
||||
deployOptionsDocsUrl: loadState('appstore', 'deployOptionsDocsUrl', null),
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -3,36 +3,36 @@
|
|||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiTextShort } from '@mdi/js'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
defineProps<{
|
||||
app: IAppstoreApp | IAppstoreExApp
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
id="desc"
|
||||
:name="t('settings', 'Description')"
|
||||
:name="t('appstore', 'Description')"
|
||||
:order="0">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTextShort" />
|
||||
</template>
|
||||
<div class="app-description">
|
||||
<Markdown :text="app.description" :min-heading="4" />
|
||||
<div :class="$style.appDescriptionTab">
|
||||
<MarkdownPreview :text="app.description" :minHeadingLevel="3" />
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp } from '../../app-types.ts'
|
||||
|
||||
import { mdiTextShort } from '@mdi/js'
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import Markdown from '../Markdown.vue'
|
||||
|
||||
defineProps<{
|
||||
app: IAppstoreApp
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-description {
|
||||
<style module>
|
||||
.appDescriptionTab {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
267
apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue
Normal file
267
apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiTextBoxOutline } from '@mdi/js'
|
||||
import { getCapabilities } from '@nextcloud/capabilities'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { computed, useId } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import BadgeAppDaemon from '../BadgeAppDaemon.vue'
|
||||
import BadgeAppLevel from '../BadgeAppLevel.vue'
|
||||
import BadgeAppScore from '../BadgeAppScore.vue'
|
||||
import { useAppsStore } from '../../store/apps.ts'
|
||||
|
||||
const { app } = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
|
||||
|
||||
const store = useAppsStore()
|
||||
|
||||
// @ts-expect-error - missing types
|
||||
const productName = getCapabilities().theming.productName as string
|
||||
const idLimitedToGroups = useId()
|
||||
|
||||
const lastModified = computed(() => app.releases
|
||||
?.map((release) => release.lastModified)
|
||||
.map((date) => Date.parse(date))
|
||||
.sort()
|
||||
.at(-1))
|
||||
|
||||
/**
|
||||
* App authors as comma separated string
|
||||
*/
|
||||
const appAuthors = computed(() => {
|
||||
if (!app) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return [app.author].flat().map(authorName)
|
||||
.sort((a, b) => a.split(' ').at(-1)!.localeCompare(b.split(' ').at(-1)!))
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
const groupsAppIsLimitedto = computed(() => {
|
||||
if (!app.groups) {
|
||||
return []
|
||||
}
|
||||
|
||||
return app.groups.map((group) => ({ id: group, name: group }))
|
||||
})
|
||||
|
||||
const appstoreUrl = computed(() => `https://apps.nextcloud.com/apps/${app.id}`)
|
||||
|
||||
/**
|
||||
* Further external resources (e.g. website)
|
||||
*/
|
||||
const externalResources = computed(() => {
|
||||
const resources: { id: string, href: string, label: string }[] = []
|
||||
if (!app.internal) {
|
||||
resources.push({
|
||||
id: 'appstore',
|
||||
href: appstoreUrl.value,
|
||||
label: t('appstore', 'View in store'),
|
||||
})
|
||||
}
|
||||
if (app.website) {
|
||||
resources.push({
|
||||
id: 'website',
|
||||
href: app.website,
|
||||
label: t('appstore', 'Visit website'),
|
||||
})
|
||||
}
|
||||
if (app.documentation) {
|
||||
if (app.documentation.user) {
|
||||
resources.push({
|
||||
id: 'doc-user',
|
||||
href: app.documentation.user,
|
||||
label: t('appstore', 'Usage documentation'),
|
||||
})
|
||||
}
|
||||
if (app.documentation.admin) {
|
||||
resources.push({
|
||||
id: 'doc-admin',
|
||||
href: app.documentation.admin,
|
||||
label: t('appstore', 'Admin documentation'),
|
||||
})
|
||||
}
|
||||
if (app.documentation.developer) {
|
||||
resources.push({
|
||||
id: 'doc-developer',
|
||||
href: app.documentation.developer,
|
||||
label: t('appstore', 'Developer documentation'),
|
||||
})
|
||||
}
|
||||
}
|
||||
return resources
|
||||
})
|
||||
|
||||
const appCategories = computed(() => {
|
||||
return [app.category].flat()
|
||||
.map((id) => store.getCategoryById(id)?.displayName ?? id)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the author name from the XML node
|
||||
*
|
||||
* @param xmlNode - The XML node to get the author name from
|
||||
*/
|
||||
function authorName(xmlNode): string {
|
||||
if (xmlNode['@value']) {
|
||||
// Complex node (with email or homepage attribute)
|
||||
return xmlNode['@value']
|
||||
}
|
||||
// Simple text node
|
||||
return xmlNode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
id="details"
|
||||
:name="t('appstore', 'Details')"
|
||||
:order="1">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiTextBoxOutline" />
|
||||
</template>
|
||||
<div class="app-details">
|
||||
<!-- Featured/Supported badges -->
|
||||
<div :class="$style.appstoreDetailsTab__badges">
|
||||
<BadgeAppLevel :level="app.level" />
|
||||
<BadgeAppDaemon v-if="app.app_api && app.daemon" :daemon="app.daemon" />
|
||||
<BadgeAppScore :app />
|
||||
</div>
|
||||
|
||||
<NcNoteCard v-if="app.missingMinNextcloudVersion || app.missingMaxNextcloudVersion" type="warning">
|
||||
<template v-if="app.missingMinNextcloudVersion">
|
||||
{{ t('appstore', 'This app has no minimum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</template>
|
||||
<template v-if="app.missingMaxNextcloudVersion">
|
||||
{{ t('appstore', 'This app has no maximum {productName} version assigned. This will be an error in the future.', { productName }) }}
|
||||
</template>
|
||||
</NcNoteCard>
|
||||
|
||||
<NcNoteCard v-if="!app.isCompatible && app.missingDependencies && app.missingDependencies.length" type="error">
|
||||
{{ t('appstore', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
|
||||
<ul :aria-label="t('appstore', 'Missing dependencies')" :class="$style.appstoreDetailsTab__missingDependencies">
|
||||
<li v-for="(dep, index) in app.missingDependencies" :key="index">
|
||||
{{ dep }}
|
||||
</li>
|
||||
</ul>
|
||||
</NcNoteCard>
|
||||
|
||||
<div v-if="groupsAppIsLimitedto.length" :class="$style.appstoreDetailsTab__section">
|
||||
<h4 :id="idLimitedToGroups">
|
||||
{{ t('appstore', 'Limited to groups') }}
|
||||
</h4>
|
||||
<ul :aria-labelledby="idLimitedToGroups" :class="$style.appstoreDetailsTab__sectionDetails">
|
||||
<li
|
||||
v-for="group of groupsAppIsLimitedto"
|
||||
:key="group.id"
|
||||
:title="group.id">
|
||||
{{ group.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="lastModified && !app.shipped" :class="$style.appstoreDetailsTab__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Latest updated') }}
|
||||
</h4>
|
||||
<NcDateTime :class="$style.appstoreDetailsTab__sectionDetails" :timestamp="lastModified" />
|
||||
</div>
|
||||
|
||||
<div :class="$style.appstoreDetailsTab__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Author') }}
|
||||
</h4>
|
||||
<p :class="$style.appstoreDetailsTab__sectionDetails">
|
||||
{{ appAuthors }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :class="$style.appstoreDetailsTab__section">
|
||||
<h4>
|
||||
{{ t('appstore', 'Categories') }}
|
||||
</h4>
|
||||
<p :class="$style.appstoreDetailsTab__sectionDetails">
|
||||
{{ appCategories }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="externalResources.length > 0" :class="$style.appstoreDetailsTab__section">
|
||||
<h4>{{ t('appstore', 'Resources') }}</h4>
|
||||
<ul
|
||||
:class="$style.appstoreDetailsTab__resources"
|
||||
:aria-label="t('appstore', 'Documentation resources')">
|
||||
<li
|
||||
v-for="resource of externalResources"
|
||||
:key="resource.id"
|
||||
:class="$style.appstoreDetailsTab__resourcesItem">
|
||||
<a
|
||||
:class="$style.appstoreDetailsTab__resourcesLink"
|
||||
:href="resource.href"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">
|
||||
{{ resource.label }} ↗
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appstoreDetailsTab__badges {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__section {
|
||||
margin-top: 15px;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-block-end: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__sectionDetails {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__missingDependencies {
|
||||
list-style: disc;
|
||||
padding-block: 0.5lh 0;
|
||||
padding-inline: 1em 0;
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__resourcesLink {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.appstoreDetailsTab__resourcesItem {
|
||||
padding-inline-start: 20px;
|
||||
|
||||
&::before {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-main-text);
|
||||
content: "";
|
||||
float: inline-start;
|
||||
margin-inline-start: -13px;
|
||||
position: relative;
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IAppstoreApp, IAppstoreAppRelease, IAppstoreExApp } from '../../apps.d.ts'
|
||||
|
||||
import { mdiClockFast } from '@mdi/js'
|
||||
import { getLanguage, t } from '@nextcloud/l10n'
|
||||
import { computed } from 'vue'
|
||||
import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
|
||||
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
|
||||
import MarkdownPreview from '../MarkdownPreview.vue'
|
||||
|
||||
const props = defineProps<{ app: IAppstoreApp | IAppstoreExApp }>()
|
||||
|
||||
const releases = computed(() => (props.app.releases ?? [])
|
||||
.filter((release) => {
|
||||
const values = Object.values(release.translations ?? {})
|
||||
return values.length > 0 && values.some(({ changelog }) => !!changelog)
|
||||
}))
|
||||
|
||||
/**
|
||||
* Create a changelog text from a release
|
||||
*
|
||||
* @param release - The release to create the changelog from
|
||||
*/
|
||||
function createChangelogFromRelease(release: IAppstoreAppRelease) {
|
||||
const localizedEntry = release.translations[getLanguage()]
|
||||
return localizedEntry?.changelog ?? release.translations.en?.changelog ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSidebarTab
|
||||
v-if="releases.length > 0"
|
||||
id="changelog"
|
||||
:name="t('appstore', 'Changelog')"
|
||||
:order="2">
|
||||
<template #icon>
|
||||
<NcIconSvgWrapper :path="mdiClockFast" :size="24" />
|
||||
</template>
|
||||
<div v-for="release in releases" :key="release.version" :class="$style.appReleasesTab">
|
||||
<h3 :class="$style.appReleasesTab__heading">
|
||||
{{ release.version }}
|
||||
</h3>
|
||||
<MarkdownPreview
|
||||
:class="$style.appReleasesTab__text"
|
||||
:minHeadingLevel="3"
|
||||
:text="createChangelogFromRelease(release)" />
|
||||
</div>
|
||||
</NcAppSidebarTab>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.appReleasesTab__heading {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.appReleasesTab__text {
|
||||
/* Overwrite changelog heading styles */
|
||||
h4 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue