aboutsummaryrefslogtreecommitdiffstats
path: root/apps/updatenotification/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/updatenotification/lib')
-rw-r--r--apps/updatenotification/lib/AppInfo/Application.php82
-rw-r--r--apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php109
-rw-r--r--apps/updatenotification/lib/BackgroundJob/ResetToken.php49
-rw-r--r--apps/updatenotification/lib/BackgroundJob/UpdateAvailableNotifications.php244
-rw-r--r--apps/updatenotification/lib/Command/Check.php67
-rw-r--r--apps/updatenotification/lib/Controller/APIController.php197
-rw-r--r--apps/updatenotification/lib/Controller/AdminController.php72
-rw-r--r--apps/updatenotification/lib/Controller/ChangelogController.php62
-rw-r--r--apps/updatenotification/lib/Listener/AppUpdateEventListener.php61
-rw-r--r--apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php54
-rw-r--r--apps/updatenotification/lib/Manager.php114
-rw-r--r--apps/updatenotification/lib/Notification/AppUpdateNotifier.php104
-rw-r--r--apps/updatenotification/lib/Notification/Notifier.php165
-rw-r--r--apps/updatenotification/lib/ResponseDefinitions.php19
-rw-r--r--apps/updatenotification/lib/Settings/Admin.php151
-rw-r--r--apps/updatenotification/lib/UpdateChecker.php72
-rw-r--r--apps/updatenotification/lib/resettokenbackgroundjob.php76
-rw-r--r--apps/updatenotification/lib/updatechecker.php67
18 files changed, 1622 insertions, 143 deletions
diff --git a/apps/updatenotification/lib/AppInfo/Application.php b/apps/updatenotification/lib/AppInfo/Application.php
new file mode 100644
index 00000000000..2a1678da5db
--- /dev/null
+++ b/apps/updatenotification/lib/AppInfo/Application.php
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UpdateNotification\AppInfo;
+
+use OCA\UpdateNotification\Listener\AppUpdateEventListener;
+use OCA\UpdateNotification\Listener\BeforeTemplateRenderedEventListener;
+use OCA\UpdateNotification\Notification\AppUpdateNotifier;
+use OCA\UpdateNotification\Notification\Notifier;
+use OCA\UpdateNotification\UpdateChecker;
+use OCP\App\Events\AppUpdateEvent;
+use OCP\App\IAppManager;
+use OCP\AppFramework\App;
+use OCP\AppFramework\Bootstrap\IBootContext;
+use OCP\AppFramework\Bootstrap\IBootstrap;
+use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\Util;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+class Application extends App implements IBootstrap {
+ public const APP_NAME = 'updatenotification';
+
+ public function __construct() {
+ parent::__construct(self::APP_NAME, []);
+ }
+
+ public function register(IRegistrationContext $context): void {
+ $context->registerNotifierService(Notifier::class);
+ $context->registerNotifierService(AppUpdateNotifier::class);
+
+ $context->registerEventListener(AppUpdateEvent::class, AppUpdateEventListener::class);
+ $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedEventListener::class);
+ }
+
+ public function boot(IBootContext $context): void {
+ $context->injectFn(function (IConfig $config,
+ IUserSession $userSession,
+ IAppManager $appManager,
+ IGroupManager $groupManager,
+ ContainerInterface $container,
+ LoggerInterface $logger,
+ ): void {
+ if ($config->getSystemValue('updatechecker', true) !== true) {
+ // Updater check is disabled
+ return;
+ }
+
+ $user = $userSession->getUser();
+ if (!$user instanceof IUser) {
+ // Nothing to do for guests
+ return;
+ }
+
+ if (!$appManager->isEnabledForUser('notifications')
+ && $groupManager->isAdmin($user->getUID())) {
+ try {
+ $updateChecker = $container->get(UpdateChecker::class);
+ } catch (ContainerExceptionInterface $e) {
+ $logger->error($e->getMessage(), ['exception' => $e]);
+ return;
+ }
+
+ if ($updateChecker->getUpdateState() !== []) {
+ Util::addScript(self::APP_NAME, 'update-notification-legacy');
+ $updateChecker->setInitialState();
+ }
+ }
+ });
+ }
+}
diff --git a/apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php b/apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php
new file mode 100644
index 00000000000..049390546ed
--- /dev/null
+++ b/apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UpdateNotification\BackgroundJob;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCA\UpdateNotification\Manager;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Services\IAppConfig;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\QueuedJob;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Notification\IManager;
+use OCP\Notification\INotification;
+use Psr\Log\LoggerInterface;
+
+class AppUpdatedNotifications extends QueuedJob {
+ public function __construct(
+ ITimeFactory $time,
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ private IManager $notificationManager,
+ private IUserManager $userManager,
+ private IAppManager $appManager,
+ private LoggerInterface $logger,
+ private Manager $manager,
+ ) {
+ parent::__construct($time);
+ }
+
+ /**
+ * @param array{appId: string, timestamp: int} $argument
+ */
+ protected function run(mixed $argument): void {
+ $appId = $argument['appId'];
+ $timestamp = $argument['timestamp'];
+ $dateTime = $this->time->getDateTime();
+ $dateTime->setTimestamp($timestamp);
+
+ $this->logger->debug(
+ 'Running background job to create app update notifications for "' . $appId . '"',
+ [
+ 'app' => Application::APP_NAME,
+ ],
+ );
+
+ if ($this->manager->getChangelogFile($appId, 'en') === null) {
+ $this->logger->debug('Skipping app updated notification - no changelog provided');
+ return;
+ }
+
+ $this->stopPreviousNotifications($appId);
+
+ // Create new notifications
+ $notification = $this->notificationManager->createNotification();
+ $notification->setApp(Application::APP_NAME)
+ ->setDateTime($dateTime)
+ ->setSubject('app_updated', [$appId])
+ ->setObject('app_updated', $appId);
+
+ $this->notifyUsers($appId, $notification);
+ }
+
+ /**
+ * Stop all previous notifications users might not have dismissed until now
+ * @param string $appId The app to stop update notifications for
+ */
+ private function stopPreviousNotifications(string $appId): void {
+ $notification = $this->notificationManager->createNotification();
+ $notification->setApp(Application::APP_NAME)
+ ->setObject('app_updated', $appId);
+ $this->notificationManager->markProcessed($notification);
+ }
+
+ /**
+ * Notify all users for which the updated app is enabled
+ */
+ private function notifyUsers(string $appId, INotification $notification): void {
+ $guestsEnabled = $this->appConfig->getAppValueBool('app_updated.notify_guests', false) && class_exists('\OCA\Guests\UserBackend');
+
+ $isDefer = $this->notificationManager->defer();
+
+ // Notify all seen users about the app update
+ $this->userManager->callForSeenUsers(function (IUser $user) use ($guestsEnabled, $appId, $notification): void {
+ if (!$guestsEnabled && ($user->getBackendClassName() === '\OCA\Guests\UserBackend')) {
+ return;
+ }
+
+ if (!$this->appManager->isEnabledForUser($appId, $user)) {
+ return;
+ }
+
+ $notification->setUser($user->getUID());
+ $this->notificationManager->notify($notification);
+ });
+
+ // If we enabled the defer we call the flush
+ if ($isDefer) {
+ $this->notificationManager->flush();
+ }
+ }
+}
diff --git a/apps/updatenotification/lib/BackgroundJob/ResetToken.php b/apps/updatenotification/lib/BackgroundJob/ResetToken.php
new file mode 100644
index 00000000000..35543ce5247
--- /dev/null
+++ b/apps/updatenotification/lib/BackgroundJob/ResetToken.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\UpdateNotification\BackgroundJob;
+
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\IAppConfig;
+use OCP\IConfig;
+
+/**
+ * Deletes the updater secret after if it is older than 48h
+ */
+class ResetToken extends TimedJob {
+
+ /**
+ * @param IConfig $config
+ * @param ITimeFactory $timeFactory
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ ) {
+ parent::__construct($time);
+ // Run all 10 minutes
+ parent::setInterval(60 * 10);
+ }
+
+ /**
+ * @param $argument
+ */
+ protected function run($argument) {
+ if ($this->config->getSystemValueBool('config_is_read_only')) {
+ return;
+ }
+
+ $secretCreated = $this->appConfig->getValueInt('core', 'updater.secret.created', $this->time->getTime());
+ // Delete old tokens after 2 days
+ if ($secretCreated >= 172800) {
+ $this->config->deleteSystemValue('updater.secret');
+ }
+ }
+}
diff --git a/apps/updatenotification/lib/BackgroundJob/UpdateAvailableNotifications.php b/apps/updatenotification/lib/BackgroundJob/UpdateAvailableNotifications.php
new file mode 100644
index 00000000000..8879bb0c223
--- /dev/null
+++ b/apps/updatenotification/lib/BackgroundJob/UpdateAvailableNotifications.php
@@ -0,0 +1,244 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\UpdateNotification\BackgroundJob;
+
+use OC\Installer;
+use OC\Updater\VersionCheck;
+use OCA\UpdateNotification\AppInfo\Application;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Services\IAppConfig;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\IConfig;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\Notification\IManager;
+use OCP\ServerVersion;
+
+class UpdateAvailableNotifications extends TimedJob {
+
+ /**
+ * Numbers of failed updater connection to report error as notification.
+ * @var list<int>
+ */
+ protected const CONNECTION_NOTIFICATIONS = [3, 7, 14, 30];
+
+ /** @var ?string[] */
+ protected $users = null;
+
+ public function __construct(
+ ITimeFactory $timeFactory,
+ protected ServerVersion $serverVersion,
+ protected IConfig $config,
+ protected IAppConfig $appConfig,
+ protected IManager $notificationManager,
+ protected IGroupManager $groupManager,
+ protected IAppManager $appManager,
+ protected Installer $installer,
+ protected VersionCheck $versionCheck,
+ ) {
+ parent::__construct($timeFactory);
+ // Run once a day
+ $this->setInterval(60 * 60 * 24);
+ $this->setTimeSensitivity(self::TIME_INSENSITIVE);
+ }
+
+ protected function run($argument) {
+ // Do not check for updates if not connected to the internet
+ if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
+ return;
+ }
+
+ if (\OC::$CLI && !$this->config->getSystemValueBool('debug', false)) {
+ try {
+ // Jitter the pinging of the updater server and the appstore a bit.
+ // Otherwise all Nextcloud installations are pinging the servers
+ // in one of 288
+ sleep(random_int(1, 180));
+ } catch (\Exception $e) {
+ }
+ }
+
+ $this->checkCoreUpdate();
+ $this->checkAppUpdates();
+ }
+
+ /**
+ * Check for Nextcloud server update
+ */
+ protected function checkCoreUpdate(): void {
+ if (!$this->config->getSystemValueBool('updatechecker', true)) {
+ // update checker is disabled so no core update check!
+ return;
+ }
+
+ if (\in_array($this->serverVersion->getChannel(), ['daily', 'git'], true)) {
+ // "These aren't the update channels you're looking for." - Ben Obi-Wan Kenobi
+ return;
+ }
+
+ $status = $this->versionCheck->check();
+ if ($status === false) {
+ $errors = 1 + $this->appConfig->getAppValueInt('update_check_errors', 0);
+ $this->appConfig->setAppValueInt('update_check_errors', $errors);
+
+ if (\in_array($errors, self::CONNECTION_NOTIFICATIONS, true)) {
+ $this->sendErrorNotifications($errors);
+ }
+ } elseif (\is_array($status)) {
+ $this->appConfig->setAppValueInt('update_check_errors', 0);
+ $this->clearErrorNotifications();
+
+ if (isset($status['version'])) {
+ $this->createNotifications('core', $status['version'], $status['versionstring']);
+ }
+ }
+ }
+
+ /**
+ * Send a message to the admin when the update server could not be reached
+ * @param int $numDays
+ */
+ protected function sendErrorNotifications($numDays): void {
+ $this->clearErrorNotifications();
+
+ $notification = $this->notificationManager->createNotification();
+ try {
+ $notification->setApp(Application::APP_NAME)
+ ->setDateTime(new \DateTime())
+ ->setObject(Application::APP_NAME, 'error')
+ ->setSubject('connection_error', ['days' => $numDays]);
+
+ foreach ($this->getUsersToNotify() as $uid) {
+ $notification->setUser($uid);
+ $this->notificationManager->notify($notification);
+ }
+ } catch (\InvalidArgumentException $e) {
+ return;
+ }
+ }
+
+ /**
+ * Remove error notifications again
+ */
+ protected function clearErrorNotifications(): void {
+ $notification = $this->notificationManager->createNotification();
+ try {
+ $notification->setApp(Application::APP_NAME)
+ ->setSubject('connection_error')
+ ->setObject(Application::APP_NAME, 'error');
+ } catch (\InvalidArgumentException $e) {
+ return;
+ }
+ $this->notificationManager->markProcessed($notification);
+ }
+
+ /**
+ * Check all installed apps for updates
+ */
+ protected function checkAppUpdates(): void {
+ $apps = $this->appManager->getEnabledApps();
+ foreach ($apps as $app) {
+ $update = $this->isUpdateAvailable($app);
+ if ($update !== false) {
+ $this->createNotifications($app, $update);
+ }
+ }
+ }
+
+ /**
+ * Create notifications for this app version
+ *
+ * @param string $app
+ * @param string $version
+ * @param string $visibleVersion
+ */
+ protected function createNotifications($app, $version, $visibleVersion = ''): void {
+ $lastNotification = $this->appConfig->getAppValueString($app, '');
+ if ($lastNotification === $version) {
+ // We already notified about this update
+ return;
+ }
+
+ if ($lastNotification !== '') {
+ // Delete old updates
+ $this->deleteOutdatedNotifications($app, $lastNotification);
+ }
+
+ $notification = $this->notificationManager->createNotification();
+ try {
+ $notification->setApp(Application::APP_NAME)
+ ->setDateTime(new \DateTime())
+ ->setObject($app, $version);
+
+ if ($visibleVersion !== '') {
+ $notification->setSubject('update_available', ['version' => $visibleVersion]);
+ } else {
+ $notification->setSubject('update_available');
+ }
+
+ foreach ($this->getUsersToNotify() as $uid) {
+ $notification->setUser($uid);
+ $this->notificationManager->notify($notification);
+ }
+ } catch (\InvalidArgumentException $e) {
+ return;
+ }
+
+ $this->appConfig->setAppValueString($app, $version);
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getUsersToNotify(): array {
+ if ($this->users !== null) {
+ return $this->users;
+ }
+
+ $notifyGroups = $this->appConfig->getAppValueArray('notify_groups', ['admin']);
+ $this->users = [];
+ foreach ($notifyGroups as $group) {
+ $groupToNotify = $this->groupManager->get($group);
+ if ($groupToNotify instanceof IGroup) {
+ foreach ($groupToNotify->getUsers() as $user) {
+ $this->users[] = $user->getUID();
+ }
+ }
+ }
+
+ $this->users = array_values(array_unique($this->users));
+ return $this->users;
+ }
+
+ /**
+ * Delete notifications for old updates
+ *
+ * @param string $app
+ * @param string $version
+ */
+ protected function deleteOutdatedNotifications($app, $version): void {
+ $notification = $this->notificationManager->createNotification();
+ try {
+ $notification->setApp(Application::APP_NAME)
+ ->setObject($app, $version);
+ } catch (\InvalidArgumentException) {
+ return;
+ }
+ $this->notificationManager->markProcessed($notification);
+ }
+
+ /**
+ * @param string $app
+ * @return string|false
+ */
+ protected function isUpdateAvailable($app) {
+ return $this->installer->isUpdateAvailable($app);
+ }
+}
diff --git a/apps/updatenotification/lib/Command/Check.php b/apps/updatenotification/lib/Command/Check.php
new file mode 100644
index 00000000000..d93e4935012
--- /dev/null
+++ b/apps/updatenotification/lib/Command/Check.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UpdateNotification\Command;
+
+use OC\App\AppManager;
+use OC\Installer;
+use OCA\UpdateNotification\UpdateChecker;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class Check extends Command {
+
+ public function __construct(
+ private AppManager $appManager,
+ private UpdateChecker $updateChecker,
+ private Installer $installer,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void {
+ $this
+ ->setName('update:check')
+ ->setDescription('Check for server and app updates')
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $updatesAvailableCount = 0;
+
+ // Server
+ $r = $this->updateChecker->getUpdateState();
+ if (isset($r['updateAvailable']) && $r['updateAvailable']) {
+ $output->writeln($r['updateVersionString'] . ' is available. Get more information on how to update at ' . $r['updateLink'] . '.');
+ $updatesAvailableCount += 1;
+ }
+
+
+ // Apps
+ $apps = $this->appManager->getEnabledApps();
+ foreach ($apps as $app) {
+ $update = $this->installer->isUpdateAvailable($app);
+ if ($update !== false) {
+ $output->writeln('Update for ' . $app . ' to version ' . $update . ' is available.');
+ $updatesAvailableCount += 1;
+ }
+ }
+
+ // Report summary
+ if ($updatesAvailableCount === 0) {
+ $output->writeln('<info>Everything up to date</info>');
+ } elseif ($updatesAvailableCount === 1) {
+ $output->writeln('<comment>1 update available</comment>');
+ } else {
+ $output->writeln('<comment>' . $updatesAvailableCount . ' updates available</comment>');
+ }
+
+ return 0;
+ }
+}
diff --git a/apps/updatenotification/lib/Controller/APIController.php b/apps/updatenotification/lib/Controller/APIController.php
new file mode 100644
index 00000000000..4360d814dd2
--- /dev/null
+++ b/apps/updatenotification/lib/Controller/APIController.php
@@ -0,0 +1,197 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UpdateNotification\Controller;
+
+use OC\App\AppStore\Fetcher\AppFetcher;
+use OCA\UpdateNotification\Manager;
+use OCA\UpdateNotification\ResponseDefinitions;
+use OCP\App\AppPathNotFoundException;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\IConfig;
+use OCP\IRequest;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+
+/**
+ * @psalm-import-type UpdateNotificationApp from ResponseDefinitions
+ */
+class APIController extends OCSController {
+
+ protected ?string $language = null;
+
+ /**
+ * List of apps that were in the appstore but are now shipped and don't have
+ * a compatible update available.
+ *
+ * @var array<string, int>
+ */
+ protected array $appsShippedInFutureVersion = [
+ 'bruteforcesettings' => 25,
+ 'suspicious_login' => 25,
+ 'twofactor_totp' => 25,
+ 'files_downloadlimit' => 29,
+ 'twofactor_nextcloud_notification' => 30,
+ 'app_api' => 30,
+ ];
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ protected IConfig $config,
+ protected IAppManager $appManager,
+ protected AppFetcher $appFetcher,
+ protected IFactory $l10nFactory,
+ protected IUserSession $userSession,
+ protected Manager $manager,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * List available updates for apps
+ *
+ * @param string $newVersion Server version to check updates for
+ *
+ * @return DataResponse<Http::STATUS_OK, array{missing: list<UpdateNotificationApp>, available: list<UpdateNotificationApp>}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{appstore_disabled: bool, already_on_latest?: bool}, array{}>
+ *
+ * 200: Apps returned
+ * 404: New versions not found
+ */
+ public function getAppList(string $newVersion): DataResponse {
+ if (!$this->config->getSystemValue('appstoreenabled', true)) {
+ return new DataResponse([
+ 'appstore_disabled' => true,
+ ], Http::STATUS_NOT_FOUND);
+ }
+
+ // Get list of installed custom apps
+ $installedApps = $this->appManager->getEnabledApps();
+ $installedApps = array_filter($installedApps, function ($app) {
+ try {
+ $this->appManager->getAppPath($app);
+ } catch (AppPathNotFoundException $e) {
+ return false;
+ }
+ return !$this->appManager->isShipped($app) && !isset($this->appsShippedInFutureVersion[$app]);
+ });
+
+ if (empty($installedApps)) {
+ return new DataResponse([
+ 'missing' => [],
+ 'available' => [],
+ ]);
+ }
+
+ $this->appFetcher->setVersion($newVersion, 'future-apps.json', false);
+
+ // Apps available on the app store for that version
+ $availableApps = array_map(static function (array $app): string {
+ return $app['id'];
+ }, $this->appFetcher->get());
+
+ if (empty($availableApps)) {
+ return new DataResponse([
+ 'appstore_disabled' => false,
+ 'already_on_latest' => false,
+ ], Http::STATUS_NOT_FOUND);
+ }
+
+ // Ignore apps that are deployed from git
+ $installedApps = array_filter($installedApps, function (string $appId) {
+ try {
+ return !file_exists($this->appManager->getAppPath($appId) . '/.git');
+ } catch (AppPathNotFoundException $e) {
+ return true;
+ }
+ });
+
+ $missing = array_diff($installedApps, $availableApps);
+ $missing = array_map([$this, 'getAppDetails'], $missing);
+ sort($missing);
+
+ $available = array_intersect($installedApps, $availableApps);
+ $available = array_map([$this, 'getAppDetails'], $available);
+ sort($available);
+
+ return new DataResponse([
+ 'missing' => $missing,
+ 'available' => $available,
+ ]);
+ }
+
+ /**
+ * Get translated app name
+ *
+ * @param string $appId
+ * @return UpdateNotificationApp
+ */
+ protected function getAppDetails(string $appId): array {
+ $app = $this->appManager->getAppInfo($appId, false, $this->language);
+ $name = $app['name'] ?? $appId;
+ return [
+ 'appId' => $appId,
+ 'appName' => $name,
+ ];
+ }
+
+ protected function getLanguage(): string {
+ if ($this->language === null) {
+ $this->language = $this->l10nFactory->getUserLanguage($this->userSession->getUser());
+ }
+ return $this->language;
+ }
+
+ /**
+ * Get changelog entry for an app
+ *
+ * @param string $appId App to search changelog entry for
+ * @param string|null $version The version to search the changelog entry for (defaults to the latest installed)
+ *
+ * @return DataResponse<Http::STATUS_OK, array{appName: string, content: string, version: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{}, array{}>
+ *
+ * 200: Changelog entry returned
+ * 400: The `version` parameter is not a valid version format
+ * 404: No changelog found
+ */
+ public function getAppChangelogEntry(string $appId, ?string $version = null): DataResponse {
+ $version = $version ?? $this->appManager->getAppVersion($appId);
+ // handle pre-release versions
+ $matches = [];
+ $result = preg_match('/^(\d+\.\d+(\.\d+)?)/', $version, $matches);
+ if ($result === false || $result === 0) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ $shortVersion = $matches[0];
+
+ $changes = $this->manager->getChangelog($appId, $shortVersion);
+
+ if ($changes === null) {
+ return new DataResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ // Remove version headline
+ /** @var string[] */
+ $changes = explode("\n", $changes, 2);
+ $changes = trim(end($changes));
+
+ // Get app info for localized app name
+ $info = $this->appManager->getAppInfo($appId) ?? [];
+ /** @var string */
+ $appName = $info['name'] ?? $appId;
+
+ return new DataResponse([
+ 'appName' => $appName,
+ 'content' => $changes,
+ 'version' => $version,
+ ]);
+ }
+}
diff --git a/apps/updatenotification/lib/Controller/AdminController.php b/apps/updatenotification/lib/Controller/AdminController.php
new file mode 100644
index 00000000000..26745948890
--- /dev/null
+++ b/apps/updatenotification/lib/Controller/AdminController.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\UpdateNotification\Controller;
+
+use OCA\UpdateNotification\BackgroundJob\ResetToken;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJobList;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IRequest;
+use OCP\Security\ISecureRandom;
+use OCP\Util;
+
+class AdminController extends Controller {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IJobList $jobList,
+ private ISecureRandom $secureRandom,
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ private ITimeFactory $timeFactory,
+ private IL10N $l10n,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ private function isUpdaterEnabled(): bool {
+ return !$this->config->getSystemValueBool('upgrade.disable-web');
+ }
+
+ /**
+ * @param string $channel
+ * @return DataResponse
+ */
+ public function setChannel(string $channel): DataResponse {
+ Util::setChannel($channel);
+ $this->appConfig->setValueInt('core', 'lastupdatedat', 0);
+ return new DataResponse(['status' => 'success', 'data' => ['message' => $this->l10n->t('Channel updated')]]);
+ }
+
+ /**
+ * @return DataResponse
+ */
+ public function createCredentials(): DataResponse {
+ if (!$this->isUpdaterEnabled()) {
+ return new DataResponse(['status' => 'error', 'message' => $this->l10n->t('Web updater is disabled')], Http::STATUS_FORBIDDEN);
+ }
+
+ // Create a new job and store the creation date
+ $this->jobList->add(ResetToken::class);
+ $this->appConfig->setValueInt('core', 'updater.secret.created', $this->timeFactory->getTime());
+
+ // Create a new token
+ $newToken = $this->secureRandom->generate(64);
+ $this->config->setSystemValue('updater.secret', password_hash($newToken, PASSWORD_DEFAULT));
+
+ return new DataResponse($newToken);
+ }
+}
diff --git a/apps/updatenotification/lib/Controller/ChangelogController.php b/apps/updatenotification/lib/Controller/ChangelogController.php
new file mode 100644
index 00000000000..a274ed3d2b2
--- /dev/null
+++ b/apps/updatenotification/lib/Controller/ChangelogController.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UpdateNotification\Controller;
+
+use OCA\UpdateNotification\Manager;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\IRequest;
+use OCP\Util;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class ChangelogController extends Controller {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private Manager $manager,
+ private IAppManager $appManager,
+ private IInitialState $initialState,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * This page is only used for clients not support showing the app changelog feature in-app and thus need to show it on a dedicated page.
+ * @param string $app App to show the changelog for
+ * @param string|null $version Version entry to show (defaults to latest installed)
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ public function showChangelog(string $app, ?string $version = null): TemplateResponse {
+ $version = $version ?? $this->appManager->getAppVersion($app);
+ $appInfo = $this->appManager->getAppInfo($app) ?? [];
+ $appName = $appInfo['name'] ?? $app;
+
+ $changes = $this->manager->getChangelog($app, $version) ?? '';
+ // Remove version headline
+ /** @var string[] */
+ $changes = explode("\n", $changes, 2);
+ $changes = trim(end($changes));
+
+ $this->initialState->provideInitialState('changelog', [
+ 'appName' => $appName,
+ 'appVersion' => $version,
+ 'text' => $changes,
+ ]);
+
+ Util::addScript($this->appName, 'view-changelog-page');
+ return new TemplateResponse($this->appName, 'empty');
+ }
+}
diff --git a/apps/updatenotification/lib/Listener/AppUpdateEventListener.php b/apps/updatenotification/lib/Listener/AppUpdateEventListener.php
new file mode 100644
index 00000000000..49a2506d913
--- /dev/null
+++ b/apps/updatenotification/lib/Listener/AppUpdateEventListener.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UpdateNotification\Listener;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCA\UpdateNotification\BackgroundJob\AppUpdatedNotifications;
+use OCP\App\Events\AppUpdateEvent;
+use OCP\BackgroundJob\IJobList;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IAppConfig;
+use Psr\Log\LoggerInterface;
+
+/** @template-implements IEventListener<AppUpdateEvent> */
+class AppUpdateEventListener implements IEventListener {
+
+ public function __construct(
+ private IJobList $jobList,
+ private LoggerInterface $logger,
+ private IAppConfig $appConfig,
+ ) {
+ }
+
+ /**
+ * @param AppUpdateEvent $event
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof AppUpdateEvent)) {
+ return;
+ }
+
+ if (!$this->appConfig->getValueBool(Application::APP_NAME, 'app_updated.enabled', true)) {
+ return;
+ }
+
+ foreach ($this->jobList->getJobsIterator(AppUpdatedNotifications::class, null, 0) as $job) {
+ // Remove waiting notification jobs for this app
+ if ($job->getArgument()['appId'] === $event->getAppId()) {
+ $this->jobList->remove($job);
+ }
+ }
+
+ $this->jobList->add(AppUpdatedNotifications::class, [
+ 'appId' => $event->getAppId(),
+ 'timestamp' => time(),
+ ]);
+
+ $this->logger->debug(
+ 'Scheduled app update notification for "' . $event->getAppId() . '"',
+ [
+ 'app' => Application::APP_NAME,
+ ],
+ );
+ }
+}
diff --git a/apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php b/apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php
new file mode 100644
index 00000000000..974734a76f4
--- /dev/null
+++ b/apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UpdateNotification\Listener;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IAppConfig;
+use OCP\Util;
+use Psr\Log\LoggerInterface;
+
+/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
+class BeforeTemplateRenderedEventListener implements IEventListener {
+
+ public function __construct(
+ private IAppManager $appManager,
+ private LoggerInterface $logger,
+ private IAppConfig $appConfig,
+ ) {
+ }
+
+ /**
+ * @param BeforeTemplateRenderedEvent $event
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof BeforeTemplateRenderedEvent)) {
+ return;
+ }
+
+ if (!$this->appConfig->getValueBool(Application::APP_NAME, 'app_updated.enabled', true)) {
+ return;
+ }
+
+ // Only handle logged in users
+ if (!$event->isLoggedIn()) {
+ return;
+ }
+
+ // Ignore when notifications are disabled
+ if (!$this->appManager->isEnabledForUser('notifications')) {
+ return;
+ }
+
+ Util::addInitScript(Application::APP_NAME, 'init');
+ }
+}
diff --git a/apps/updatenotification/lib/Manager.php b/apps/updatenotification/lib/Manager.php
new file mode 100644
index 00000000000..ebc1c83a9b4
--- /dev/null
+++ b/apps/updatenotification/lib/Manager.php
@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UpdateNotification;
+
+use OCP\App\IAppManager;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use Psr\Log\LoggerInterface;
+
+class Manager {
+
+ private ?IUser $currentUser;
+
+ public function __construct(
+ IUserSession $currentSession,
+ private IAppManager $appManager,
+ private IFactory $l10NFactory,
+ private LoggerInterface $logger,
+ ) {
+ $this->currentUser = $currentSession->getUser();
+ }
+
+ /**
+ * Get the changelog entry for the given appId
+ * @param string $appId The app for which to query the entry
+ * @param string $version The version for which to query the changelog entry
+ * @param ?string $languageCode The language in which to query the changelog (defaults to current user language and fallsback to English)
+ * @return string|null Either the changelog entry or null if no changelog is found
+ */
+ public function getChangelog(string $appId, string $version, ?string $languageCode = null): ?string {
+ if ($languageCode === null) {
+ $languageCode = $this->l10NFactory->getUserLanguage($this->currentUser);
+ }
+
+ $path = $this->getChangelogFile($appId, $languageCode);
+ if ($path === null) {
+ $this->logger->debug('No changelog file found for app ' . $appId . ' and language code ' . $languageCode);
+ return null;
+ }
+
+ $changes = $this->retrieveChangelogEntry($path, $version);
+ return $changes;
+ }
+
+ /**
+ * Get the changelog file in the requested language or fallback to English
+ * @param string $appId The app to load the changelog for
+ * @param string $languageCode The language code to search
+ * @return string|null Either the file path or null if not found
+ */
+ public function getChangelogFile(string $appId, string $languageCode): ?string {
+ try {
+ $appPath = $this->appManager->getAppPath($appId);
+ $files = ["CHANGELOG.$languageCode.md", 'CHANGELOG.en.md'];
+ foreach ($files as $file) {
+ $path = $appPath . '/' . $file;
+ if (is_file($path)) {
+ return $path;
+ }
+ }
+ } catch (\Throwable $e) {
+ // ignore and return null below
+ }
+ return null;
+ }
+
+ /**
+ * Retrieve a log entry from the changelog
+ * @param string $path The path to the changelog file
+ * @param string $version The version to query (make sure to only pass in "{major}.{minor}(.{patch}" format)
+ */
+ protected function retrieveChangelogEntry(string $path, string $version): ?string {
+ $matches = [];
+ $content = file_get_contents($path);
+ if ($content === false) {
+ $this->logger->debug('Could not open changelog file', ['file-path' => $path]);
+ return null;
+ }
+
+ $result = preg_match_all('/^## (?:\[)?(?:v)?(\d+\.\d+(\.\d+)?)/m', $content, $matches, PREG_OFFSET_CAPTURE);
+ if ($result === false || $result === 0) {
+ $this->logger->debug('No entries in changelog found', ['file_path' => $path]);
+ return null;
+ }
+
+ // Get the key of the match that equals the requested version
+ $index = array_key_first(
+ // Get the array containing the match that equals the requested version, keys are preserved so: [1 => '1.2.4']
+ array_filter(
+ // This is the array of the versions found, like ['1.2.3', '1.2.4']
+ $matches[1],
+ // Callback to filter only version that matches the requested version
+ fn (array $match) => version_compare($match[0], $version, '=='),
+ )
+ );
+
+ if ($index === null) {
+ $this->logger->debug('No changelog entry for version ' . $version . ' found', ['file_path' => $path]);
+ return null;
+ }
+
+ $offsetChangelogEntry = $matches[0][$index][1];
+ // Length of the changelog entry (offset of next match - own offset) or null if the whole rest should be considered
+ $lengthChangelogEntry = $index < ($result - 1) ? ($matches[0][$index + 1][1] - $offsetChangelogEntry) : null;
+ return substr($content, $offsetChangelogEntry, $lengthChangelogEntry);
+ }
+}
diff --git a/apps/updatenotification/lib/Notification/AppUpdateNotifier.php b/apps/updatenotification/lib/Notification/AppUpdateNotifier.php
new file mode 100644
index 00000000000..353ca883aba
--- /dev/null
+++ b/apps/updatenotification/lib/Notification/AppUpdateNotifier.php
@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UpdateNotification\Notification;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCP\App\IAppManager;
+use OCP\IURLGenerator;
+use OCP\IUserManager;
+use OCP\L10N\IFactory;
+use OCP\Notification\AlreadyProcessedException;
+use OCP\Notification\IAction;
+use OCP\Notification\IManager as INotificationManager;
+use OCP\Notification\INotification;
+use OCP\Notification\INotifier;
+use OCP\Notification\UnknownNotificationException;
+use Psr\Log\LoggerInterface;
+
+class AppUpdateNotifier implements INotifier {
+
+ public function __construct(
+ private IFactory $l10nFactory,
+ private INotificationManager $notificationManager,
+ private IUserManager $userManager,
+ private IURLGenerator $urlGenerator,
+ private IAppManager $appManager,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ public function getID(): string {
+ return 'updatenotification_app_updated';
+ }
+
+ /**
+ * Human readable name describing the notifier
+ */
+ public function getName(): string {
+ return $this->l10nFactory->get(Application::APP_NAME)->t('App updated');
+ }
+
+ /**
+ * @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
+ * @throws AlreadyProcessedException When the app is no longer known
+ */
+ public function prepare(INotification $notification, string $languageCode): INotification {
+ if ($notification->getApp() !== Application::APP_NAME) {
+ throw new UnknownNotificationException('Unknown app');
+ }
+
+ if ($notification->getSubject() !== 'app_updated') {
+ throw new UnknownNotificationException('Unknown subject');
+ }
+
+ $appId = $notification->getSubjectParameters()[0];
+ $appInfo = $this->appManager->getAppInfo($appId, lang:$languageCode);
+ if ($appInfo === null) {
+ throw new AlreadyProcessedException();
+ }
+
+ // Prepare translation factory for requested language
+ $l = $this->l10nFactory->get(Application::APP_NAME, $languageCode);
+
+ $icon = $this->appManager->getAppIcon($appId, true);
+ if ($icon === null) {
+ $icon = $this->urlGenerator->imagePath('core', 'actions/change.svg');
+ }
+
+ $action = $notification->createAction();
+ $action
+ ->setLabel($l->t('See what\'s new'))
+ ->setParsedLabel($l->t('See what\'s new'))
+ ->setLink($this->urlGenerator->linkToRouteAbsolute('updatenotification.Changelog.showChangelog', ['app' => $appId, 'version' => $this->appManager->getAppVersion($appId)]), IAction::TYPE_WEB);
+
+ $notification
+ ->setIcon($this->urlGenerator->getAbsoluteURL($icon))
+ ->addParsedAction($action)
+ ->setRichSubject(
+ $l->t('{app} updated to version {version}'),
+ [
+ 'app' => [
+ 'type' => 'app',
+ 'id' => $appId,
+ 'name' => $appInfo['name'],
+ ],
+ 'version' => [
+ 'type' => 'highlight',
+ 'id' => $appId,
+ 'name' => $appInfo['version'],
+ ],
+ ],
+ );
+
+ return $notification;
+ }
+}
diff --git a/apps/updatenotification/lib/Notification/Notifier.php b/apps/updatenotification/lib/Notification/Notifier.php
new file mode 100644
index 00000000000..787675bd98d
--- /dev/null
+++ b/apps/updatenotification/lib/Notification/Notifier.php
@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\UpdateNotification\Notification;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Services\IAppConfig;
+use OCP\IGroupManager;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use OCP\Notification\AlreadyProcessedException;
+use OCP\Notification\IManager;
+use OCP\Notification\INotification;
+use OCP\Notification\INotifier;
+use OCP\Notification\UnknownNotificationException;
+use OCP\ServerVersion;
+
+class Notifier implements INotifier {
+ /** @var string[] */
+ protected $appVersions;
+
+ /**
+ * Notifier constructor.
+ */
+ public function __construct(
+ protected IURLGenerator $url,
+ protected IAppConfig $appConfig,
+ protected IManager $notificationManager,
+ protected IFactory $l10NFactory,
+ protected IUserSession $userSession,
+ protected IGroupManager $groupManager,
+ protected IAppManager $appManager,
+ protected ServerVersion $serverVersion,
+ ) {
+ $this->appVersions = $this->appManager->getAppInstalledVersions();
+ }
+
+ /**
+ * Identifier of the notifier, only use [a-z0-9_]
+ *
+ * @return string
+ * @since 17.0.0
+ */
+ public function getID(): string {
+ return Application::APP_NAME;
+ }
+
+ /**
+ * Human readable name describing the notifier
+ *
+ * @return string
+ * @since 17.0.0
+ */
+ public function getName(): string {
+ return $this->l10NFactory->get(Application::APP_NAME)->t('Update 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
+ * @throws AlreadyProcessedException When the notification is not needed anymore and should be deleted
+ * @since 9.0.0
+ */
+ public function prepare(INotification $notification, string $languageCode): INotification {
+ if ($notification->getApp() !== Application::APP_NAME) {
+ throw new UnknownNotificationException('Unknown app id');
+ }
+
+ if ($notification->getSubject() !== 'update_available' && $notification->getSubject() !== 'connection_error') {
+ throw new UnknownNotificationException('Unknown subject');
+ }
+
+ $l = $this->l10NFactory->get(Application::APP_NAME, $languageCode);
+ if ($notification->getSubject() === 'connection_error') {
+ $errors = $this->appConfig->getAppValueInt('update_check_errors', 0);
+ if ($errors === 0) {
+ throw new AlreadyProcessedException();
+ }
+
+ $notification->setParsedSubject($l->t('The update server could not be reached since %d days to check for new updates.', [$errors]))
+ ->setParsedMessage($l->t('Please check the Nextcloud and server log files for errors.'));
+ } else {
+ if ($notification->getObjectType() === 'core') {
+ $this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
+
+ $parameters = $notification->getSubjectParameters();
+ $notification->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
+ 'serverAndVersion' => [
+ 'type' => 'highlight',
+ 'id' => $notification->getObjectType(),
+ 'name' => $parameters['version'],
+ ]
+ ]);
+
+ if ($this->isAdmin()) {
+ $notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']) . '#version');
+ }
+ } else {
+ $appInfo = $this->getAppInfo($notification->getObjectType(), $languageCode);
+ $appName = ($appInfo === null) ? $notification->getObjectType() : $appInfo['name'];
+
+ if (isset($this->appVersions[$notification->getObjectType()])) {
+ $this->updateAlreadyInstalledCheck($notification, $this->appVersions[$notification->getObjectType()]);
+ }
+
+ $notification->setRichSubject($l->t('Update for {app} to version %s is available.', [$notification->getObjectId()]), [
+ 'app' => [
+ 'type' => 'app',
+ 'id' => $notification->getObjectType(),
+ 'name' => $appName,
+ ]
+ ]);
+
+ if ($this->isAdmin()) {
+ $notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
+ }
+ }
+ }
+
+ $notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_NAME, 'notification.svg')));
+
+ return $notification;
+ }
+
+ /**
+ * Remove the notification and prevent rendering, when the update is installed
+ *
+ * @param INotification $notification
+ * @param string $installedVersion
+ * @throws AlreadyProcessedException When the update is already installed
+ */
+ protected function updateAlreadyInstalledCheck(INotification $notification, $installedVersion): void {
+ if (version_compare($notification->getObjectId(), $installedVersion, '<=')) {
+ throw new AlreadyProcessedException();
+ }
+ }
+
+ protected function isAdmin(): bool {
+ $user = $this->userSession->getUser();
+
+ if ($user instanceof IUser) {
+ return $this->groupManager->isAdmin($user->getUID());
+ }
+
+ return false;
+ }
+
+ protected function getCoreVersions(): string {
+ return implode('.', $this->serverVersion->getVersion());
+ }
+
+ protected function getAppInfo(string $appId, ?string $languageCode): ?array {
+ return $this->appManager->getAppInfo($appId, false, $languageCode);
+ }
+}
diff --git a/apps/updatenotification/lib/ResponseDefinitions.php b/apps/updatenotification/lib/ResponseDefinitions.php
new file mode 100644
index 00000000000..754787ea2a7
--- /dev/null
+++ b/apps/updatenotification/lib/ResponseDefinitions.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UpdateNotification;
+
+/**
+ * @psalm-type UpdateNotificationApp = array{
+ * appId: string,
+ * appName: string,
+ * }
+ */
+class ResponseDefinitions {
+}
diff --git a/apps/updatenotification/lib/Settings/Admin.php b/apps/updatenotification/lib/Settings/Admin.php
new file mode 100644
index 00000000000..22228f1bccc
--- /dev/null
+++ b/apps/updatenotification/lib/Settings/Admin.php
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\UpdateNotification\Settings;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCA\UpdateNotification\UpdateChecker;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IDateTimeFormatter;
+use OCP\IGroupManager;
+use OCP\IUserManager;
+use OCP\L10N\IFactory;
+use OCP\ServerVersion;
+use OCP\Settings\ISettings;
+use OCP\Support\Subscription\IRegistry;
+use Psr\Log\LoggerInterface;
+
+class Admin implements ISettings {
+ public function __construct(
+ private IConfig $config,
+ private IAppConfig $appConfig,
+ private UpdateChecker $updateChecker,
+ private IGroupManager $groupManager,
+ private IDateTimeFormatter $dateTimeFormatter,
+ private IFactory $l10nFactory,
+ private IRegistry $subscriptionRegistry,
+ private IUserManager $userManager,
+ private LoggerInterface $logger,
+ private IInitialState $initialState,
+ private ServerVersion $serverVersion,
+ ) {
+ }
+
+ public function getForm(): TemplateResponse {
+ $lastUpdateCheckTimestamp = $this->appConfig->getValueInt('core', 'lastupdatedat');
+ $lastUpdateCheck = $this->dateTimeFormatter->formatDateTime($lastUpdateCheckTimestamp);
+
+ $channels = [
+ 'daily',
+ 'beta',
+ 'stable',
+ 'production',
+ ];
+ $currentChannel = $this->serverVersion->getChannel();
+ if ($currentChannel === 'git') {
+ $channels[] = 'git';
+ }
+
+ $updateState = $this->updateChecker->getUpdateState();
+
+ $notifyGroups = $this->appConfig->getValueArray(Application::APP_NAME, 'notify_groups', ['admin']);
+
+ $defaultUpdateServerURL = 'https://updates.nextcloud.com/updater_server/';
+ $updateServerURL = $this->config->getSystemValue('updater.server.url', $defaultUpdateServerURL);
+ $defaultCustomerUpdateServerURLPrefix = 'https://updates.nextcloud.com/customers/';
+
+ $isDefaultUpdateServerURL = $updateServerURL === $defaultUpdateServerURL
+ || strpos($updateServerURL, $defaultCustomerUpdateServerURLPrefix) === 0;
+
+ $hasValidSubscription = $this->subscriptionRegistry->delegateHasValidSubscription();
+
+ $params = [
+ 'isNewVersionAvailable' => !empty($updateState['updateAvailable']),
+ 'isUpdateChecked' => $lastUpdateCheckTimestamp > 0,
+ 'lastChecked' => $lastUpdateCheck,
+ 'currentChannel' => $currentChannel,
+ 'channels' => $channels,
+ 'newVersion' => empty($updateState['updateVersion']) ? '' : $updateState['updateVersion'],
+ 'newVersionString' => empty($updateState['updateVersionString']) ? '' : $updateState['updateVersionString'],
+ 'downloadLink' => empty($updateState['downloadLink']) ? '' : $updateState['downloadLink'],
+ 'changes' => $this->filterChanges($updateState['changes'] ?? []),
+ 'webUpdaterEnabled' => !$this->config->getSystemValue('upgrade.disable-web', false),
+ 'isWebUpdaterRecommended' => $this->isWebUpdaterRecommended(),
+ 'updaterEnabled' => empty($updateState['updaterEnabled']) ? false : $updateState['updaterEnabled'],
+ 'versionIsEol' => empty($updateState['versionIsEol']) ? false : $updateState['versionIsEol'],
+ 'isDefaultUpdateServerURL' => $isDefaultUpdateServerURL,
+ 'updateServerURL' => $updateServerURL,
+ 'notifyGroups' => $this->getSelectedGroups($notifyGroups),
+ 'hasValidSubscription' => $hasValidSubscription,
+ ];
+ $this->initialState->provideInitialState('data', $params);
+
+ return new TemplateResponse('updatenotification', 'admin', [], '');
+ }
+
+ protected function filterChanges(array $changes): array {
+ $filtered = [];
+ if (isset($changes['changelogURL'])) {
+ $filtered['changelogURL'] = $changes['changelogURL'];
+ }
+ if (!isset($changes['whatsNew'])) {
+ return $filtered;
+ }
+
+ $iterator = $this->l10nFactory->getLanguageIterator();
+ do {
+ $lang = $iterator->current();
+ if (isset($changes['whatsNew'][$lang])) {
+ $filtered['whatsNew'] = $changes['whatsNew'][$lang];
+ return $filtered;
+ }
+ $iterator->next();
+ } while ($lang !== 'en' && $iterator->valid());
+
+ return $filtered;
+ }
+
+ /**
+ * @param string[] $groupIds
+ * @return list<array{id: string, displayname: string}>
+ */
+ protected function getSelectedGroups(array $groupIds): array {
+ $result = [];
+ foreach ($groupIds as $groupId) {
+ $group = $this->groupManager->get($groupId);
+
+ if ($group === null) {
+ continue;
+ }
+
+ $result[] = ['id' => $group->getGID(), 'displayname' => $group->getDisplayName()];
+ }
+
+ return $result;
+ }
+
+ public function getSection(): ?string {
+ if (!$this->config->getSystemValueBool('updatechecker', true)) {
+ // update checker is disabled so we do not show the section at all
+ return null;
+ }
+
+ return 'overview';
+ }
+
+ public function getPriority(): int {
+ return 11;
+ }
+
+ private function isWebUpdaterRecommended(): bool {
+ return (int)$this->userManager->countUsersTotal(100) < 100;
+ }
+}
diff --git a/apps/updatenotification/lib/UpdateChecker.php b/apps/updatenotification/lib/UpdateChecker.php
new file mode 100644
index 00000000000..b206ba4a3e4
--- /dev/null
+++ b/apps/updatenotification/lib/UpdateChecker.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OCA\UpdateNotification;
+
+use OC\Updater\ChangesCheck;
+use OC\Updater\VersionCheck;
+use OCP\AppFramework\Services\IInitialState;
+
+class UpdateChecker {
+
+ public function __construct(
+ private VersionCheck $updater,
+ private ChangesCheck $changesCheck,
+ private IInitialState $initialState,
+ ) {
+ }
+
+ /**
+ * @return array
+ */
+ public function getUpdateState(): array {
+ $data = $this->updater->check();
+ $result = [];
+
+ if (isset($data['version']) && $data['version'] !== '' && $data['version'] !== []) {
+ $result['updateAvailable'] = true;
+ $result['updateVersion'] = $data['version'];
+ $result['updateVersionString'] = $data['versionstring'];
+ $result['updaterEnabled'] = $data['autoupdater'] === '1';
+ $result['versionIsEol'] = $data['eol'] === '1';
+ if (strpos($data['web'], 'https://') === 0) {
+ $result['updateLink'] = $data['web'];
+ }
+ if (strpos($data['url'], 'https://') === 0) {
+ $result['downloadLink'] = $data['url'];
+ }
+ if (strpos($data['changes'], 'https://') === 0) {
+ try {
+ $result['changes'] = $this->changesCheck->check($data['changes'], $data['version']);
+ } catch (\Exception $e) {
+ // no info, not a problem
+ }
+ }
+
+ return $result;
+ }
+
+ return [];
+ }
+
+ /**
+ * Provide update information as initial state
+ */
+ public function setInitialState(): void {
+ $updateState = $this->getUpdateState();
+ if (empty($updateState)) {
+ return;
+ }
+
+ $this->initialState->provideInitialState('updateState', [
+ 'updateVersion' => $updateState['updateVersionString'],
+ 'updateLink' => $updateState['updateLink'] ?? '',
+ ]);
+ }
+}
diff --git a/apps/updatenotification/lib/resettokenbackgroundjob.php b/apps/updatenotification/lib/resettokenbackgroundjob.php
deleted file mode 100644
index 61bd9fc0490..00000000000
--- a/apps/updatenotification/lib/resettokenbackgroundjob.php
+++ /dev/null
@@ -1,76 +0,0 @@
-<?php
-/**
- * @author Lukas Reschke <lukas@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\UpdateNotification;
-
-use OC\AppFramework\Utility\TimeFactory;
-use OC\BackgroundJob\TimedJob;
-use OCP\AppFramework\Utility\ITimeFactory;
-use OCP\IConfig;
-
-/**
- * Class ResetTokenBackgroundJob deletes any configured token all 24 hours for
- *
- *
- * @package OCA\UpdateNotification
- */
-class ResetTokenBackgroundJob extends TimedJob {
- /** @var IConfig */
- private $config;
- /** @var ITimeFactory */
- private $timeFactory;
-
- /**
- * @param IConfig|null $config
- * @param ITimeFactory|null $timeFactory
- */
- public function __construct(IConfig $config = null,
- ITimeFactory $timeFactory = null) {
- // Run all 10 minutes
- $this->setInterval(60 * 10);
-
- if ($config instanceof IConfig && $timeFactory instanceof ITimeFactory) {
- $this->config = $config;
- $this->timeFactory = $timeFactory;
- } else {
- $this->fixDIForJobs();
- }
- }
-
- /**
- * DI for jobs
- */
- private function fixDIForJobs() {
- $this->config = \OC::$server->getConfig();
- $this->timeFactory = new TimeFactory();
- }
-
- /**
- * @param $argument
- */
- protected function run($argument) {
- // Delete old tokens after 2 days
- if($this->timeFactory->getTime() - $this->config->getAppValue('core', 'updater.secret.created', $this->timeFactory->getTime()) >= 172800) {
- $this->config->deleteSystemValue('updater.secret');
- }
- }
-
-}
diff --git a/apps/updatenotification/lib/updatechecker.php b/apps/updatenotification/lib/updatechecker.php
deleted file mode 100644
index 965e21617e7..00000000000
--- a/apps/updatenotification/lib/updatechecker.php
+++ /dev/null
@@ -1,67 +0,0 @@
-<?php
-/**
- * @author Lukas Reschke <lukas@owncloud.com>
- *
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
- */
-
-namespace OCA\UpdateNotification;
-
-use OC\Updater;
-
-class UpdateChecker {
- /** @var Updater */
- private $updater;
-
- /**
- * @param Updater $updater
- */
- public function __construct(Updater $updater) {
- $this->updater = $updater;
- }
-
- /**
- * @return array
- */
- public function getUpdateState() {
- $data = $this->updater->check();
- $result = [];
-
- if(isset($data['version']) && $data['version'] !== '' && $data['version'] !== []) {
- $result['updateAvailable'] = true;
- $result['updateVersion'] = $data['versionstring'];
- if(substr($data['web'], 0, 8) === 'https://') {
- $result['updateLink'] = $data['web'];
- }
-
- return $result;
- }
-
- return [];
- }
-
- /**
- * @param array $data
- */
- public function getJavaScript(array $data) {
- $data['array']['oc_updateState'] = json_encode([
- 'updateAvailable' => true,
- 'updateVersion' => $this->getUpdateState()['updateVersion'],
- 'updateLink' => isset($this->getUpdateState()['updateLink']) ? $this->getUpdateState()['updateLink'] : '',
- ]);
- }
-}