Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
'requirements' => ['other' => '.+'], 'defaults' => ['other' => '']],

// ExApps actions
['name' => 'ExAppsPage#viewApps', 'url' => '/apps', 'verb' => 'GET' , 'root' => '/apps'],
['name' => 'ExAppsPage#listCategories', 'url' => '/apps/categories', 'verb' => 'GET' , 'root' => ''],
['name' => 'ExAppsPage#listApps', 'url' => '/apps/list', 'verb' => 'GET' , 'root' => ''],
['name' => 'ExAppsPage#enableApp', 'url' => '/apps/enable/{appId}', 'verb' => 'GET' , 'root' => ''],
Expand Down
37 changes: 0 additions & 37 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,6 @@
use OCP\Files\Events\Node\NodeTouchedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\INavigationManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use OCP\SabrePluginEvent;
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
use OCP\Settings\Events\DeclarativeSettingsRegisterFormEvent;
Expand Down Expand Up @@ -110,9 +104,7 @@ public function register(IRegistrationContext $context): void {
}

public function boot(IBootContext $context): void {
$server = $context->getServerContainer();
try {
$context->injectFn($this->registerExAppsManagementNavigation(...));
$context->injectFn($this->registerExAppsMenuEntries(...));
} catch (NotFoundExceptionInterface|ContainerExceptionInterface|Throwable) {
}
Expand All @@ -127,35 +119,6 @@ public function registerDavAuth(): void {
});
}

/**
* Register ExApps management navigation entry right after default Apps management link.
*
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
private function registerExAppsManagementNavigation(IUserSession $userSession): void {
$container = $this->getContainer();
/** @var IGroupManager $groupManager */
$groupManager = $container->get(IGroupManager::class);
/** @var IUser $user */
$user = $userSession->getUser();
if ($groupManager->isInGroup($user->getUID(), 'admin')) {
$container->get(INavigationManager::class)->add(function () use ($container) {
$urlGenerator = $container->get(IURLGenerator::class);
$l10n = $container->get(IL10N::class);
return [
'id' => self::APP_ID,
'type' => 'settings',
'order' => 6,
'href' => $urlGenerator->linkToRoute('app_api.ExAppsPage.viewApps'),
'icon' => $urlGenerator->imagePath('app_api', 'app-dark.svg'),
'target' => '_blank',
'name' => $l10n->t('External Apps'),
];
});
}
}

