Обновление клиента

This commit is contained in:
root
2026-03-05 13:40:40 +00:00
parent 34bcd34979
commit b8905de237
4147 changed files with 748711 additions and 7 deletions
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\AppInfo;
use OCA\Support\Capabilities;
use OCA\Support\Notification\Notifier;
use OCA\Support\Settings\Admin;
use OCA\Support\Settings\Section;
use OCA\Support\Subscription\SubscriptionAdapter;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\IConfig;
use OCP\Settings\IManager as ISettingsManager;
use OCP\Support\Subscription\Exception\AlreadyRegisteredException;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
class Application extends App implements IBootstrap {
public const APP_ID = 'support';
public function __construct() {
parent::__construct(self::APP_ID);
}
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
$context->registerNotifierService(Notifier::class);
}
#[\Override]
public function boot(IBootContext $context): void {
$container = $context->getAppContainer();
/* @var $registry IRegistry */
$registry = $container->get(IRegistry::class);
try {
$registry->registerService(SubscriptionAdapter::class);
if ($container->get(IConfig::class)->getAppValue('support', 'hide-app', 'no') !== 'yes') {
$settingsManager = $container->get(ISettingsManager::class);
$settingsManager->registerSetting('admin', Admin::class);
$settingsManager->registerSection('admin', Section::class);
}
} catch (AlreadyRegisteredException $e) {
$logger = $container->get(LoggerInterface::class);
$logger->critical('Multiple subscription adapters are registered.', [
'exception' => $e,
]);
}
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\BackgroundJobs;
use OCA\Support\Service\SubscriptionService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use OCP\IAppConfig;
class CheckSubscription extends TimedJob {
public function __construct(
ITimeFactory $factory,
private readonly IAppConfig $appConfig,
private readonly SubscriptionService $subscriptionService,
) {
parent::__construct($factory);
// Run every 5 minutes
$this->setInterval(60 * 5);
}
#[\Override]
public function run($argument) {
$lastCheck = $this->appConfig->getValueInt('support', 'last_check');
// renew subscription info every 23h
if (time() - $lastCheck > 23 * 60 * 60) {
$this->subscriptionService->renewSubscriptionInfo(false);
$this->subscriptionService->checkSubscription();
}
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support;
use OCA\Support\Subscription\SubscriptionAdapter;
use OCP\Capabilities\ICapability;
use OCP\IConfig;
class Capabilities implements ICapability {
public function __construct(
protected readonly SubscriptionAdapter $adapter,
protected readonly IConfig $config,
) {
}
/**
* @return array{
* support?: array{
* hasValidSubscription: bool,
* desktopEnterpriseChannel: string
* },
* }
*/
#[\Override]
public function getCapabilities(): array {
if (!$this->adapter->hasValidSubscription()) {
return [];
}
return [
'support' => [
'hasValidSubscription' => true,
'desktopEnterpriseChannel' => $this->config->getSystemValueString('desktopEnterpriseChannel', 'enterprise'),
],
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Command;
use OCA\Support\DetailManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SystemReport extends Command {
public function __construct(
protected readonly DetailManager $detailManager,
) {
parent::__construct();
}
#[\Override]
protected function configure(): void {
$this
->setName('support:report')
->setDescription('Generate a system report')
;
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln($this->detailManager->getRenderedDetails());
return 0;
}
}
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Controller;
use OCA\Support\DetailManager;
use OCA\Support\Service\SubscriptionService;
use OCA\Support\Settings\Admin;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Constants;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Folder;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Security\Events\GenerateSecurePasswordEvent;
use OCP\Security\ISecureRandom;
use OCP\Share\IManager;
use OCP\Share\IShare;
use Psr\Log\LoggerInterface;
class ApiController extends Controller {
private Folder $userFolder;
public function __construct(
string $appName,
IRequest $request,
protected readonly IURLGenerator $urlGenerator,
protected readonly SubscriptionService $subscriptionService,
protected readonly DetailManager $detailManager,
protected readonly IUserSession $userSession,
protected readonly LoggerInterface $logger,
protected readonly IL10N $l10n,
protected readonly IManager $shareManager,
protected readonly IEventDispatcher $eventDispatcher,
protected readonly ISecureRandom $random,
protected readonly ITimeFactory $timeFactory,
protected readonly ?string $userId,
readonly IRootFolder $rootFolder,
) {
parent::__construct($appName, $request);
$this->userFolder = $rootFolder->getUserFolder($this->userId);
}
#[AuthorizedAdminSetting(settings: Admin::class)]
public function setSubscriptionKey(string $subscriptionKey): RedirectResponse {
$this->subscriptionService->setSubscriptionKey(trim($subscriptionKey));
return new RedirectResponse($this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('settings.AdminSettings.index', ['section' => 'support'])));
}
#[AuthorizedAdminSetting(settings: Admin::class)]
public function generateSystemReport(): DataResponse {
try {
$directory = $this->userFolder->get('System information');
} catch (NotFoundException $e) {
try {
$directory = $this->userFolder->newFolder('System information');
} catch (\Exception $ex) {
$this->logger->warning('Could not create folder "System information" to store generated report.', [
'app' => 'support',
'exception' => $e,
]);
$response = new DataResponse(['message' => $this->l10n->t('Could not create folder "System information" to store generated report.')]);
$response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR);
return $response;
}
}
if (!($directory instanceof Folder)) {
$this->logger->warning('Could not create folder "System information" to store generated report, a file exists with this name.', [
'app' => 'support',
]);
$response = new DataResponse(['message' => $this->l10n->t('Could not create folder "System information" to store generated report, a file exists with this name.')]);
$response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR);
return $response;
}
$date = $this->timeFactory->getDateTime()->format('Y-m-d');
$filename = $date . '.md';
$filename = $directory->getNonExistingName($filename);
try {
$file = $directory->newFile($filename);
$details = $this->detailManager->getRenderedDetails();
$file->putContent($details);
} catch (\Exception $e) {
$this->logger->warning('Could not create file "' . $filename . '" to store generated report.', [
'app' => 'support',
'exception' => $e,
]);
$response = new DataResponse(['message' => $this->l10n->t('Could not create file "%s" to store generated report.', [ $filename ])]);
$response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR);
return $response;
}
try {
$passwordEvent = new GenerateSecurePasswordEvent();
$this->eventDispatcher->dispatchTyped($passwordEvent);
$password = $passwordEvent->getPassword() ?? $this->random->generate(20);
$share = $this->shareManager->newShare();
$share->setNode($file);
$share->setPermissions(Constants::PERMISSION_READ);
$share->setShareType(IShare::TYPE_LINK);
$share->setSharedBy($this->userId);
$share->setPassword($password);
if ($this->shareManager->shareApiLinkDefaultExpireDateEnforced()) {
$expiry = $this->timeFactory->getDateTime();
$expiry->add(new \DateInterval('P' . $this->shareManager->shareApiLinkDefaultExpireDays() . 'D'));
} else {
$expiry = $this->timeFactory->getDateTime();
$expiry->add(new \DateInterval('P2W'));
}
$share->setExpirationDate($expiry);
$share = $this->shareManager->createShare($share);
} catch (\Exception $e) {
$this->logger->warning('Could not share file "' . $filename . '".', [
'app' => 'support',
'exception' => $e,
]);
$response = new DataResponse(['message' => $this->l10n->t('Could not share file "%s". Nevertheless, you can find it in the folder "System information".', [$filename])]);
$response->setStatus(Http::STATUS_INTERNAL_SERVER_ERROR);
return $response;
}
return new DataResponse(
[
'link' => $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', ['token' => $share->getToken()]),
'password' => $password,
],
Http::STATUS_CREATED
);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support;
class Detail implements IDetail {
public function __construct(
private readonly string $section,
private readonly string $title,
private readonly string $information,
private readonly int $type,
) {
}
#[\Override]
public function getTitle(): string {
return $this->title;
}
#[\Override]
public function getSection(): string {
return $this->section;
}
#[\Override]
public function getInformation(): string {
return $this->information;
}
#[\Override]
public function getType(): int {
return $this->type;
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support;
use OCA\Support\Sections\AppApiSection;
use OCA\Support\Sections\LdapSection;
use OCA\Support\Sections\PhpInfoSection;
use OCA\Support\Sections\ServerSection;
use OCA\Support\Sections\SetupChecksSection;
use OCA\Support\Sections\TalkSection;
class DetailManager {
private array $sections = [];
public function __construct(
ServerSection $serverSection,
SetupChecksSection $setupChecksSection,
PhpInfoSection $phpInfoSection,
TalkSection $talkSection,
LdapSection $ldapSection,
AppApiSection $appApiSection,
) {
// Register core details that are used in every report
$this->addSection($serverSection);
$this->addSection($setupChecksSection);
$this->addSection($phpInfoSection);
if ($talkSection->isTalkEnabled()) {
$this->addSection($talkSection);
}
if ($ldapSection->isLdapEnabled()) {
$this->addSection($ldapSection);
}
if ($appApiSection->isAppApiEnabled()) {
$this->addSection($appApiSection);
}
}
public function createSection(string $identifier, string $title, int $order = 0): void {
$section = new Section($identifier, $title, $order);
$this->addSection($section);
}
public function addSection(ISection $section): void {
if (array_key_exists($section->getIdentifier(), $this->sections)) {
/** @var ISection $existing */
$existing = $this->sections[$section->getIdentifier()];
foreach ($section->getDetails() as $detail) {
$existing->addDetail($detail);
}
return;
}
$this->sections[$section->getIdentifier()] = $section;
}
public function removeSection(string $section): void {
unset($this->sections[$section]);
}
public function createDetail(string $sectionIdentifier, string $title, string $information, int $type = IDetail::TYPE_MULTI_LINE_PREFORMAT): void {
$detail = new Detail($sectionIdentifier, $title, $information, $type);
/** @var ISection $sectionObject */
$sectionObject = $this->sections[$sectionIdentifier];
$sectionObject->addDetail($detail);
}
/**
* @return ISection[]
*/
public function getSections(): array {
return $this->sections;
}
public function getRenderedDetails(): string {
$result = '';
/** @var ISection $section */
foreach ($this->sections as $section) {
$result .= $this->renderSectionHeader($section);
/** @var IDetail $detail */
foreach ($section->getDetails() as $detail) {
$result .= $this->renderDetail($detail);
}
}
return $result;
}
private function renderSectionHeader(ISection $section): string {
return '## ' . $section->getTitle() . "\n\n";
}
private function renderDetail(IDetail $detail): string {
switch ($detail->getType()) {
case IDetail::TYPE_SINGLE_LINE:
return '**' . $detail->getTitle() . ':** ' . $detail->getInformation() . "\n\n";
case IDetail::TYPE_MULTI_LINE:
return '**' . $detail->getTitle() . ":** \n\n" . $detail->getInformation() . "\n\n";
case IDetail::TYPE_MULTI_LINE_PREFORMAT:
return '**' . $detail->getTitle() . ":** \n\n``` \n" . $detail->getInformation() . "\n```\n\n";
case IDetail::TYPE_COLLAPSIBLE:
return '<details><summary>' . $detail->getTitle() . "</summary>\n\n" . $detail->getInformation() . "\n</details>\n\n";
case IDetail::TYPE_COLLAPSIBLE_PREFORMAT:
return '<details><summary>' . $detail->getTitle() . "</summary>\n\n```\n" . $detail->getInformation() . "\n```\n</details>\n\n";
default:
return '**' . $detail->getTitle() . ':** ' . $detail->getInformation() . "\n\n";
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support;
interface IDetail {
public const TYPE_SINGLE_LINE = 0;
public const TYPE_MULTI_LINE = 1;
public const TYPE_MULTI_LINE_PREFORMAT = 2;
public const TYPE_COLLAPSIBLE = 3;
public const TYPE_COLLAPSIBLE_PREFORMAT = 4;
public function getTitle(): string;
public function getSection(): string;
public function getInformation(): string;
public function getType(): int;
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support;
/**
* Interface ISection
*
* @package OCA\IssueTemplate
*/
interface ISection {
public function getIdentifier(): string;
public function getTitle(): string;
public function addDetail(IDetail $details): void;
/**
* @return IDetail[]
*/
public function getDetails(): array;
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Notification;
use OCA\Support\AppInfo\Application;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\L10N\IFactory;
use OCP\Notification\IManager;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException;
class Notifier implements INotifier {
public function __construct(
protected readonly IURLGenerator $url,
protected readonly IConfig $config,
protected readonly IManager $notificationManager,
protected readonly IFactory $l10nFactory,
) {
}
#[\Override]
public function getID(): string {
return 'support';
}
#[\Override]
public function getName(): string {
return $this->l10nFactory->get(Application::APP_ID)->t('Subscription notifications');
}
/**
* @param INotification $notification
* @param string $languageCode The code of the language that should be used to prepare the notification
* @return INotification
* @throws UnknownNotificationException When the notification was not prepared by a notifier
* @since 9.0.0
*/
#[\Override]
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== 'support') {
throw new UnknownNotificationException();
}
$l = $this->l10nFactory->get('support', $languageCode);
switch ($notification->getSubject()) {
case 'subscription_info':
$notification->setParsedSubject($l->t('Nextcloud Subscription'))
->setParsedMessage($l->t('Your server has no Nextcloud Subscription or your Subscription has expired.'));
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('support', 'notification.svg')));
return $notification;
case 'subscription_over_limit':
$notification->setParsedSubject($l->t('Nextcloud Subscription'))
->setParsedMessage($l->t('Your Nextcloud server subscription does not cover your number of users.'));
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('support', 'notification.svg')));
return $notification;
case 'subscription_expired':
$notification->setParsedSubject($l->t('Nextcloud Subscription'))
->setParsedMessage($l->t('Your Nextcloud Subscription has expired!'));
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('support', 'notification.svg')));
return $notification;
default:
// Unknown subject => Unknown notification => throw
throw new UnknownNotificationException();
}
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Repair;
use OCP\IAppConfig;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
class MigrateLazyAppConfig implements IRepairStep {
public function __construct(
protected readonly IAppConfig $appConfig,
) {
}
#[\Override]
public function getName(): string {
return 'Migrate some config values to lazy loading';
}
#[\Override]
public function run(IOutput $output): void {
$this->appConfig->updateLazy('support', 'last_response', true);
// Copy often used values to non-lazy (also done when fetching)
$data = $this->appConfig->getValueArray('support', 'last_response');
if (!empty($data)) {
$this->appConfig->setValueString('support', 'end_date', $data['endDate'] ?? '');
$this->appConfig->setValueBool('support', 'extended_support', $data['extendedSupport'] ?? false);
}
// if more config values needs to be switched to lazy, just add them here
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Repair;
use OCP\IConfig;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use OCP\Support\Subscription\IRegistry;
class SwitchUpdaterServer implements IRepairStep {
public function __construct(
protected readonly IConfig $config,
protected readonly IRegistry $subscriptionRegistry,
) {
}
#[\Override]
public function getName(): string {
return 'Switches from default updater server to the customer one if a valid subscription is available';
}
#[\Override]
public function run(IOutput $output): void {
if ($this->config->getAppValue('support', 'SwitchUpdaterServerHasRun') === 'yes') {
$output->info('Repair step already executed');
return;
}
$currentUpdaterServer = $this->config->getSystemValue('updater.server.url', 'https://updates.nextcloud.com/updater_server/');
$subscriptionKey = $this->config->getAppValue('support', 'subscription_key', '');
/**
* only overwrite the updater server if:
* - it is the default one
* - there is a valid subscription
* - there is a subscription key set
* - the subscription key is halfway sane
*/
if ($currentUpdaterServer === 'https://updates.nextcloud.com/updater_server/' &&
$this->subscriptionRegistry->delegateHasValidSubscription() &&
$subscriptionKey !== '' &&
preg_match('!^[a-zA-Z0-9-]{10,250}$!', $subscriptionKey)
) {
$this->config->setSystemValue('updater.server.url', 'https://updates.nextcloud.com/customers/' . $subscriptionKey . '/');
}
// if everything is done, no need to redo the repair during next upgrade
$this->config->setAppValue('support', 'SwitchUpdaterServerHasRun', 'yes');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support;
class Section implements ISection {
/** @var IDetail[] */
private array $details = [];
public function __construct(
private readonly string $identifier,
private readonly string $title,
int $order = 0,
) {
}
#[\Override]
public function getIdentifier(): string {
return $this->identifier;
}
#[\Override]
public function getTitle(): string {
return $this->title;
}
#[\Override]
public function addDetail(IDetail $details): void {
$this->details[] = $details;
}
#[\Override]
public function getDetails(): array {
return $this->details;
}
public function createDetail(string $title, string $information, int $type = IDetail::TYPE_SINGLE_LINE): IDetail {
$detail = new Detail($this->getIdentifier(), $title, $information, $type);
$this->addDetail($detail);
return $detail;
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Sections;
use OCA\AppAPI\Db\ExAppMapper;
use OCA\AppAPI\Service\DaemonConfigService;
use OCA\Support\IDetail;
use OCA\Support\Section;
use OCP\App\IAppManager;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\Server;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
class AppApiSection extends Section {
public function __construct(
protected readonly IConfig $config,
protected readonly IAppManager $appManager,
protected readonly IClientService $clientService,
) {
parent::__construct('app_api', 'AppAPI');
}
#[\Override]
public function getDetails(): array {
$this->createDetail('AppAPI configuration', $this->getAppApiInfo(), IDetail::TYPE_COLLAPSIBLE);
return parent::getDetails();
}
public function isAppApiEnabled(): bool {
return $this->appManager->isInstalled('app_api');
}
private function getAppApiInfo(): string {
$output = PHP_EOL;
try {
$exAppMapper = Server::get(ExAppMapper::class);
$exApps = $exAppMapper->findAll();
$output .= PHP_EOL;
$output .= '## ExApps' . PHP_EOL;
if (!empty($exApps)) {
foreach ($exApps as $exApp) {
$enabled = $exApp->getEnabled() ? 'enabled' : 'disabled';
$output .= ' * ' . $exApp->getAppid() . ' (' . $exApp->getName() . '): ' . $exApp->getVersion() . ' [' . $enabled . ']' . PHP_EOL;
}
} else {
$output .= ' * no ExApps installed' . PHP_EOL;
}
$daemonConfigService = Server::get(DaemonConfigService::class);
$daemonConfigs = $daemonConfigService->getRegisteredDaemonConfigs();
$output .= PHP_EOL;
$output .= '## Deploy daemons' . PHP_EOL;
foreach ($daemonConfigs as $daemon) {
$deployConfig = $daemon->getDeployConfig();
$deployConfig['haproxy_password'] = '***';
$output .= ' * ' . $daemon->getName() . ' (' . $daemon->getDisplayName() . ')' . PHP_EOL;
$output .= ' - Is HaRP: ' . (isset($deployConfig['harp']) ? 'yes' : 'no') . PHP_EOL;
$output .= ' - Deployment method: ' . $daemon->getAcceptsDeployId() . PHP_EOL;
$output .= ' - Protocol: ' . $daemon->getProtocol() . PHP_EOL;
$output .= ' - Host: ' . $daemon->getHost() . PHP_EOL;
$output .= ' - Deploy config: ' . json_encode($deployConfig, JSON_PRETTY_PRINT) . PHP_EOL;
}
$config = [
'default_daemon_config' => $this->config->getAppValue('app_api', 'default_daemon_config'),
'init_timeout' => $this->config->getAppValue('app_api', 'init_timeout', '40'),
'container_restart_policy' => $this->config->getAppValue('app_api', 'container_restart_policy', 'unless-stopped'),
];
$output .= PHP_EOL;
$output .= '## Config' . PHP_EOL;
$output .= ' * Default daemon config (default_daemon_config): ' . $config['default_daemon_config'] . PHP_EOL;
$output .= ' * Init timeout (init_timeout): ' . $config['init_timeout'] . PHP_EOL;
$output .= ' * Container restart policy (container_restart_policy): ' . $config['container_restart_policy'] . PHP_EOL;
} catch (NotFoundExceptionInterface|ContainerExceptionInterface) {
}
return $output;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Sections;
use OCA\Support\IDetail;
use OCA\Support\Section;
use OCA\User_LDAP\Configuration;
use OCA\User_LDAP\Helper;
use OCA\User_LDAP\User_Proxy;
use OCP\IUserManager;
use OCP\Server;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\BufferedOutput;
class LdapSection extends Section {
public function __construct(
protected readonly IUserManager $userManager,
) {
parent::__construct('ldap', 'LDAP');
}
#[\Override]
public function getDetails(): array {
$this->createDetail('LDAP configuration', $this->getLDAPInfo(), IDetail::TYPE_COLLAPSIBLE_PREFORMAT);
return parent::getDetails();
}
public function isLDAPEnabled(): bool {
$backends = $this->userManager->getBackends();
foreach ($backends as $backend) {
if ($backend instanceof User_Proxy) {
return true;
}
}
return false;
}
private function getLDAPInfo(): string {
$helper = Server::get(Helper::class);
$output = new BufferedOutput();
// copy of OCA\User_LDAP\Command\ShowConfig::renderConfigs
$configIDs = $helper->getServerConfigurationPrefixes();
foreach ($configIDs as $id) {
$configHolder = new Configuration($id);
$configuration = $configHolder->getConfiguration();
ksort($configuration);
$table = new Table($output);
$table->setHeaders(['Configuration', $id]);
$rows = [];
foreach ($configuration as $key => $value) {
if ($key === 'ldapAgentPassword') {
$value = '***';
}
if (is_array($value)) {
$value = implode(';', $value);
}
$rows[] = [$key, $value];
}
$table->setRows($rows);
$table->render();
}
return $output->fetch();
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Sections;
use DOMDocument;
use DOMXPath;
use OCA\Support\IDetail;
use OCA\Support\Section;
class PhpInfoSection extends Section {
public function __construct(
) {
parent::__construct('phpinfo', 'Phpinfo');
}
#[\Override]
public function getDetails(): array {
ob_start();
phpinfo(INFO_CONFIGURATION | INFO_MODULES);
$phpinfo = ob_get_clean();
if ($phpinfo === false) {
$this->createDetail('error', 'Failed to retrieve phpinfo output.', IDetail::TYPE_SINGLE_LINE);
return parent::getDetails();
}
if (strpos($phpinfo, '<!DOCTYPE html>') === false) {
// If phpinfo output is not HTML, we dump the raw output
$this->createDetail('phpifo', $phpinfo, IDetail::TYPE_COLLAPSIBLE_PREFORMAT);
return parent::getDetails();
}
$parsedInfo = array_values($this->parsePhpInfoFromHtml($phpinfo))[0] ?? [];
foreach ($parsedInfo as $sectionName => $sectionData) {
$this->createDetail($sectionName, json_encode($sectionData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), IDetail::TYPE_COLLAPSIBLE_PREFORMAT);
}
return parent::getDetails();
}
private function parsePhpInfoFromHtml(string $phpInfoHtmlStr): array {
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML($phpInfoHtmlStr, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
$result = [];
$currentSection = '';
$currentSubsection = '';
$elements = $xpath->query('//h1 | //h2 | //table');
foreach ($elements as $el) {
if ($el->nodeName === 'h1') {
$currentSection = trim($el->textContent);
$result[$currentSection] = [];
} elseif ($el->nodeName === 'h2') {
$currentSubsection = trim($el->textContent);
if (!isset($result[$currentSection][$currentSubsection])) {
$result[$currentSection][$currentSubsection] = [];
}
} elseif ($el->nodeName === 'table') {
$rows = $el->getElementsByTagName('tr');
foreach ($rows as $row) {
$cols = $row->getElementsByTagName('td');
$ths = $row->getElementsByTagName('th');
if ($cols->length === 2) {
// Single key => value
$key = trim($cols->item(0)->textContent);
$val = trim($cols->item(1)->textContent);
$result[$currentSection][$currentSubsection][$key] = $val;
} elseif ($cols->length === 3) {
// Directive with local/master value
$key = trim($cols->item(0)->textContent);
$local = trim($cols->item(1)->textContent);
$master = trim($cols->item(2)->textContent);
$result[$currentSection][$currentSubsection][$key] = [
'local' => $local,
'master' => $master,
];
} elseif ($ths->length > 0) {
// This is a header row; skip or save metadata
continue;
}
}
}
}
return $result;
}
}
+425
View File
@@ -0,0 +1,425 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Sections;
use OC\IntegrityCheck\Checker;
use OC\SystemConfig;
use OCA\Files_External\Lib\StorageConfig;
use OCA\Files_External\Service\GlobalStoragesService;
use OCA\Support\IDetail;
use OCA\Support\Section;
use OCA\Support\Service\SubscriptionService;
use OCA\Support\Subscription\SubscriptionAdapter;
use OCP\App\IAppManager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\Server;
use OCP\ServerVersion;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\BufferedOutput;
class ServerSection extends Section {
public function __construct(
protected readonly IConfig $config,
protected readonly Checker $checker,
protected readonly IAppManager $appManager,
protected readonly IDBConnection $connection,
protected readonly IUserManager $userManager,
protected readonly LoggerInterface $logger,
protected readonly SystemConfig $systemConfig,
protected readonly IAppConfig $appConfig,
protected readonly ServerVersion $serverVersion,
protected readonly SubscriptionAdapter $adapter,
protected readonly ITimeFactory $timeFactory,
) {
parent::__construct('server-detail', 'Server configuration detail');
}
#[\Override]
public function getDetails(): array {
$this->createDetail('Operating system', $this->getOsVersion());
$this->createDetail('Webserver', $this->getWebserver());
$this->createDetail('Database', $this->getDatabaseInfo());
$this->createDetail('PHP version', $this->getPhpVersion());
$this->createDetail('Nextcloud version', $this->getNextcloudVersion());
$this->createDetail('Updated from an older Nextcloud/ownCloud or fresh install', '');
$this->createDetail('Where did you install Nextcloud from', $this->getInstallMethod());
$this->createDetail('Signing status', $this->getIntegrityResults(), IDetail::TYPE_COLLAPSIBLE);
$this->createDetail('List of activated apps', $this->renderAppList(), IDetail::TYPE_COLLAPSIBLE_PREFORMAT);
$this->createDetail('Configuration (config/config.php)', print_r(json_encode($this->getConfig(), JSON_PRETTY_PRINT), true), IDetail::TYPE_COLLAPSIBLE_PREFORMAT);
$this->createDetail('Cron Configuration', $this->getCronConfig());
$externalStorageEnabled = $this->appManager->isEnabledForUser('files_external');
$this->createDetail('External storages', $externalStorageEnabled ? 'yes' : 'files_external is disabled');
if ($externalStorageEnabled) {
$this->createDetail('External storage configuration', $this->getExternalStorageInfo(), IDetail::TYPE_COLLAPSIBLE_PREFORMAT);
}
$this->createDetail('Encryption', $this->getEncryptionInfo());
$this->createDetail('User-backends', $this->getUserBackendInfo());
$this->createDetail('Subscription', $this->getSubscriptionInfo());
$this->createDetail('Browser', $this->getBrowser());
return parent::getDetails();
}
private function getWebserver(): string {
return ($_SERVER['SERVER_SOFTWARE'] ?? 'Unknown') . ' (' . PHP_SAPI . ')';
}
private function getNextcloudVersion(): string {
return $this->serverVersion->getHumanVersion() . ' - ' . $this->config->getSystemValueString('version');
}
private function getOsVersion(): string {
return function_exists('php_uname') ? php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('v') . ' ' . php_uname('m') : PHP_OS;
}
private function getPhpVersion(): string {
return PHP_VERSION . "\n\nModules loaded: " . implode(', ', get_loaded_extensions());
}
protected function getDatabaseInfo(): string {
return $this->config->getSystemValueString('dbtype') . ' ' . $this->getDatabaseVersion();
}
/**
* original source from nextcloud/survey_client
* @link https://github.com/nextcloud/survey_client/blob/master/lib/Categories/Database.php#L80-L107
*
* @copyright Copyright (c) 2016, ownCloud, Inc.
* @author Joas Schilling <coding@schilljs.com>
* @license AGPL-3.0
*/
private function getDatabaseVersion(): string {
switch ($this->config->getSystemValueString('dbtype')) {
case 'sqlite':
case 'sqlite3':
$sql = 'SELECT sqlite_version() AS version';
break;
case 'oci':
$sql = 'SELECT VERSION FROM PRODUCT_COMPONENT_VERSION';
break;
case 'mysql':
case 'pgsql':
default:
$sql = 'SELECT VERSION() AS version';
break;
}
try {
$result = $this->connection->executeQuery($sql);
$version = $result->fetchOne();
$result->closeCursor();
if ($version) {
return $this->cleanVersion($version);
}
} catch (Exception $e) {
$this->logger->debug('Unable to determine database version', [
'exception' => $e
]);
}
return 'N/A';
}
/**
* Try to strip away additional information
*
* @copyright Copyright (c) 2016, ownCloud, Inc.
* @author Joas Schilling <coding@schilljs.com>
* @license AGPL-3.0
*
* @param string $version E.g. `5.6.27-0ubuntu0.14.04.1`
* @return string `5.6.27`
*/
protected function cleanVersion(string $version): string {
$matches = [];
preg_match('/^(\d+)(\.\d+)(\.\d+)/', $version, $matches);
if (isset($matches[0])) {
return $matches[0];
}
return $version;
}
private function getCronConfig(): string {
$mode = $this->appConfig->getValueString('core', 'backgroundjobs_mode', 'ajax');
$last = $this->appConfig->getValueInt('core', 'lastcron', 0);
if ($last === 0) {
$formattedLast = 'never';
} else {
$formattedLast = date('c', $last) . ' (' . (time() - $last) . ' seconds ago)';
}
return PHP_EOL . PHP_EOL
. 'Mode: ' . $mode . PHP_EOL
. 'Last: ' . $formattedLast . PHP_EOL;
}
private function getIntegrityResults(): string {
if (!$this->checker->isCodeCheckEnforced()) {
return 'Integrity checker has been disabled. Integrity cannot be verified.';
}
return print_r(json_encode($this->checker->getResults(), JSON_PRETTY_PRINT), true);
}
private function getInstallMethod(): string {
$base = \OC::$SERVERROOT;
if (file_exists($base . '/.git')) {
return 'git';
}
return 'unknown';
}
private function renderAppList(): string {
$apps = $this->getAppList();
$result = '';
if ($apps['supported'] !== []) {
$result .= "Supported:\n";
foreach ($apps['supported'] as $name => $version) {
$result .= ' - ' . $name . ': ' . $version . "\n";
}
}
$result .= "Enabled:\n";
foreach ($apps['enabled'] as $name => $version) {
$result .= ' - ' . $name . ': ' . $version . "\n";
}
$result .= "Disabled:\n";
foreach ($apps['disabled'] as $name => $version) {
if ($version) {
$result .= ' - ' . $name . ': ' . $version . "\n";
} else {
$result .= ' - ' . $name . "\n";
}
}
return $result;
}
/**
* @return array<string, array<string, string|bool>>
*/
private function getAppList(): array {
$apps = $this->appManager->getAllAppsInAppsFolders();
$alwaysEnabled = $this->appManager->getAlwaysEnabledApps();
$subscriptionRegistry = Server::get(IRegistry::class);
$supportedAppsIDs = $subscriptionRegistry->delegateGetSupportedApps();
$supportedApps = $enabledApps = $disabledApps = [];
$versions = $this->appManager->getAppInstalledVersions();
// sort enabled apps above disabled apps
foreach ($apps as $app) {
if (in_array($app, $alwaysEnabled)) {
continue;
}
if ($this->appManager->isEnabledForAnyone($app)) {
if (in_array($app, $supportedAppsIDs)) {
$supportedApps[] = $app;
continue;
}
// enabled but not ours
$enabledApps[] = $app;
continue;
}
$disabledApps[] = $app;
}
$apps = [
'supported' => [],
'enabled' => [],
'disabled' => []
];
sort($supportedApps);
foreach ($supportedApps as $app) {
$apps['supported'][$app] = $versions[$app] ?? true;
}
sort($enabledApps);
foreach ($enabledApps as $app) {
$apps['enabled'][$app] = $versions[$app] ?? true;
}
sort($disabledApps);
foreach ($disabledApps as $app) {
$apps['disabled'][$app] = $versions[$app] ?? false;
}
return $apps;
}
protected function getEncryptionInfo(): string {
return $this->appConfig->getValueString('core', 'encryption_enabled', 'no');
}
protected function getExternalStorageInfo(): string {
$globalService = Server::get(GlobalStoragesService::class);
$mounts = $globalService->getStorageForAllUsers();
// copy of OCA\Files_External\Command\ListCommand::listMounts
if ($mounts === null || count($mounts) === 0) {
return 'No mounts configured';
}
$headers = ['Mount ID', 'Mount Point', 'Storage', 'Authentication Type', 'Configuration', 'Options'];
$headers[] = 'Applicable Users';
$headers[] = 'Applicable Groups';
$headers[] = 'Type';
$hideKeys = ['password', 'refresh_token', 'token', 'client_secret', 'public_key', 'private_key', 'key', 'secret'];
/** @var StorageConfig $mount */
foreach ($mounts as $mount) {
$config = $mount->getBackendOptions();
foreach ($config as $key => $value) {
if (in_array($key, $hideKeys)) {
$mount->setBackendOption($key, '***');
}
}
}
$defaultMountOptions = [
'encrypt' => true,
'previews' => true,
'filesystem_check_changes' => 1,
'enable_sharing' => false,
'encoding_compatibility' => false,
'readonly' => false,
];
$rows = array_map(function (StorageConfig $config) use ($defaultMountOptions) {
$storageConfig = $config->getBackendOptions();
$keys = array_keys($storageConfig);
$values = array_values($storageConfig);
$configStrings = array_map(function ($key, $value) {
return $key . ': ' . json_encode($value);
}, $keys, $values);
$configString = implode(', ', $configStrings);
$mountOptions = $config->getMountOptions();
// hide defaults
foreach ($mountOptions as $key => $value) {
if (isset($defaultMountOptions[$key]) && ($value === $defaultMountOptions[$key])) {
unset($mountOptions[$key]);
}
}
$keys = array_keys($mountOptions);
$values = array_values($mountOptions);
$optionsStrings = array_map(function ($key, $value) {
return $key . ': ' . json_encode($value);
}, $keys, $values);
$optionsString = implode(', ', $optionsStrings);
$values = [
$config->getId(),
$config->getMountPoint(),
$config->getBackend()->getText(),
$config->getAuthMechanism()->getText(),
$configString,
$optionsString
];
$applicableUsers = implode(', ', $config->getApplicableUsers());
$applicableGroups = implode(', ', $config->getApplicableGroups());
if ($applicableUsers === '' && $applicableGroups === '') {
$applicableUsers = 'All';
}
$values[] = $applicableUsers;
$values[] = $applicableGroups;
$values[] = $config->getType() === StorageConfig::MOUNT_TYPE_ADMIN ? 'Admin' : 'Personal';
return $values;
}, $mounts);
$output = new BufferedOutput();
$table = new Table($output);
$table->setHeaders($headers);
$table->setRows($rows);
$table->render();
return $output->fetch();
}
private function getConfig(): array {
$keys = $this->systemConfig->getKeys();
$configs = [];
foreach ($keys as $key) {
$value = $this->config->getFilteredSystemValue($key, serialize(null));
if ($value !== 'N;') {
$configs[$key] = $value;
}
}
return $configs;
}
private function getBrowser(): string {
return $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
}
private function getUserBackendInfo(): string {
$backends = $this->userManager->getBackends();
$output = PHP_EOL;
foreach ($backends as $backend) {
$output .= ' * ' . get_class($backend) . PHP_EOL;
}
return $output;
}
private function getSubscriptionInfo(): string {
$output = PHP_EOL;
if ($this->adapter->hasValidSubscription()) {
$output .= ' * Instance has valid subscription key set' . PHP_EOL;
} else {
$output .= ' * No valid subscription key set' . PHP_EOL;
}
$lastError = $this->appConfig->getValueInt('support', 'last_error');
if ($lastError > 0) {
switch ($lastError) {
case SubscriptionService::ERROR_FAILED_RETRY:
$output .= ' * The subscription info could not properly fetched and will be retried' . PHP_EOL;
break;
case SubscriptionService::ERROR_FAILED_INVALID:
$output .= ' * The subscription key was invalid' . PHP_EOL;
break;
case SubscriptionService::ERROR_NO_INTERNET_CONNECTION:
$output .= ' * The subscription key could not be verified, because this server has no internet connection' . PHP_EOL;
break;
case SubscriptionService::ERROR_INVALID_SUBSCRIPTION_KEY:
$output .= ' * The subscription key had an invalid format' . PHP_EOL;
break;
default:
$output .= ' * An error occurred while fetching the subscription information' . PHP_EOL;
break;
}
}
if ($this->adapter->isHardUserLimitReached()) {
$output .= ' * Reached user limit of subscription' . PHP_EOL;
}
$rateLimitReached = $this->appConfig->getValueInt('notifications', 'rate_limit_reached');
if ($rateLimitReached >= ($this->timeFactory->now()->getTimestamp() - 7 * 24 * 3600)) {
$output .= ' * Fair-use push notification limit reached' . PHP_EOL;
}
return $output;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Sections;
use OCA\Support\IDetail;
use OCA\Support\Section;
use OCP\RichObjectStrings\IRichTextFormatter;
use OCP\SetupCheck\ISetupCheckManager;
use OCP\SetupCheck\SetupResult;
class SetupChecksSection extends Section {
public function __construct(
private ISetupCheckManager $setupCheckManager,
private IRichTextFormatter $richTextFormatter,
) {
parent::__construct('setupchecks', 'Setup checks');
}
#[\Override]
public function getDetails(): array {
// FIXME Make sure to use the cached version once we have it
$results = $this->setupCheckManager->runAll();
foreach ($results as $category => $content) {
if ($category === 'accounts') {
/* Do not include accounts section in the report */
continue;
}
$problems = '';
foreach ($content as $class => $result) {
if ($result->getSeverity() != SetupResult::SUCCESS) {
$description = $result->getDescription();
$descriptionParameters = $result->getDescriptionParameters();
if ($description !== null && $descriptionParameters !== null) {
$description = $this->richTextFormatter->richToParsed($description, $descriptionParameters);
}
$descriptionLines = explode("\n", $description);
$problems .= ' * ' . $result->getName() . ': ' . implode("\n ", $descriptionLines) . "\n";
}
}
if ($problems !== '') {
$this->createDetail($category, $problems, IDetail::TYPE_COLLAPSIBLE);
}
}
return parent::getDetails();
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Sections;
use OCA\Support\IDetail;
use OCA\Support\Section;
use OCP\App\IAppManager;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\IConfig;
class TalkSection extends Section {
public function __construct(
protected readonly IConfig $config,
protected readonly IAppManager $appManager,
protected readonly IClientService $clientService,
protected readonly IAppConfig $appConfig,
) {
parent::__construct('talk', 'Talk');
}
#[\Override]
public function getDetails(): array {
$this->createDetail('Talk configuration', $this->getTalkInfo());
$this->createDetail('Talk app configuration', $this->getTalkAppConfiguration(), IDetail::TYPE_COLLAPSIBLE_PREFORMAT);
return parent::getDetails();
}
public function isTalkEnabled(): bool {
return $this->appManager->isEnabledForUser('spreed');
}
private function getTalkInfo(): string {
$output = PHP_EOL;
$config = $this->config->getAppValue('spreed', 'stun_servers');
$servers = json_decode($config, true);
$output .= PHP_EOL;
$output .= 'STUN servers' . PHP_EOL;
if (empty($servers)) {
$output .= ' * no custom server configured' . PHP_EOL;
} else {
foreach ($servers as $server) {
$output .= ' * ' . $server . PHP_EOL;
}
}
$config = $this->config->getAppValue('spreed', 'turn_servers');
$servers = json_decode($config, true);
$output .= PHP_EOL;
$output .= 'TURN servers' . PHP_EOL;
if (empty($servers)) {
$output .= ' * no custom server configured' . PHP_EOL;
} else {
foreach ($servers as $server) {
$output .= ' * ' . ($server['schemes'] ?? 'turn') . ':' . $server['server'] . ' - ' . $server['protocols'] . PHP_EOL;
}
}
$config = $this->config->getAppValue('spreed', 'signaling_mode', 'default');
$output .= PHP_EOL;
$output .= 'Signaling servers (mode: ' . $config . '):' . PHP_EOL;
if ($this->config->getAppValue('spreed', 'sip_bridge_shared_secret') !== '') {
$output .= ' * SIP dialin is enabled' . PHP_EOL;
} else {
$output .= ' * SIP dialin is disabled' . PHP_EOL;
}
if ($this->config->getAppValue('spreed', 'sip_dialout', 'no') !== 'no') {
$output .= ' * SIP dialout is enabled' . PHP_EOL;
} else {
$output .= ' * SIP dialout is disabled' . PHP_EOL;
}
$config = $this->config->getAppValue('spreed', 'signaling_servers');
$servers = json_decode($config, true);
if (empty($servers['servers'])) {
$output .= ' * no custom server configured' . PHP_EOL;
} else {
foreach ($servers['servers'] as $server) {
$output .= ' * ' . $server['server'] . ' - ' . $this->getTalkComponentVersion($server['server']) . PHP_EOL;
}
}
$output .= PHP_EOL;
$output .= 'Recording servers:' . PHP_EOL;
if ($this->config->getAppValue('spreed', 'call_recording', 'yes') !== 'yes') {
$output .= ' * Recording is disabled' . PHP_EOL;
} else {
$output .= ' * Recording is enabled' . PHP_EOL;
}
$output .= ' * Recording consent is set to "' . $this->config->getAppValue('spreed', 'recording_consent', 'default') . '"' . PHP_EOL;
$config = $this->config->getAppValue('spreed', 'recording_servers');
$servers = json_decode($config, true);
if (empty($servers['servers'])) {
$output .= ' * no recording server configured' . PHP_EOL;
} else {
foreach ($servers['servers'] as $server) {
$output .= ' * ' . $server['server'] . ' - ' . $this->getTalkComponentVersion($server['server']) . PHP_EOL;
}
}
return $output;
}
private function getTalkAppConfiguration(): string {
$spreedConfig = $this->appConfig->getAllValues('spreed', filtered: true);
return json_encode($spreedConfig, JSON_PRETTY_PRINT) . PHP_EOL;
}
private function getTalkComponentVersion(string $url): string {
$url = rtrim($url, '/');
if (strpos($url, 'wss://') === 0) {
$url = 'https://' . substr($url, 6);
}
if (strpos($url, 'ws://') === 0) {
$url = 'http://' . substr($url, 5);
}
$client = $this->clientService->newClient();
try {
$response = $client->get($url . '/api/v1/welcome', [
'verify' => false,
'nextcloud' => [
'allow_local_address' => true,
],
]);
$body = $response->getBody();
$data = json_decode($body, true);
if (!is_array($data) || !isset($data['version'])) {
return 'error';
}
return (string)$data['version'];
} catch (\Exception $e) {
return 'error: ' . $e->getMessage();
}
}
}
@@ -0,0 +1,705 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Service;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use OC\User\Backend;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\Notification\IManager;
use OCP\ServerVersion;
use OCP\User\Backend\ICountUsersBackend;
use Psr\Log\LoggerInterface;
class SubscriptionService {
public const ERROR_FAILED_RETRY = 1;
public const ERROR_FAILED_INVALID = 2;
public const ERROR_NO_INTERNET_CONNECTION = 3;
public const ERROR_INVALID_SUBSCRIPTION_KEY = 4;
public const THRESHOLD_MEDIUM = 500;
public const THRESHOLD_LARGE = 1000;
private int $userCount = -1;
private int $activeUserCount = -1;
private ?array $subscriptionInfoCache = null;
public function __construct(
protected readonly IConfig $config,
protected readonly IClientService $clientService,
protected readonly LoggerInterface $log,
protected readonly IUserManager $userManager,
protected readonly IManager $notifications,
protected readonly IURLGenerator $urlGenerator,
protected readonly IGroupManager $groupManager,
protected readonly IMailer $mailer,
protected readonly IFactory $l10nFactory,
protected readonly ICacheFactory $cacheFactory,
protected readonly IAppConfig $appConfig,
protected readonly ServerVersion $serverVersion,
) {
}
public function setSubscriptionKey(string $subscriptionKey): void {
if (!preg_match('!^[a-zA-Z0-9-]{10,250}$!', $subscriptionKey)) {
$this->appConfig->setValueInt('support', 'last_error', self::ERROR_INVALID_SUBSCRIPTION_KEY);
return;
}
$this->appConfig->setValueString('support', 'potential_subscription_key', $subscriptionKey);
$this->appConfig->deleteKey('support', 'last_error');
$this->renewSubscriptionInfo(true);
}
public function getUserCount(): int {
if ($this->userCount > 0) {
return $this->userCount;
}
$userCount = 0;
$backends = $this->userManager->getBackends();
foreach ($backends as $backend) {
if ($backend->implementsActions(Backend::COUNT_USERS)) {
/** @var ICountUsersBackend $backend */
try {
$backendUsers = $backend->countUsers();
} catch (\Exception $e) {
$backendUsers = false;
$this->log->error($e->getMessage(), ['exception' => $e]);
}
if ($backendUsers !== false) {
$userCount += $backendUsers;
} else {
// TODO what if the user count can't be determined?
$this->log->warning('Can not determine user count for ' . get_class($backend), ['app' => 'support']);
}
}
}
$disabledUsers = $this->config->getUsersForUserValue('core', 'enabled', 'false');
$disabledUsersCount = count($disabledUsers);
$this->userCount = $userCount - $disabledUsersCount;
if ($this->userCount < 0) {
$this->userCount = 0;
// TODO this should never happen
$this->log->warning("Total user count was negative (users: $userCount, disabled: $disabledUsersCount)", ['app' => 'support']);
}
return $this->userCount;
}
public function getActiveUserCount(): int {
if ($this->activeUserCount > 0) {
return $this->activeUserCount;
}
$this->activeUserCount = $this->userManager->countSeenUsers();
return $this->activeUserCount;
}
public function renewSubscriptionInfo(bool $fast): void {
$hasInternetConnection = $this->config->getSystemValue('has_internet_connection', true);
if (!$hasInternetConnection) {
$this->appConfig->setValueInt('support', 'last_error', self::ERROR_NO_INTERNET_CONNECTION);
return;
}
$subscriptionKey = $this->appConfig->getValueString('support', 'potential_subscription_key');
if (!preg_match('!^[a-zA-Z0-9-]{10,250}$!', $subscriptionKey)) {
// fallback to normal subscription key
$subscriptionKey = $this->appConfig->getValueString('support', 'subscription_key');
if (!preg_match('!^[a-zA-Z0-9-]{10,250}$!', $subscriptionKey)) {
return;
}
}
$backendURL = $this->config->getSystemValue('support.backend', 'https://cloud.nextcloud.com/');
$backendURL = rtrim($backendURL, '/') . '/apps/zammad_organisation_management/api/query/subscription/' . $subscriptionKey;
try {
$userCount = $this->getUserCount();
$activeUserCount = $this->userManager->countSeenUsers();
$httpClient = $this->clientService->newClient();
$response = $httpClient->post(
$backendURL,
[
'body' => [
'instanceId' => $this->config->getSystemValue('instanceid', ''),
'userCount' => $userCount,
'activeUserCount' => $activeUserCount,
'apps' => $this->getAppsDetails(),
'version' => implode('.', $this->serverVersion->getVersion()),
],
'timeout' => $fast ? 10 : 30,
'connect_timeout' => $fast ? 3 : 30,
]
);
$body = json_decode($response->getBody(), true);
if ($response->getStatusCode() === 200 && is_array($body)) {
$this->log->info('Subscription info successfully fetched');
$this->appConfig->setValueString('support', 'subscription_key', $subscriptionKey);
$this->appConfig->setValueInt('support', 'last_check', time());
$this->appConfig->setValueArray('support', 'last_response', $body, lazy: true);
$this->appConfig->setValueString('support', 'end_date', $body['endDate'] ?? '');
$this->appConfig->setValueBool('support', 'extended_support', $body['extendedSupport'] ?? false);
$this->appConfig->deleteKey('support', 'last_error');
$currentUpdaterServer = $this->config->getSystemValue('updater.server.url', 'https://updates.nextcloud.com/updater_server/');
$newUpdaterServer = 'https://updates.nextcloud.com/customers/' . $subscriptionKey . '/';
/**
* only overwrite the updater server if:
* - it is the default one or another /.customers/ one
* - there is a valid subscription
* - there is a subscription key set
* - the subscription key is halfway sane
*/
if (
(
$currentUpdaterServer === 'https://updates.nextcloud.com/updater_server/' ||
substr($currentUpdaterServer, 0, 40) === 'https://updates.nextcloud.com/customers/'
) &&
$subscriptionKey !== '' &&
preg_match('!^[a-zA-Z0-9-]{10,250}$!', $subscriptionKey)
) {
$this->config->setSystemValue('updater.server.url', $newUpdaterServer);
}
// remove all pending notifications
$notification = $this->notifications->createNotification();
$notification->setApp('support')
->setSubject('subscription_info');
$this->notifications->markProcessed($notification);
// hide push fair use warning
$cacheNotifications = $this->cacheFactory->createDistributed('notifications');
$cacheNotifications->remove('push_fair_use');
return;
}
$this->log->info('Renewal of subscription info returned invalid data. URL: ' . $backendURL . ' Status: ' . $response->getStatusCode() . ' Body: ' . $response->getBody());
$error = self::ERROR_FAILED_RETRY;
} catch (ConnectException $e) {
$this->log->info('Renew of subscription info failed due to connect exception - retrying later. URL: ' . $backendURL, ['app' => 'support', 'exception' => $e]);
$error = self::ERROR_FAILED_RETRY;
} catch (RequestException $e) {
$response = $e->getResponse();
if ($response !== null && $response->getStatusCode() === 403) {
$this->log->info('Subscription key invalid');
$this->appConfig->deleteKey('support', 'potential_subscription_key');
$error = self::ERROR_FAILED_INVALID;
} else {
$this->log->info('Renew of subscription info failed. URL: ' . $backendURL, ['app' => 'support', 'exception' => $e]);
$error = self::ERROR_FAILED_RETRY;
}
} catch (\Exception $e) {
$this->log->info('Renew of subscription info failed. URL: ' . $backendURL, ['app' => 'support', 'exception' => $e]);
$error = self::ERROR_FAILED_RETRY;
}
$this->appConfig->setValueInt('support', 'last_error', $error);
}
public function getSubscriptionInfo(): array {
if ($this->subscriptionInfoCache !== null) {
return $this->subscriptionInfoCache;
}
$userCount = $this->getUserCount();
$activeUserCount = $this->getActiveUserCount();
$instanceSize = 'small';
if ($userCount > SubscriptionService::THRESHOLD_MEDIUM) {
if ($userCount > SubscriptionService::THRESHOLD_LARGE) {
$instanceSize = 'large';
} else {
$instanceSize = 'medium';
}
}
$subscriptionInfo = $this->getLastResponseSubscriptionInfo();
$now = new \DateTime();
$subscriptionEndDate = new \DateTime($subscriptionInfo['endDate'] ?? 'now');
$hasSubscription = $subscriptionInfo !== null;
$isInvalidSubscription = $now > $subscriptionEndDate;
$allowedUsersCount = $subscriptionInfo['amountOfUsers'] ?? 0;
$onlyCountActiveUsers = $subscriptionInfo['onlyCountActiveUsers'] ?? false;
if ($allowedUsersCount === -1) {
$isOverLimit = false;
} elseif ($onlyCountActiveUsers) {
$isOverLimit = $allowedUsersCount < $activeUserCount;
} else {
$isOverLimit = $allowedUsersCount < $userCount;
}
$this->subscriptionInfoCache = [
$instanceSize,
$hasSubscription,
$isInvalidSubscription,
$isOverLimit,
$subscriptionInfo
];
return $this->subscriptionInfoCache;
}
public function getLastResponseSubscriptionInfo(): ?array {
$subscriptionInfo = $this->appConfig->getValueArray('support', 'last_response', lazy: true);
if (empty($subscriptionInfo)) {
return null;
}
return $subscriptionInfo;
}
public function checkSubscription(): void {
$hasInternetConnection = $this->config->getSystemValue('has_internet_connection', true);
if (!$hasInternetConnection) {
return;
}
if ($this->appConfig->getValueBool('support', 'disable_subscription_emails')) {
return;
}
[
$instanceSize,
$hasSubscription,
$isInvalidSubscription,
$isOverLimit,
$subscriptionInfo
] = $this->getSubscriptionInfo();
if ($hasSubscription && $isInvalidSubscription) {
$this->handleExpired(
$subscriptionInfo['accountManagerInfo']['name'] ?? '',
$subscriptionInfo['accountManagerInfo']['email'] ?? '',
$subscriptionInfo['accountManagerInfo']['phone'] ?? '');
} elseif ($hasSubscription && $isOverLimit) {
$this->handleOverLimit(
$subscriptionInfo['accountManagerInfo']['name'] ?? '',
$subscriptionInfo['accountManagerInfo']['email'] ?? '',
$subscriptionInfo['accountManagerInfo']['phone'] ?? '');
} elseif (!$hasSubscription && $instanceSize === 'large') {
$this->handleNoSubscription($instanceSize);
}
}
private function handleNoSubscription(string $instanceSize): void {
$currentTime = time();
$installTime = $this->appConfig->getValueInt('core', 'installedat', $currentTime);
// skip if installed within the last 30 days
if (($installTime + 30 * 24 * 3600) > $currentTime) {
return;
}
$lastNotificationTime = $this->appConfig->getValueInt('support', 'last_notification');
// skip if last notification was within the last 30 days
if (($lastNotificationTime + 30 * 24 * 3600) > $currentTime) {
return;
}
$updateLastNotificationTime = false;
$adminGroup = $this->groupManager->get('admin');
$adminUsers = $adminGroup->getUsers();
foreach ($adminUsers as $adminUser) {
$notification = $this->notifications->createNotification();
$notification->setApp('support')
->setObject('subscription', $instanceSize)
->setSubject('subscription_info')
->setUser($adminUser->getUID());
$count = $this->notifications->getCount($notification);
// skip if the user already has a notification
if ($count > 0) {
continue;
}
$notification->setDateTime(new \DateTime());
$notification->setLink($this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'support']));
$this->notifications->notify($notification);
$updateLastNotificationTime = true;
}
foreach ($adminUsers as $adminUser) {
$emailAddress = $adminUser->getEMailAddress();
if ($emailAddress === null || $emailAddress === '') {
continue;
}
$this->sendNoSubscriptionEmail($adminUser);
$updateLastNotificationTime = true;
}
if ($updateLastNotificationTime) {
$this->appConfig->setValueInt('support', 'last_notification', $currentTime);
}
}
private function handleOverLimit(string $accountManager, string $accountManagerEmail, string $accountManagerPhone): void {
$currentTime = time();
$lastNotificationTime = $this->appConfig->getValueInt('support', 'last_over_limit_notification');
// skip if last notification was within the last 5 days
if (($lastNotificationTime + 5 * 24 * 3600) > $currentTime) {
return;
}
$updateLastNotificationTime = false;
$adminGroup = $this->groupManager->get('admin');
$adminUsers = $adminGroup->getUsers();
foreach ($adminUsers as $adminUser) {
$notification = $this->notifications->createNotification();
$notification->setApp('support')
->setObject('subscription', 'over_limit')
->setSubject('subscription_over_limit')
->setUser($adminUser->getUID());
$count = $this->notifications->getCount($notification);
// skip if the user already has a notification
if ($count > 0) {
continue;
}
$notification->setDateTime(new \DateTime());
$notification->setLink($this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'support']));
$this->notifications->notify($notification);
$updateLastNotificationTime = true;
}
foreach ($adminUsers as $adminUser) {
$emailAddress = $adminUser->getEMailAddress();
if ($emailAddress === null || $emailAddress === '') {
continue;
}
$this->sendOverLimitEmail(
$adminUser,
$accountManager,
$accountManagerEmail,
$accountManagerPhone
);
$updateLastNotificationTime = true;
}
if ($updateLastNotificationTime) {
$this->appConfig->setValueInt('support', 'last_over_limit_notification', $currentTime);
}
}
private function handleExpired(string $accountManager, string $accountManagerEmail, string $accountManagerPhone): void {
$currentTime = time();
$lastNotificationTime = $this->appConfig->getValueInt('support', 'last_expired_notification');
// skip if last notification was within the last 5 days
if (($lastNotificationTime + 5 * 24 * 3600) > $currentTime) {
return;
}
$updateLastNotificationTime = false;
$adminGroup = $this->groupManager->get('admin');
$adminUsers = $adminGroup->getUsers();
foreach ($adminUsers as $adminUser) {
$notification = $this->notifications->createNotification();
$notification->setApp('support')
->setObject('subscription', 'expired')
->setSubject('subscription_expired')
->setUser($adminUser->getUID());
$count = $this->notifications->getCount($notification);
// skip if the user already has a notification
if ($count > 0) {
continue;
}
$notification->setDateTime(new \DateTime());
$notification->setLink($this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'support']));
$this->notifications->notify($notification);
$updateLastNotificationTime = true;
}
foreach ($adminUsers as $adminUser) {
$emailAddress = $adminUser->getEMailAddress();
if ($emailAddress === null || $emailAddress === '') {
continue;
}
$this->sendExpiredEmail(
$adminUser,
$accountManager,
$accountManagerEmail,
$accountManagerPhone
);
$updateLastNotificationTime = true;
}
if ($updateLastNotificationTime) {
$this->appConfig->setValueInt('support', 'last_expired_notification', $currentTime);
}
}
private function sendNoSubscriptionEmail(IUser $user): void {
// TODO what about enforced language?
$language = $this->config->getUserValue($user->getUID(), 'core', 'lang', 'en');
$l = $this->l10nFactory->get('support', $language);
$link = $this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'support']);
$message = $this->mailer->createMessage();
$emailTemplate = $this->mailer->createEMailTemplate('support.SubscriptionNotification', [
'displayName' => $user->getDisplayName(),
]);
$emailTemplate->setSubject($l->t('Your server has no Nextcloud Subscription'));
$emailTemplate->addHeader();
$emailTemplate->addHeading($l->t('Your Nextcloud server is not backed by a Nextcloud Enterprise Subscription.'));
$text = $l->t('A Nextcloud Enterprise Subscription means the original developers behind your self-hosted cloud server are 100%% dedicated to your success: the security, scalability, performance and functionality of your service!');
$listItem1 = $l->t('If your server setup breaks and employees can\'t work anymore, you don\'t have to rely on searching online forums for a solution. You have direct access to our experienced engineers!');
$listItem2 = $l->t('You have a contract with the vendor providing early security information, mitigations, patches and updates.');
$listItem3 = $l->t('If you need to stay longer on your current version without disruptions, you don\'t have to run software without security updates.');
$listItem4 = $l->t('You have the best expertise at hand to deal with performance and scalability issues.');
$listItem5 = $l->t('You have access to the right documentation and expertise to quickly answer compliance questions or deliver on GDPR, HIPAA and other regulation requirements.');
$text2 = $l->t('We can also provide Outlook integration, Online Office, scalable integrated audio-video and chat communication and other features only available in a limited form for free or develop further integrations and capabilities to your needs.');
$text3 = $l->t('A subscription helps you get the most out of Nextcloud!');
$emailTemplate->addBodyText(
htmlspecialchars($text),
$text
);
$emailTemplate->addBodyListItem(htmlspecialchars($listItem1), '', '', $listItem1);
$emailTemplate->addBodyListItem(htmlspecialchars($listItem2), '', '', $listItem2);
$emailTemplate->addBodyListItem(htmlspecialchars($listItem3), '', '', $listItem3);
$emailTemplate->addBodyListItem(htmlspecialchars($listItem4), '', '', $listItem4);
$emailTemplate->addBodyListItem(htmlspecialchars($listItem5), '', '', $listItem5);
$emailTemplate->addBodyText(
htmlspecialchars($text2) . '<br><br>' .
htmlspecialchars($text3),
$text2 . "\n\n" .
$text3
);
$emailTemplate->addBodyButton(
$l->t('Learn more now'),
$link
);
$generalLink = $this->urlGenerator->getAbsoluteURL('/');
$noteText = $l->t('This mail was sent to all administrators by the support app on your Nextcloud instance at %1$s because you have over %2$s registered users.', [$generalLink, self::THRESHOLD_LARGE]);
$emailTemplate->addBodyText($noteText);
$emailTemplate->addFooter();
$message->useTemplate($emailTemplate);
$attachment = $this->mailer->createAttachmentFromPath(__DIR__ . '/../../resources/Why the Nextcloud Subscription.pdf');
$message->attach($attachment);
$message->setTo([$user->getEMailAddress()]);
$this->mailer->send($message);
}
private function sendOverLimitEmail(IUser $user, string $accountManager, string $accountManagerEmail, string $accountManagerPhone): void {
// TODO what about enforced language?
$language = $this->config->getUserValue($user->getUID(), 'core', 'lang', 'en');
$l = $this->l10nFactory->get('support', $language);
$link = $this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'support']);
$message = $this->mailer->createMessage();
$emailTemplate = $this->mailer->createEMailTemplate('support.SubscriptionNotification', [
'displayName' => $user->getDisplayName(),
]);
$emailTemplate->setSubject($l->t('Your Nextcloud server Subscription is over limit'));
$emailTemplate->addHeader();
$emailTemplate->addHeading($l->t('Your Nextcloud server Subscription is over limit'));
$text = $l->t('Dear admin,');
$text2 = $l->t('Your Nextcloud Subscription doesn\'t cover the number of users who are currently active on this server. Please contact your Nextcloud account manager to get your subscription updated!');
$text3 = $l->t('%1$s is your account manager and can be reached by email via %2$s or by phone via %3$s.', [$accountManager, $accountManagerEmail, $accountManagerPhone]);
$text4 = $l->t('Thank you,');
$text5 = $l->t('Your Nextcloud team');
$emailTemplate->addBodyText(
htmlspecialchars($text) . '<br><br>' .
htmlspecialchars($text2) . '<br><br>' .
htmlspecialchars($text3) . '<br><br>' .
htmlspecialchars($text4) . '<br><br>' .
htmlspecialchars($text5),
$text . "\n\n" .
$text2 . "\n\n" .
$text3 . "\n\n" .
$text4 . "\n\n" .
$text5
);
$emailTemplate->addBodyButton(
$l->t('Learn more now'),
$link
);
$generalLink = $this->urlGenerator->getAbsoluteURL('/');
$noteText = $l->t('This mail was sent to all administrators by the support app on your Nextcloud instance at %s because you have more users than your subscription covers.', [$generalLink]);
$emailTemplate->addBodyText($noteText);
$message->setTo([$user->getEMailAddress()]);
$emailTemplate->addFooter();
$message->useTemplate($emailTemplate);
$this->mailer->send($message);
}
private function sendExpiredEmail(IUser $user, string $accountManager, string $accountManagerEmail, string $accountManagerPhone): void {
// TODO what about enforced language?
$language = $this->config->getUserValue($user->getUID(), 'core', 'lang', 'en');
$l = $this->l10nFactory->get('support', $language);
$link = $this->urlGenerator->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'support']);
$message = $this->mailer->createMessage();
$emailTemplate = $this->mailer->createEMailTemplate('support.SubscriptionNotification', [
'displayName' => $user->getDisplayName(),
]);
$emailTemplate->setSubject($l->t('Your Nextcloud server Subscription is expired'));
$emailTemplate->addHeader();
$emailTemplate->addHeading($l->t('Your Nextcloud server Subscription is expired!'));
$text = $l->t('Dear admin,');
$text2 = $l->t('Your Nextcloud Subscription has expired! Please contact your Nextcloud account manager to get your subscription updated!');
$text3 = $l->t('%1$s is your account manager and can be reached by email via %2$s or by phone via %3$s.', [$accountManager, $accountManagerEmail, $accountManagerPhone]);
$text4 = $l->t('Thank you,');
$text5 = $l->t('Your Nextcloud team');
$emailTemplate->addBodyText(
htmlspecialchars($text) . '<br><br>' .
htmlspecialchars($text2) . '<br><br>' .
htmlspecialchars($text3) . '<br><br>' .
htmlspecialchars($text4) . '<br><br>' .
htmlspecialchars($text5),
$text . "\n\n" .
$text2 . "\n\n" .
$text3 . "\n\n" .
$text4 . "\n\n" .
$text5
);
$emailTemplate->addBodyButton(
$l->t('Learn more now'),
$link
);
$generalLink = $this->urlGenerator->getAbsoluteURL('/');
$noteText = $l->t('This mail was sent to all administrators by the support app on your Nextcloud instance at %s because your subscription expired.', [$generalLink]);
$emailTemplate->addBodyText($noteText);
$message->setTo([$user->getEMailAddress()]);
$emailTemplate->addFooter();
$message->useTemplate($emailTemplate);
$this->mailer->send($message);
}
/**
* return details about installed apps
*
* [
* appId => [
* 'enabled' => string,
* 'version' => string
* ]
* ]
*
* 'enabled' can be:
* 'disabled', if app is disabled
* 'enabled', if app is enabled
* 'group-limited', if app is limited to groups
* 'invalid', if stored value does not fit previous condition
*
* @return array<string, array<string, string>>
*/
private function getAppsDetails(): array {
/** @var array<string, string> */
$enabled = $this->appConfig->searchValues('enabled', false, IAppConfig::VALUE_STRING);
/** @var array<string, string> */
$installed = $this->appConfig->searchValues('installed_version', false, IAppConfig::VALUE_STRING);
/** @var array<string, array<string, string>> $details */
$details = [];
foreach ($enabled as $appId => $enabledStatus) {
$enabledFlag = 'invalid';
try {
$enabledFlag = match ($enabledStatus) {
'no' => 'disabled',
'yes' => 'enabled',
default => (is_array(json_decode($enabledStatus, flags: JSON_THROW_ON_ERROR))) ? 'group-limited' : $enabledFlag
};
} catch (\JsonException) {
}
$details[$appId] = [
'enabled' => $enabledFlag,
'version' => $installed[$appId] ?? 'missing',
];
}
return $details;
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Settings;
use OCA\Support\Service\SubscriptionService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\ServerVersion;
use OCP\Settings\IDelegatedSettings;
class Admin implements IDelegatedSettings {
public function __construct(
protected readonly IConfig $config,
protected readonly IAppConfig $appConfig,
protected readonly IUserManager $userManager,
protected readonly IURLGenerator $urlGenerator,
protected readonly SubscriptionService $subscriptionService,
protected readonly ServerVersion $serverVersion,
) {
}
#[\Override]
public function getForm(): TemplateResponse {
$userCount = $this->subscriptionService->getUserCount();
$activeUserCount = $this->userManager->countSeenUsers();
$instanceSize = 'small';
if ($userCount > SubscriptionService::THRESHOLD_MEDIUM) {
if ($userCount > SubscriptionService::THRESHOLD_LARGE) {
$instanceSize = 'large';
} else {
$instanceSize = 'medium';
}
}
$subscriptionKey = $this->appConfig->getValueString('support', 'subscription_key');
$potentialSubscriptionKey = $this->appConfig->getValueString('support', 'potential_subscription_key');
$subscriptionInfo = $this->appConfig->getValueArray('support', 'last_response', lazy: true);
$lastError = $this->appConfig->getValueInt('support', 'last_error');
// delete the invalid error, because there is no renewal happening
if ($lastError === SubscriptionService::ERROR_FAILED_INVALID) {
if ($subscriptionKey !== '') {
$this->appConfig->setValueString('support', 'potential_subscription_key', $subscriptionKey);
} else {
$this->appConfig->deleteKey('support', 'potential_subscription_key');
}
$this->appConfig->deleteKey('support', 'last_error');
} elseif ($lastError === SubscriptionService::ERROR_INVALID_SUBSCRIPTION_KEY) {
$this->appConfig->deleteKey('support', 'last_error');
}
$now = new \DateTime();
$subscriptionEndDate = new \DateTime($subscriptionInfo['endDate'] ?? 'now');
if ($now > $subscriptionEndDate) {
$years = 0;
$months = 0;
$days = 0;
$weeks = 0;
} else {
$diff = $now->diff($subscriptionEndDate);
$years = (int)$diff->format('%y');
$months = (int)$diff->format('%m');
$days = (int)$diff->format('%d');
$weeks = floor($days / 7);
/* run up to the next month for 4 weeks and more */
if ($weeks > 3) {
$months += 1;
$weeks = 0;
$days = 0;
}
}
$specificSubscriptions = [];
$collaboraEndDate = new \DateTime($subscriptionInfo['collabora']['endDate'] ?? 'yesterday');
if ($now < $collaboraEndDate) {
$specificSubscriptions[] = 'Collabora';
}
$talkEndDate = new \DateTime($subscriptionInfo['talk']['endDate'] ?? 'yesterday');
if ($now < $talkEndDate) {
$specificSubscriptions[] = 'Talk';
}
$groupwareEndDate = new \DateTime($subscriptionInfo['groupware']['endDate'] ?? 'yesterday');
if ($now < $groupwareEndDate) {
$specificSubscriptions[] = 'Groupware';
}
$allowedUsersCount = $subscriptionInfo['amountOfUsers'] ?? 0;
$onlyCountActiveUsers = $subscriptionInfo['onlyCountActiveUsers'] ?? false;
if ($allowedUsersCount === -1) {
$isOverLimit = false;
} elseif ($onlyCountActiveUsers) {
$isOverLimit = $allowedUsersCount < $activeUserCount;
} else {
$isOverLimit = $allowedUsersCount < $userCount;
}
if (isset($subscriptionInfo['partnerContact']) && count($subscriptionInfo['partnerContact']) > 0) {
$contactInfo = $subscriptionInfo['partnerContact'];
} else {
$contactInfo = $subscriptionInfo['accountManagerInfo'] ?? '';
}
$params = [
'instanceSize' => $instanceSize,
'userCount' => $userCount,
'activeUserCount' => $activeUserCount,
'subscriptionKey' => $subscriptionKey,
'potentialSubscriptionKey' => $potentialSubscriptionKey,
'lastError' => $lastError,
'contactPerson' => $contactInfo,
'subscriptionType' => $subscriptionInfo['level'] ?? '',
'subscriptionUsers' => $allowedUsersCount,
'onlyCountActiveUsers' => $onlyCountActiveUsers,
'specificSubscriptions' => $specificSubscriptions,
'extendedSupport' => $subscriptionInfo['extendedSupport'] ?? false,
'expiryYears' => $years,
'expiryMonths' => $months,
'expiryWeeks' => $weeks,
'expiryDays' => $days,
'validSubscription' => ($years + $months + $days) > 0,
'overLimit' => $isOverLimit,
'showSubscriptionDetails' => !empty($subscriptionInfo),
'showSubscriptionKeyInput' => empty($subscriptionInfo),
'showCommunitySupportSection' => $instanceSize === 'small' && empty($subscriptionInfo),
'showEnterpriseSupportSection' => $instanceSize !== 'small' && empty($subscriptionInfo),
'subscriptionKeyUrl' => $this->urlGenerator->linkToRoute('support.api.setSubscriptionKey'),
'offlineActivationData' => [
'subscriptionKey' => $potentialSubscriptionKey,
'instanceId' => $this->config->getSystemValueString('instanceid', ''),
'userCount' => $userCount,
'activeUserCount' => $activeUserCount,
'version' => implode('.', $this->serverVersion->getVersion())
],
'subscriptionEndDate' => $subscriptionEndDate->format('Y-m-d'),
];
return new TemplateResponse('support', 'admin', $params);
}
#[\Override]
public function getSection(): string {
return 'support';
}
/**
* @return int whether the form should be rather on the top or bottom of
* the admin section. The forms are arranged in ascending order of the
* priority values. It is required to return a value between 0 and 100.
*
* keep the server setting at the top, right after "server settings"
*/
#[\Override]
public function getPriority(): int {
return 0;
}
#[\Override]
public function getName(): ?string {
return null; // Only one setting in this section
}
#[\Override]
public function getAuthorizedAppConfig(): array {
return [
'support' => ['.*'],
];
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Settings;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class Section implements IIconSection {
public function __construct(
protected readonly IL10N $l,
protected readonly IURLGenerator $url,
) {
}
#[\Override]
public function getID(): string {
return 'support';
}
#[\Override]
public function getName(): string {
return $this->l->t('Support');
}
#[\Override]
public function getPriority(): int {
return 1;
}
#[\Override]
public function getIcon(): string {
return $this->url->imagePath('support', 'section.svg');
}
}
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Support\Subscription;
use OCA\Support\Service\SubscriptionService;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IConfig;
use OCP\ServerVersion;
use OCP\Support\Subscription\ISubscription;
use OCP\Support\Subscription\ISupportedApps;
class SubscriptionAdapter implements ISubscription, ISupportedApps {
public function __construct(
private readonly SubscriptionService $subscriptionService,
private readonly IConfig $config,
private readonly IAppConfig $appConfig,
private readonly ITimeFactory $timeFactory,
private readonly ServerVersion $serverVersion,
) {
}
/**
* Indicates if a valid subscription is available
*/
#[\Override]
public function hasValidSubscription(): bool {
try {
$endDate = $this->appConfig->getAppValueString('end_date');
} catch (\Throwable) {
return false;
}
return $this->subscriptionNotExpired($endDate);
}
private function subscriptionNotExpired(string $endDate): bool {
if ($endDate === '' || $endDate === 'now') {
return false;
}
$subscriptionEndDate = $this->timeFactory->getDateTime($endDate);
$now = $this->timeFactory->getDateTime();
return $now < $subscriptionEndDate;
}
/**
* Fetches the list of app IDs that are supported by the subscription
*
* @since 17.0.0
*/
#[\Override]
public function getSupportedApps(): array {
[
$instanceSize,
$hasSubscription,
$isInvalidSubscription,
$isOverLimit,
$subscriptionInfo
] = $this->subscriptionService->getSubscriptionInfo();
$hasValidGroupwareSubscription = $this->subscriptionNotExpired($subscriptionInfo['groupware']['endDate'] ?? 'now');
$hasValidTalkSubscription = $this->subscriptionNotExpired($subscriptionInfo['talk']['endDate'] ?? 'now');
$hasValidCollaboraSubscription = $this->subscriptionNotExpired($subscriptionInfo['collabora']['endDate'] ?? 'now');
$hasValidOnlyOfficeSubscription = $this->subscriptionNotExpired($subscriptionInfo['onlyoffice']['endDate'] ?? 'now');
$filesSubscription = [
'activity',
'admin_audit',
'bruteforcesettings',
'circles',
'cloud_federation_api',
'comments',
'data_request',
'dav',
'encryption',
'external',
'federatedfilesharing',
'federation',
'files',
'files_accesscontrol',
'files_antivirus',
'files_automatedtagging',
'files_external',
'files_fulltextsearch',
'files_fulltextsearch_tesseract',
'files_pdfviewer',
'files_retention',
'files_sharing',
'files_trashbin',
'files_versions',
'firstrunwizard',
'fulltextsearch',
'fulltextsearch_elasticsearch',
'groupfolders',
'guests',
'logreader',
'lookup_server_connector',
'nextcloud_announcements',
'notifications',
'oauth2',
'password_policy',
'photos',
'privacy',
'provisioning_api',
'recommendations',
'serverinfo',
'settings',
'sharebymail',
'sharepoint',
'socialsharing_diaspora',
'socialsharing_email',
'socialsharing_facebook',
'socialsharing_twitter',
'support',
'survey_client',
'suspicious_login',
'systemtags',
'terms_of_service',
'text',
'theming',
'twofactor_backupcodes',
'twofactor_totp',
'updatenotification',
'user_ldap',
'user_oidc',
'user_saml',
'viewer',
'workflowengine',
'workflow_script',
];
$nextcloudVersion = $this->serverVersion->getMajorVersion();
if ($nextcloudVersion >= 30) {
$filesSubscription[] = 'app_api';
$filesSubscription[] = 'twofactor_nextcloud_notification';
if (($subscriptionInfo['level'] ?? 'none') === 'ultimate') {
$filesSubscription[] = 'webhook_listeners';
}
}
if ($nextcloudVersion >= 29) {
$filesSubscription[] = 'files_downloadlimit';
$filesSubscription[] = 'files_reminders';
}
if ($nextcloudVersion >= 28) {
$filesSubscription[] = 'files_reminders';
$filesSubscription[] = 'security_guard';
} else {
// Removed in 28
$filesSubscription[] = 'files_rightclick';
}
if ($nextcloudVersion >= 26) {
$filesSubscription[] = 'files_confidential';
}
if ($nextcloudVersion >= 25) {
$filesSubscription[] = 'related_resources';
} else {
// Removed in 25
$filesSubscription[] = 'files_videoplayer';
}
if ($nextcloudVersion >= 24) {
$filesSubscription[] = 'files_lock';
}
if ($nextcloudVersion >= 22) {
$filesSubscription[] = 'approval';
$filesSubscription[] = 'contacts';
$filesSubscription[] = 'files_zip';
}
if ($nextcloudVersion >= 20) {
$filesSubscription[] = 'dashboard';
$filesSubscription[] = 'flow_notifications';
$filesSubscription[] = 'user_status';
$filesSubscription[] = 'weather_status';
}
if ($nextcloudVersion >= 19) {
$filesSubscription[] = 'contactsinteraction';
}
if ($nextcloudVersion >= 18) {
$filesSubscription[] = 'globalsiteselector';
}
$supportedApps = [];
if ($hasSubscription) {
$supportedApps = array_merge($supportedApps, $filesSubscription);
}
if ($hasValidGroupwareSubscription) {
$supportedApps[] = 'calendar';
$supportedApps[] = 'contacts';
$supportedApps[] = 'deck';
$supportedApps[] = 'mail';
}
if ($hasValidTalkSubscription) {
$supportedApps[] = 'spreed';
}
if ($hasValidCollaboraSubscription) {
$supportedApps[] = 'richdocuments';
}
if ($hasValidOnlyOfficeSubscription) {
$supportedApps[] = 'onlyoffice';
}
if (isset($subscriptionInfo['supportedApps'])) {
foreach ($subscriptionInfo['supportedApps'] as $app) {
if ($app !== '' && !in_array($app, $supportedApps)) {
$supportedApps[] = $app;
}
}
}
return $supportedApps;
}
/**
* Indicates if the subscription has extended support
*
* @since 17.0.0
*/
#[\Override]
public function hasExtendedSupport(): bool {
try {
return $this->appConfig->getAppValueBool('extended_support');
} catch (\Throwable) {
return false;
}
}
/**
* Indicates if a hard user limit is reached and no new users should be created
*
* @since 21.0.0
*/
#[\Override]
public function isHardUserLimitReached(): bool {
[
,,
$isInvalidSubscription,
$isOverLimit,
$subscriptionInfo
] = $this->subscriptionService->getSubscriptionInfo();
$configUserLimit = (int)$this->config->getAppValue('support', 'user-limit', '0');
if (
!$isInvalidSubscription
&& $configUserLimit > 0
&& $configUserLimit <= $this->subscriptionService->getUserCount()
) {
return true;
}
if (!isset($subscriptionInfo['hasHardUserLimit']) || $subscriptionInfo['hasHardUserLimit'] === false) {
return false;
}
return $isOverLimit;
}
}