Merge branch 'master' into fix-ios-browsers

This commit is contained in:
Philip Renich 2026-05-06 14:37:51 +12:00 committed by GitHub
commit d8b9c6d6a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4492 changed files with 58923 additions and 36730 deletions

View file

@ -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"

View file

@ -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: |

View file

@ -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: |

View file

@ -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: |

View file

@ -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 }}

View file

@ -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 || '';

View file

@ -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}}"

View file

@ -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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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'

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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'

View file

@ -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}}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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'

View file

@ -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:

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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 }}

View file

@ -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
View file

@ -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/

View file

@ -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

@ -1 +1 @@
Subproject commit f257bfe47eb6ed77a0f5f87ac420fe39020d9ed7
Subproject commit 5d09a7f56e2d01b5f4083e65db77c4f7aa775252

View file

@ -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);

View file

@ -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);
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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
View 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"

View 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>

View 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();

View 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
View 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"
}

View 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);
}
}

View 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;
}
}

View 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.

View 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',
);

View file

@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
);

View file

@ -0,0 +1,10 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = $vendorDir;
return array(
'OCA\\Appstore\\' => array($baseDir . '/../lib'),
);

View 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;
}
}

View 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);
}
}

View file

@ -0,0 +1,5 @@
{
"packages": [],
"dev": false,
"dev-package-names": []
}

View 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,
),
),
);

View file

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View file

View 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 {
}
}

View 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'];
}
}

View 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
}
}
}
}

View 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;
}
}

View file

@ -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, []);
}

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

View 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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later

View 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>

View 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)
},
}

View 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)
},
}

View 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)
},
}

View 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)
},
}

View 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)
},
}

View 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!
},
},
]

View 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 })
},
}

View 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)
},
}

View 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)
},
}

View 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
View 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
View 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[]
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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),
}
},

View file

@ -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>

View 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>

View file

@ -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