private function registerExAppsMenuEntries(): void {
$container = $this->getContainer();
$menuEntryService = $container->get(TopMenuService::class);
Expand Down
55 changes: 7 additions & 48 deletions lib/Controller/ExAppsPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
Expand Down Expand Up @@ -79,49 +77,6 @@ public function __construct(
$this->appManager = $appManager;
}

#[NoCSRFRequired]
public function viewApps(): TemplateResponse {
$defaultDaemonConfigName = $this->config->getAppValue(Application::APP_ID, 'default_daemon_config');

$appInitialData = [
'appstoreEnabled' => $this->config->getSystemValueBool('appstoreenabled', true),
'updateCount' => count($this->getExAppsWithUpdates()),
];

if ($defaultDaemonConfigName !== '') {
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($defaultDaemonConfigName);
if ($daemonConfig !== null) {
$this->dockerActions->initGuzzleClient($daemonConfig);
$daemonConfigAccessible = $this->dockerActions->ping($this->dockerActions->buildDockerUrl($daemonConfig));
$appInitialData['daemon_config_accessible'] = $daemonConfigAccessible;
$appInitialData['default_daemon_config'] = $daemonConfig->jsonSerialize();
unset($appInitialData['default_daemon_config']['deploy_config']['haproxy_password']); // do not expose password
if (!$daemonConfigAccessible) {
$this->logger->error(sprintf('Deploy daemon "%s" is not accessible by Nextcloud. Please verify its configuration', $daemonConfig->getName()));
}
}
}

$this->initialStateService->provideInitialState('apps', $appInitialData);

$templateResponse = new TemplateResponse(Application::APP_ID, 'main');
$policy = new ContentSecurityPolicy();
$policy->addAllowedImageDomain('https://usercontent.apps.nextcloud.com');
$templateResponse->setContentSecurityPolicy($policy);

return $templateResponse;
}

private function getExAppsWithUpdates(): array {
$apps = $this->exAppFetcher->get();
$appsWithUpdates = array_filter($apps, function (array $app) {
$exApp = $this->exAppService->getExApp($app['id']);
$newestVersion = $app['releases'][0]['version'];
return $exApp !== null && isset($app['releases'][0]['version']) && version_compare($newestVersion, $exApp->getVersion(), '>');
});
return array_values($appsWithUpdates);
}

/**
* Using the same algorithm of ExApps listing as for regular apps.
* Returns all apps for a category from the App Store
Expand Down Expand Up @@ -195,6 +150,7 @@ private function getAppsForCategory(string $requestedCategory = ''): array {

$formattedApps[] = [
'id' => $app['id'],
'app_api' => true,
'installed' => $exApp !== null, // if ExApp registered then it's assumed that it was already deployed (installed)
'appstore' => true,
'name' => $app['translations'][$currentLanguage]['name'] ?? $app['translations']['en']['name'],
Expand Down Expand Up @@ -228,6 +184,7 @@ private function getAppsForCategory(string $requestedCategory = ''): array {
'removable' => $existsLocally,
'active' => $exApp !== null && $exApp->getEnabled() === 1,
'needsDownload' => !$existsLocally,
'groups' => [],
'fromAppStore' => true,
'appstoreData' => $app,
'daemon' => $daemon,
Expand All @@ -242,7 +199,7 @@ private function getAppsForCategory(string $requestedCategory = ''): array {
#[NoCSRFRequired]
public function listApps(): JSONResponse {
$apps = $this->getAppsForCategory('');
$appsWithUpdate = $this->getExAppsWithUpdates();
$appsWithUpdate = $this->exAppFetcher->getExAppsWithUpdates();

$exApps = $this->exAppService->getExAppsList('all');
$dependencyAnalyzer = new DependencyAnalyzer(new Platform($this->config), $this->l10n);
Expand Down Expand Up @@ -325,6 +282,7 @@ private function buildLocalAppsList(array $apps, array $exApps): array {

$formattedLocalApps[] = [
'id' => $app['id'],
'app_api' => true,
'appstore' => false,
'installed' => true,
'name' => $exApp->getName(),
Expand Down Expand Up @@ -357,6 +315,7 @@ private function buildLocalAppsList(array $apps, array $exApps): array {
'removable' => true, // to allow "remove" command for manual-install
'active' => $exApp->getEnabled() === 1,
'needsDownload' => false,
'groups' => [],
'fromAppStore' => false,
'appstoreData' => $app,
'daemon' => $daemon,
Expand Down Expand Up @@ -391,7 +350,7 @@ public function enableApp(string $appId): JSONResponse {
return new JSONResponse([]);
}

$appsWithUpdate = $this->getExAppsWithUpdates();
$appsWithUpdate = $this->exAppFetcher->getExAppsWithUpdates();
$appIdsWithUpdate = array_map(function (array $appWithUpdate) {
return $appWithUpdate['id'];
}, $appsWithUpdate);
Expand Down Expand Up @@ -426,7 +385,7 @@ public function disableApp(string $appId): JSONResponse {
#[PasswordConfirmationRequired]
#[NoCSRFRequired]
public function updateApp(string $appId): JSONResponse {
$appsWithUpdate = $this->getExAppsWithUpdates();
$appsWithUpdate = $this->exAppFetcher->getExAppsWithUpdates();
$appIdsWithUpdate = array_map(function (array $appWithUpdate) {
return $appWithUpdate['id'];
}, $appsWithUpdate);
Expand Down
38 changes: 21 additions & 17 deletions lib/Fetcher/AppAPIFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
use OCP\Files\NotPermittedException;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\Server;
use OCP\ServerVersion;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;

abstract class AppAPIFetcher {
public const INVALIDATE_AFTER_SECONDS = 3600;
public const INVALIDATE_AFTER_SECONDS_UNSTABLE = 900;
public const RETRY_AFTER_FAILURE_SECONDS = 300;
public const APP_STORE_URL = 'https://apps.nextcloud.com/api/v1';

protected IAppData $appData;

Expand All @@ -37,15 +40,12 @@ public function __construct(
protected IConfig $config,
protected LoggerInterface $logger,
protected IRegistry $registry,
protected ServerVersion $serverVersion,
) {
$this->appData = $appDataFactory->get('appstore');
}

/**
* Fetches the response from the server
*
* @throws Exception
*/
protected function fetch(string $ETag, string $content): array {
$appstoreenabled = $this->config->getSystemValueBool('appstoreenabled', true);
Expand All @@ -72,7 +72,8 @@ protected function fetch(string $ETag, string $content): array {
$response = $client->get($this->getEndpoint(), $options);
} catch (ConnectException $e) {
$this->config->setAppValue(Application::APP_ID, 'appstore-appapi-fetcher-lastFailure', (string)time());
throw $e;
$this->logger->error('Failed to connect to the appstore', ['exception' => $e, 'app' => 'appstoreExAppFetcher']);
return [];
}

$responseJson = [];
Expand Down Expand Up @@ -102,8 +103,10 @@ protected function fetch(string $ETag, string $content): array {
public function get(bool $allowUnstable = false): array {
$appstoreenabled = $this->config->getSystemValueBool('appstoreenabled', true);
$internetavailable = $this->config->getSystemValueBool('has_internet_connection', true);
$isDefaultAppStore = $this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL) === self::APP_STORE_URL;

if (!$appstoreenabled || !$internetavailable) {
if (!$appstoreenabled || (!$internetavailable && $isDefaultAppStore)) {
$this->logger->info('AppStore is disabled or this instance has no Internet connection to access the default app store', ['app' => 'appstoreExAppFetcher']);
return [];
}

Expand All @@ -117,12 +120,18 @@ public function get(bool $allowUnstable = false): array {
$file = $rootFolder->getFile($this->fileName);
$jsonBlob = json_decode($file->getContent(), true);

// Always get latests apps info if $allowUnstable
if (!$allowUnstable && is_array($jsonBlob)) {

if (is_array($jsonBlob)) {
// No caching when the version has been updated
if (isset($jsonBlob['ncversion']) && $jsonBlob['ncversion'] === $this->getVersion()) {
// If the timestamp is older than 3600 seconds request the files new
if ((int)$jsonBlob['timestamp'] > ($this->timeFactory->getTime() - self::INVALIDATE_AFTER_SECONDS)) {
$invalidateAfterSeconds = self::INVALIDATE_AFTER_SECONDS;

if ($allowUnstable) {
$invalidateAfterSeconds = self::INVALIDATE_AFTER_SECONDS_UNSTABLE;
}

if ((int)$jsonBlob['timestamp'] > ($this->timeFactory->getTime() - $invalidateAfterSeconds)) {
return $jsonBlob['data'];
}

Expand All @@ -141,24 +150,19 @@ public function get(bool $allowUnstable = false): array {
try {
$responseJson = $this->fetch($ETag, $content, $allowUnstable);

if (empty($responseJson)) {
if (empty($responseJson) || empty($responseJson['data'])) {
return [];
}

// Don't store the apps request file
if ($allowUnstable) {
return $responseJson['data'];
}

$file->putContent(json_encode($responseJson));
return json_decode($file->getContent(), true)['data'];
} catch (ConnectException $e) {
$this->logger->warning('Could not connect to appstore: ' . $e->getMessage(), ['app' => 'appstoreFetcher']);
$this->logger->warning('Could not connect to appstore: ' . $e->getMessage(), ['app' => 'appstoreExAppFetcher']);
return [];
} catch (Exception $e) {
$this->logger->warning($e->getMessage(), [
'exception' => $e,
'app' => 'appstoreFetcher',
'app' => 'appstoreExAppFetcher',
]);
return [];
}
Expand Down Expand Up @@ -186,7 +190,7 @@ public function setVersion(string $version): void {
*/
protected function getChannel(): string {
if ($this->channel === null) {
$this->channel = $this->serverVersion->getChannel();
$this->channel = Server::get(ServerVersion::class)->getChannel();
}
return $this->channel;
}
Expand Down
30 changes: 19 additions & 11 deletions lib/Fetcher/ExAppFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,29 @@

namespace OCA\AppAPI\Fetcher;

use Exception;
use InvalidArgumentException;
use OC\App\AppStore\Version\VersionParser;
use OC\App\CompareVersion;
use OC\Files\AppData\Factory;
use OCA\AppAPI\Service\ExAppService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\ServerVersion;
use OCP\Server;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;

class ExAppFetcher extends AppAPIFetcher {
private CompareVersion $compareVersion;
private bool $ignoreMaxVersion;

public function __construct(
Factory $appDataFactory,
IClientService $clientService,
ITimeFactory $timeFactory,
IConfig $config,
CompareVersion $compareVersion,
private CompareVersion $compareVersion,
LoggerInterface $logger,
protected IRegistry $registry,
protected ServerVersion $serverVersion,
) {
parent::__construct(
$appDataFactory,
Expand All @@ -37,26 +35,22 @@ public function __construct(
$config,
$logger,
$registry,
$serverVersion
);

$this->compareVersion = $compareVersion;

$this->fileName = 'appapi_apps.json';
$this->endpointName = 'appapi_apps.json';
$this->ignoreMaxVersion = true;
}

/**
* Only returns the latest compatible app release in the releases array
*
* @throws Exception
*/
protected function fetch(string $ETag, string $content, bool $allowUnstable = false): array {
/** @var mixed[] $response */
$response = parent::fetch($ETag, $content);

if (empty($response)) {
if (!isset($response['data']) || $response['data'] === null) {
$this->logger->warning('Response from appstore is invalid, ExApps could not be retrieved. Try again later.', ['app' => 'appstoreExAppFetcher']);
return [];
}

Expand Down Expand Up @@ -150,6 +144,9 @@ public function get($allowUnstable = false): array {
$allowPreReleases = $allowUnstable || $this->getChannel() === 'beta' || $this->getChannel() === 'daily' || $this->getChannel() === 'git';

$apps = parent::get($allowPreReleases);
if (empty($apps)) {
return [];
}
$allowList = $this->config->getSystemValue('appsallowlist');

// If the admin specified a allow list, filter apps from the appstore
Expand All @@ -161,4 +158,15 @@ public function get($allowUnstable = false): array {

return $apps;
}

public function getExAppsWithUpdates(): array {
$apps = $this->get();
$appsWithUpdates = array_filter($apps, function (array $app) {
$exAppService = Server::get(ExAppService::class);
$exApp = $exAppService->getExApp($app['id']);
$newestVersion = $app['releases'][0]['version'];
return $exApp !== null && isset($app['releases'][0]['version']) && version_compare($newestVersion, $exApp->getVersion(), '>');
});
return array_values($appsWithUpdates);
}
}
Loading