mirror of
https://github.com/nextcloud/app_api.git
synced 2025-07-26 08:34:09 +00:00
450 lines
16 KiB
PHP
450 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace OCA\AppAPI\Service;
|
|
|
|
use OCA\AppAPI\AppInfo\Application;
|
|
use OCA\AppAPI\Db\ExApp;
|
|
use OCA\AppAPI\Db\ExAppMapper;
|
|
use OCA\AppAPI\Fetcher\ExAppArchiveFetcher;
|
|
use OCA\AppAPI\Fetcher\ExAppFetcher;
|
|
use OCA\AppAPI\Service\ProvidersAI\TaskProcessingService;
|
|
use OCA\AppAPI\Service\UI\FilesActionsMenuService;
|
|
use OCA\AppAPI\Service\UI\InitialStateService;
|
|
use OCA\AppAPI\Service\UI\ScriptsService;
|
|
use OCA\AppAPI\Service\UI\SettingsService;
|
|
use OCA\AppAPI\Service\UI\StylesService;
|
|
use OCA\AppAPI\Service\UI\TopMenuService;
|
|
use OCP\AppFramework\Db\DoesNotExistException;
|
|
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
|
use OCP\DB\Exception;
|
|
use OCP\ICache;
|
|
use OCP\ICacheFactory;
|
|
use OCP\IConfig;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
use Psr\Container\ContainerExceptionInterface;
|
|
use Psr\Container\NotFoundExceptionInterface;
|
|
use Psr\Log\LoggerInterface;
|
|
use SimpleXMLElement;
|
|
|
|
class ExAppService {
|
|
private ?ICache $cache = null;
|
|
|
|
public function __construct(
|
|
private readonly LoggerInterface $logger,
|
|
ICacheFactory $cacheFactory,
|
|
private readonly IUserManager $userManager,
|
|
private readonly ExAppFetcher $exAppFetcher,
|
|
private readonly ExAppArchiveFetcher $exAppArchiveFetcher,
|
|
private readonly ExAppMapper $exAppMapper,
|
|
private readonly TopMenuService $topMenuService,
|
|
private readonly InitialStateService $initialStateService,
|
|
private readonly ScriptsService $scriptsService,
|
|
private readonly StylesService $stylesService,
|
|
private readonly FilesActionsMenuService $filesActionsMenuService,
|
|
private readonly TaskProcessingService $taskProcessingService,
|
|
private readonly TalkBotsService $talkBotsService,
|
|
private readonly SettingsService $settingsService,
|
|
private readonly ExAppOccService $occService,
|
|
private readonly ExAppDeployOptionsService $deployOptionsService,
|
|
private readonly IConfig $config,
|
|
) {
|
|
if ($cacheFactory->isAvailable()) {
|
|
$distributedCacheClass = ltrim($config->getSystemValueString('memcache.distributed', ''), '\\');
|
|
$localCacheClass = ltrim($config->getSystemValueString('memcache.local', ''), '\\');
|
|
if (
|
|
($distributedCacheClass === '' && ($localCacheClass !== \OC\Memcache\APCu::class)) &&
|
|
($distributedCacheClass !== \OC\Memcache\APCu::class)
|
|
) {
|
|
$this->cache = $cacheFactory->createDistributed(Application::APP_ID . '/service');
|
|
}
|
|
}
|
|
}
|
|
|
|
public function getExApp(string $appId): ?ExApp {
|
|
foreach ($this->getExApps() as $exApp) {
|
|
if ($exApp->getAppid() === $appId) {
|
|
return $exApp;
|
|
}
|
|
}
|
|
$this->logger->debug(sprintf('ExApp "%s" not found.', $appId));
|
|
return null;
|
|
}
|
|
|
|
public function registerExApp(array $appInfo): ?ExApp {
|
|
$exApp = new ExApp([
|
|
'appid' => $appInfo['id'],
|
|
'version' => $appInfo['version'],
|
|
'name' => $appInfo['name'],
|
|
'daemon_config_name' => $appInfo['daemon_config_name'],
|
|
'port' => $appInfo['port'],
|
|
'secret' => $appInfo['secret'],
|
|
'status' => json_encode(['deploy' => 0, 'init' => 0, 'action' => '', 'type' => 'install', 'error' => '']),
|
|
'created_time' => time(),
|
|
]);
|
|
try {
|
|
$this->exAppMapper->insert($exApp);
|
|
$exApp = $this->exAppMapper->findByAppId($appInfo['id']);
|
|
$this->cache?->remove('/ex_apps');
|
|
if (isset($appInfo['external-app']['routes'])) {
|
|
$exApp->setRoutes($this->registerExAppRoutes($exApp, $appInfo['external-app']['routes'])->getRoutes() ?? []);
|
|
}
|
|
return $exApp;
|
|
} catch (Exception | MultipleObjectsReturnedException | DoesNotExistException $e) {
|
|
$this->logger->error(sprintf('Error while registering ExApp %s: %s', $appInfo['id'], $e->getMessage()));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function unregisterExApp(string $appId): bool {
|
|
$exApp = $this->getExApp($appId);
|
|
if ($exApp === null) {
|
|
return false;
|
|
}
|
|
$this->talkBotsService->unregisterExAppTalkBots($exApp); // TODO: Think about internal Events for clean and flexible unregister ExApp callbacks
|
|
$this->filesActionsMenuService->unregisterExAppFileActions($appId);
|
|
$this->topMenuService->unregisterExAppMenuEntries($appId);
|
|
$this->initialStateService->deleteExAppInitialStates($appId);
|
|
$this->scriptsService->deleteExAppScripts($appId);
|
|
$this->stylesService->deleteExAppStyles($appId);
|
|
$this->taskProcessingService->unregisterExAppTaskProcessingProviders($appId);
|
|
$this->settingsService->unregisterExAppForms($appId);
|
|
$this->exAppArchiveFetcher->removeExAppFolder($appId);
|
|
$this->occService->unregisterExAppOccCommands($appId);
|
|
$this->deployOptionsService->removeExAppDeployOptions($appId);
|
|
$this->unregisterExAppWebhooks($appId);
|
|
$r = $this->exAppMapper->deleteExApp($appId);
|
|
if ($r !== 1) {
|
|
$this->logger->error(sprintf('Error while unregistering %s ExApp from the database.', $appId));
|
|
}
|
|
$rmRoutes = $this->removeExAppRoutes($exApp);
|
|
if ($rmRoutes === null) {
|
|
$this->logger->error(sprintf('Error while unregistering %s ExApp routes from the database.', $appId));
|
|
}
|
|
$this->cache?->remove('/ex_apps');
|
|
return $r === 1 && $rmRoutes !== null;
|
|
}
|
|
|
|
public function getExAppFreePort(): int {
|
|
try {
|
|
$ports = $this->exAppMapper->getUsedPorts();
|
|
for ($port = 23000; $port <= 23999; $port++) {
|
|
if (!in_array($port, $ports)) {
|
|
return $port;
|
|
}
|
|
}
|
|
} catch (Exception) {
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
public function enableExAppInternal(ExApp $exApp): bool {
|
|
$exApp->setEnabled(1);
|
|
$status = $exApp->getStatus();
|
|
$status['error'] = '';
|
|
$exApp->setStatus($status);
|
|
return $this->updateExApp($exApp, ['enabled', 'status']);
|
|
}
|
|
|
|
public function disableExAppInternal(ExApp $exApp): void {
|
|
$exApp->setEnabled(0);
|
|
$this->updateExApp($exApp, ['enabled']);
|
|
}
|
|
|
|
public function getExAppsByDaemonName(string $daemonName): array {
|
|
try {
|
|
return array_filter($this->exAppMapper->findAll(), function (ExApp $exApp) use ($daemonName) {
|
|
return $exApp->getDaemonConfigName() === $daemonName;
|
|
});
|
|
} catch (Exception $e) {
|
|
$this->logger->error(sprintf('Error while getting ExApps list. Error: %s', $e->getMessage()), ['exception' => $e]);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public function getExAppsList(string $list = 'enabled'): array {
|
|
$exApps = $this->getExApps();
|
|
if ($list === 'enabled') {
|
|
$exApps = array_values(array_filter($exApps, function (ExApp $exApp) {
|
|
return $exApp->getEnabled() === 1;
|
|
}));
|
|
}
|
|
return array_map(function (ExApp $exApp) {
|
|
return $this->formatExAppInfo($exApp);
|
|
}, $exApps);
|
|
}
|
|
|
|
public function formatExAppInfo(ExApp $exApp): array {
|
|
return [
|
|
'id' => $exApp->getAppid(),
|
|
'name' => $exApp->getName(),
|
|
'version' => $exApp->getVersion(),
|
|
'enabled' => filter_var($exApp->getEnabled(), FILTER_VALIDATE_BOOLEAN),
|
|
'status' => $exApp->getStatus(),
|
|
];
|
|
}
|
|
|
|
public function getNCUsersList(): ?array {
|
|
return array_map(function (IUser $user) {
|
|
return $user->getUID();
|
|
}, $this->userManager->searchDisplayName(''));
|
|
}
|
|
|
|
public function updateExAppInfo(ExApp $exApp, array $appInfo): bool {
|
|
$exApp->setVersion($appInfo['version']);
|
|
$exApp->setName($appInfo['name']);
|
|
if (!$this->updateExApp($exApp, ['version', 'name'])) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public function updateExApp(ExApp $exApp, array $fields = ['version', 'name', 'port', 'status', 'enabled']): bool {
|
|
try {
|
|
$this->exAppMapper->updateExApp($exApp, $fields);
|
|
$this->cache?->remove('/ex_apps');
|
|
if (in_array('enabled', $fields) || in_array('version', $fields)) {
|
|
$this->resetCaches();
|
|
}
|
|
return true;
|
|
} catch (Exception $e) {
|
|
$this->logger->error(sprintf('Failed to update "%s" ExApp info.', $exApp->getAppid()), ['exception' => $e]);
|
|
$this->cache?->remove('/ex_apps');
|
|
$this->resetCaches();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public function getLatestExAppInfoFromAppstore(string $appId, string &$extractedDir): ?SimpleXMLElement {
|
|
$exApps = $this->exAppFetcher->get();
|
|
$exAppAppstoreData = array_filter($exApps, function (array $exAppItem) use ($appId) {
|
|
return $exAppItem['id'] === $appId && count($exAppItem['releases']) > 0;
|
|
});
|
|
if (empty($exAppAppstoreData)) {
|
|
return null;
|
|
}
|
|
$exAppAppstoreData = end($exAppAppstoreData);
|
|
$exAppReleaseInfo = end($exAppAppstoreData['releases']);
|
|
if ($exAppReleaseInfo !== false) {
|
|
return $this->exAppArchiveFetcher->downloadInfoXml($exAppAppstoreData, $extractedDir);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function resetCaches(): void {
|
|
$this->topMenuService->resetCacheEnabled();
|
|
$this->filesActionsMenuService->resetCacheEnabled();
|
|
$this->settingsService->resetCacheEnabled();
|
|
$this->occService->resetCacheEnabled();
|
|
$this->deployOptionsService->resetCache();
|
|
}
|
|
|
|
public function getAppInfo(string $appId, ?string $infoXml, ?string $jsonInfo, ?array $deployOptions = null): array {
|
|
$extractedDir = '';
|
|
if ($jsonInfo !== null) {
|
|
$appInfo = json_decode($jsonInfo, true);
|
|
if (!$appInfo) {
|
|
return ['error' => 'Invalid app info provided in JSON format'];
|
|
}
|
|
# fill 'id' if it is missing(this field was called `appid` in previous versions in json)
|
|
$appInfo['id'] = $appInfo['id'] ?? $appId;
|
|
# during manual install JSON can have all values at root level
|
|
foreach (['docker-install', 'translations_folder', 'routes'] as $key) {
|
|
if (isset($appInfo[$key])) {
|
|
$appInfo['external-app'][$key] = $appInfo[$key];
|
|
unset($appInfo[$key]);
|
|
}
|
|
}
|
|
} else {
|
|
if ($infoXml !== null) {
|
|
$infoXmlContents = file_get_contents($infoXml);
|
|
if ($infoXmlContents === false) {
|
|
return ['error' => sprintf('Failed to read info.xml from %s', $infoXml)];
|
|
}
|
|
$xmlAppInfo = simplexml_load_string($infoXmlContents);
|
|
if ($xmlAppInfo === false) {
|
|
return ['error' => sprintf('Failed to load info.xml from %s', $infoXml)];
|
|
}
|
|
} else {
|
|
$xmlAppInfo = $this->getLatestExAppInfoFromAppstore($appId, $extractedDir);
|
|
if (empty($xmlAppInfo)) {
|
|
return ['error' => sprintf('Failed to get app info for `%s` from the Appstore', $appId)];
|
|
}
|
|
}
|
|
$appInfo = json_decode(json_encode((array)$xmlAppInfo), true);
|
|
if (isset($appInfo['external-app']['routes']['route'])) {
|
|
if (isset($appInfo['external-app']['routes']['route'][0])) {
|
|
$appInfo['external-app']['routes'] = $appInfo['external-app']['routes']['route'];
|
|
} else {
|
|
$appInfo['external-app']['routes'] = [$appInfo['external-app']['routes']['route']];
|
|
}
|
|
// update routes, map string access_level to int
|
|
$appInfo['external-app']['routes'] = array_map(function ($route) use ($appId) {
|
|
$route['access_level'] = $this->mapExAppRouteAccessLevelNameToNumber($route['access_level']);
|
|
if ($route['access_level'] !== -1) {
|
|
return $route;
|
|
} else {
|
|
$this->logger->error(sprintf('Invalid access level `%s` for route `%s` in ExApp `%s`', $route['access_level'], $route['url'], $appId));
|
|
}
|
|
}, $appInfo['external-app']['routes']);
|
|
}
|
|
// Advanced deploy options
|
|
if (isset($appInfo['external-app']['environment-variables']['variable'])) {
|
|
$envVars = [];
|
|
if (!isset($appInfo['external-app']['environment-variables']['variable'][0])) {
|
|
$appInfo['external-app']['environment-variables']['variable'] = [$appInfo['external-app']['environment-variables']['variable']];
|
|
}
|
|
foreach ($appInfo['external-app']['environment-variables']['variable'] as $envVar) {
|
|
$envVars[$envVar['name']] = [
|
|
'name' => $envVar['name'],
|
|
'displayName' => $envVar['display-name'] ?? '',
|
|
'description' => $envVar['description'] ?? '',
|
|
'default' => $envVar['default'] ?? '',
|
|
'value' => $envVar['default'] ?? '',
|
|
];
|
|
}
|
|
if (isset($deployOptions['environment_variables']) && count(array_keys($deployOptions['environment_variables'])) > 0) {
|
|
// override with given deploy options values
|
|
foreach ($deployOptions['environment_variables'] as $key => $value) {
|
|
if (array_key_exists($key, $envVars)) {
|
|
$envVars[$key]['value'] = $value['value'] ?? $value ?? '';
|
|
}
|
|
}
|
|
}
|
|
$envVars = array_filter($envVars, function ($envVar) {
|
|
return $envVar['value'] !== '';
|
|
});
|
|
$appInfo['external-app']['environment-variables'] = $envVars;
|
|
}
|
|
if (isset($deployOptions['mounts'])) {
|
|
$appInfo['external-app']['mounts'] = $deployOptions['mounts'];
|
|
}
|
|
if ($extractedDir) {
|
|
if (file_exists($extractedDir . '/l10n')) {
|
|
$appInfo['translations_folder'] = $extractedDir . '/l10n';
|
|
} else {
|
|
$this->logger->info(sprintf('Application %s does not support translations', $appId));
|
|
}
|
|
}
|
|
}
|
|
return $appInfo;
|
|
}
|
|
|
|
public function mapExAppRouteAccessLevelNameToNumber(string $accessLevel): int {
|
|
return match($accessLevel) {
|
|
'PUBLIC' => 0,
|
|
'USER' => 1,
|
|
'ADMIN' => 2,
|
|
default => -1,
|
|
};
|
|
}
|
|
|
|
public function setAppDeployProgress(ExApp $exApp, int $progress, string $error = ''): void {
|
|
if ($progress < 0 || $progress > 100) {
|
|
throw new \InvalidArgumentException('Invalid ExApp deploy status progress value');
|
|
}
|
|
$status = $exApp->getStatus();
|
|
if ($progress !== 0 && isset($status['deploy']) && $status['deploy'] === 100) {
|
|
return;
|
|
}
|
|
if ($error !== '') {
|
|
$this->logger->error(sprintf('ExApp %s deploying failed. Error: %s', $exApp->getAppid(), $error));
|
|
$status['error'] = $error;
|
|
} else {
|
|
if ($progress === 0) {
|
|
$status['action'] = 'deploy';
|
|
$status['deploy_start_time'] = time();
|
|
$status['error'] = '';
|
|
}
|
|
$status['deploy'] = $progress;
|
|
}
|
|
if ($progress === 100) {
|
|
$status['action'] = 'healthcheck';
|
|
}
|
|
$exApp->setStatus($status);
|
|
$this->updateExApp($exApp, ['status']);
|
|
}
|
|
|
|
public function waitInitStepFinish(string $appId): string {
|
|
do {
|
|
$exApp = $this->getExApp($appId);
|
|
$status = $exApp->getStatus();
|
|
if (isset($status['error']) && $status['error'] !== '') {
|
|
return sprintf('ExApp %s initialization step failed. Error: %s', $appId, $status['error']);
|
|
}
|
|
usleep(100000); // 0.1s
|
|
} while ($status['init'] !== 100);
|
|
return "";
|
|
}
|
|
|
|
public function setStatusError(ExApp $exApp, string $error): void {
|
|
$status = $exApp->getStatus();
|
|
$status['error'] = $error;
|
|
$exApp->setStatus($status);
|
|
$this->updateExApp($exApp, ['status']);
|
|
}
|
|
|
|
/**
|
|
* Get list of registered ExApps
|
|
*
|
|
* @return ExApp[]
|
|
*/
|
|
public function getExApps(): array {
|
|
try {
|
|
$cacheKey = '/ex_apps';
|
|
$records = $this->cache?->get($cacheKey);
|
|
if ($records !== null) {
|
|
return array_map(function ($record) {
|
|
return $record instanceof ExApp ? $record : new ExApp($record);
|
|
}, $records);
|
|
}
|
|
$records = $this->exAppMapper->findAll();
|
|
$this->cache?->set($cacheKey, $records);
|
|
return $records;
|
|
} catch (Exception) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
public function registerExAppRoutes(ExApp $exApp, array $routes): ?ExApp {
|
|
try {
|
|
$this->exAppMapper->registerExAppRoutes($exApp, $routes);
|
|
$exApp->setRoutes($routes);
|
|
return $exApp;
|
|
} catch (Exception $e) {
|
|
$this->logger->error(sprintf('Error while registering ExApp %s routes: %s. Routes: %s', $exApp->getAppid(), $e->getMessage(), json_encode($routes)));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function removeExAppRoutes(ExApp $exApp): ?ExApp {
|
|
try {
|
|
$this->exAppMapper->removeExAppRoutes($exApp);
|
|
$exApp->setRoutes([]);
|
|
return $exApp;
|
|
} catch (Exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @psalm-suppress UndefinedClass
|
|
*/
|
|
private function unregisterExAppWebhooks(string $appId): void {
|
|
try {
|
|
$webhookListenerMapper = \OCP\Server::get(\OCA\WebhookListeners\Db\WebhookListenerMapper::class);
|
|
$webhookListenerMapper->deleteByAppId($appId);
|
|
} catch (ContainerExceptionInterface | NotFoundExceptionInterface $e) {
|
|
} catch (Exception $e) {
|
|
$this->logger->debug(sprintf('Error while unregistering ExApp %s webhooks: %s', $appId, $e->getMessage()));
|
|
}
|
|
}
|
|
}
|