feat(updatenotification): Add notification for users when apps are updated

* Open app changelog dialog when available (webui)
* Fallback to open changelog page for mobile clients

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2024-03-02 21:33:02 +01:00 committed by John Molakvoæ (skjnldsv)
parent d9d3448e23
commit fa14daf968
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
23 changed files with 1193 additions and 65 deletions

View File

@ -3,8 +3,8 @@
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>updatenotification</id>
<name>Update notification</name>
<summary>Displays update notifications for Nextcloud and provides the SSO for the updater.</summary>
<description>Displays update notifications for Nextcloud and provides the SSO for the updater.</description>
<summary>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</summary>
<description>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</description>
<version>1.19.0</version>
<licence>agpl</licence>
<author>Lukas Reschke</author>

View File

@ -27,8 +27,11 @@ return [
'routes' => [
['name' => 'Admin#createCredentials', 'url' => '/credentials', 'verb' => 'GET'],
['name' => 'Admin#setChannel', 'url' => '/channel', 'verb' => 'POST'],
// Fallback app changelog information for mobile clients
['name' => 'Changelog#showChangelog', 'url' => '/changelog/{app}', 'verb' => 'GET'],
],
'ocs' => [
['name' => 'API#getAppList', 'url' => '/api/{apiVersion}/applist/{newVersion}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
['name' => 'API#getAppChangelogEntry', 'url' => '/api/{apiVersion}/changelog/{appId}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
],
];

View File

@ -8,12 +8,18 @@ $baseDir = $vendorDir;
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\UpdateNotification\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => $baseDir . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => $baseDir . '/../lib/BackgroundJob/ResetToken.php',
'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => $baseDir . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
'OCA\\UpdateNotification\\Command\\Check' => $baseDir . '/../lib/Command/Check.php',
'OCA\\UpdateNotification\\Controller\\APIController' => $baseDir . '/../lib/Controller/APIController.php',
'OCA\\UpdateNotification\\Controller\\AdminController' => $baseDir . '/../lib/Controller/AdminController.php',
'OCA\\UpdateNotification\\Notification\\BackgroundJob' => $baseDir . '/../lib/Notification/BackgroundJob.php',
'OCA\\UpdateNotification\\Controller\\ChangelogController' => $baseDir . '/../lib/Controller/ChangelogController.php',
'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => $baseDir . '/../lib/Listener/AppUpdateEventListener.php',
'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => $baseDir . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
'OCA\\UpdateNotification\\Manager' => $baseDir . '/../lib/Manager.php',
'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => $baseDir . '/../lib/Notification/AppUpdateNotifier.php',
'OCA\\UpdateNotification\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',
'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => $baseDir . '/../lib/ResetTokenBackgroundJob.php',
'OCA\\UpdateNotification\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\UpdateNotification\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
'OCA\\UpdateNotification\\UpdateChecker' => $baseDir . '/../lib/UpdateChecker.php',

View File

@ -23,12 +23,18 @@ class ComposerStaticInitUpdateNotification
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\UpdateNotification\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => __DIR__ . '/..' . '/../lib/BackgroundJob/ResetToken.php',
'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
'OCA\\UpdateNotification\\Command\\Check' => __DIR__ . '/..' . '/../lib/Command/Check.php',
'OCA\\UpdateNotification\\Controller\\APIController' => __DIR__ . '/..' . '/../lib/Controller/APIController.php',
'OCA\\UpdateNotification\\Controller\\AdminController' => __DIR__ . '/..' . '/../lib/Controller/AdminController.php',
'OCA\\UpdateNotification\\Notification\\BackgroundJob' => __DIR__ . '/..' . '/../lib/Notification/BackgroundJob.php',
'OCA\\UpdateNotification\\Controller\\ChangelogController' => __DIR__ . '/..' . '/../lib/Controller/ChangelogController.php',
'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => __DIR__ . '/..' . '/../lib/Listener/AppUpdateEventListener.php',
'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => __DIR__ . '/..' . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
'OCA\\UpdateNotification\\Manager' => __DIR__ . '/..' . '/../lib/Manager.php',
'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => __DIR__ . '/..' . '/../lib/Notification/AppUpdateNotifier.php',
'OCA\\UpdateNotification\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',
'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => __DIR__ . '/..' . '/../lib/ResetTokenBackgroundJob.php',
'OCA\\UpdateNotification\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\UpdateNotification\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
'OCA\\UpdateNotification\\UpdateChecker' => __DIR__ . '/..' . '/../lib/UpdateChecker.php',

View File

@ -10,6 +10,7 @@ declare(strict_types=1);
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@ -29,13 +30,18 @@ declare(strict_types=1);
*/
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;
@ -46,12 +52,18 @@ use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
class Application extends App implements IBootstrap {
public const APP_NAME = 'updatenotification';
public function __construct() {
parent::__construct('updatenotification', []);
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 {

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\BackgroundJob;
use OCA\UpdateNotification\AppInfo\Application;
use OCA\UpdateNotification\Manager;
use OCP\App\IAppManager;
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 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 = 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) {
if ($guestsEnabled && ($user->getBackend() instanceof ('\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();
}
}
}

View File

@ -7,6 +7,7 @@ declare(strict_types=1);
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Joas Schilling <coding@schilljs.com>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@ -27,6 +28,7 @@ declare(strict_types=1);
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;
@ -43,21 +45,6 @@ use OCP\L10N\IFactory;
*/
class APIController extends OCSController {
/** @var IConfig */
protected $config;
/** @var IAppManager */
protected $appManager;
/** @var AppFetcher */
protected $appFetcher;
/** @var IFactory */
protected $l10nFactory;
/** @var IUserSession */
protected $userSession;
/** @var string */
protected $language;
@ -73,20 +60,17 @@ class APIController extends OCSController {
'twofactor_totp' => 25,
];
public function __construct(string $appName,
public function __construct(
string $appName,
IRequest $request,
IConfig $config,
IAppManager $appManager,
AppFetcher $appFetcher,
IFactory $l10nFactory,
IUserSession $userSession) {
protected IConfig $config,
protected IAppManager $appManager,
protected AppFetcher $appFetcher,
protected IFactory $l10nFactory,
protected IUserSession $userSession,
protected Manager $manager,
) {
parent::__construct($appName, $request);
$this->config = $config;
$this->appManager = $appManager;
$this->appFetcher = $appFetcher;
$this->l10nFactory = $l10nFactory;
$this->userSession = $userSession;
}
/**
@ -178,4 +162,40 @@ class APIController extends OCSController {
'appName' => $name ?? $appId,
];
}
/**
* 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{}>
*
* 200: Changelog entry returned
* 404: No changelog found
*/
public function getAppChangelogEntry(string $appId, ?string $version = null): DataResponse {
$version = $version ?? $this->appManager->getAppVersion($appId);
$changes = $this->manager->getChangelog($appId, $version);
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,
]);
}
}

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\UpdateNotification\Controller;
use OCA\UpdateNotification\Manager;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IRequest;
#[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)
* @NoCSRFRequired
* @NoAdminRequired
*/
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,
]);
\OCP\Util::addScript($this->appName, 'view-changelog-page');
return new TemplateResponse($this->appName, 'empty');
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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 Psr\Log\LoggerInterface;
/** @template-implements IEventListener<AppUpdateEvent> */
class AppUpdateEventListener implements IEventListener {
public function __construct(
private IJobList $jobList,
private LoggerInterface $logger,
) {
}
/**
* @param AppUpdateEvent $event
*/
public function handle(Event $event): void {
if (!($event instanceof AppUpdateEvent)) {
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,
],
);
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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 Psr\Log\LoggerInterface;
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
class BeforeTemplateRenderedEventListener implements IEventListener {
public function __construct(
private IAppManager $appManager,
private LoggerInterface $logger,
) {
}
/**
* @param BeforeTemplateRenderedEvent $event
*/
public function handle(Event $event): void {
if (!($event instanceof BeforeTemplateRenderedEvent)) {
return;
}
// Only handle logged in users
if (!$event->isLoggedIn()) {
return;
}
// Ignore when notifications are disabled
if (!$this->appManager->isEnabledForUser('notifications')) {
return;
}
\OCP\Util::addInitScript(Application::APP_NAME, 'init');
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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|null {
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|null {
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 changlog 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|null {
$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);
}
}

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
* @author Joas Schilling <coding@schilljs.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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\IAction;
use OCP\Notification\IManager as INotificationManager;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
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 \InvalidArgumentException When the notification was not prepared by a notifier
*/
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_NAME) {
throw new \InvalidArgumentException('Unknown app');
}
if ($notification->getSubject() !== 'app_updated') {
throw new \InvalidArgumentException('Unknown subject');
}
$appId = $notification->getSubjectParameters()[0];
$appInfo = $this->appManager->getAppInfo($appId, lang:$languageCode);
if ($appInfo === null) {
throw new \InvalidArgumentException('App info not found');
}
// Prepare translation factory for requested language
$l = $this->l10nFactory->get(Application::APP_NAME, $languageCode);
// See if we can find the app icon - if not fall back to default icon
$possibleIcons = [$appId . '-dark.svg', 'app-dark.svg', $appId . '.svg', 'app.svg'];
$icon = null;
foreach ($possibleIcons as $iconName) {
try {
$icon = $this->urlGenerator->imagePath($appId, $iconName);
} catch (\RuntimeException $e) {
// ignore
}
}
if ($icon === null) {
$icon = $this->urlGenerator->imagePath('core', 'default-app-icon');
}
$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;
}
}

View File

@ -114,6 +114,10 @@ class Notifier implements INotifier {
throw new \InvalidArgumentException('Unknown app id');
}
if ($notification->getSubject() !== 'update_available' && $notification->getSubject() !== 'connection_error') {
throw new \InvalidArgumentException('Unknown subject');
}
$l = $this->l10NFactory->get('updatenotification', $languageCode);
if ($notification->getSubject() === 'connection_error') {
$errors = (int) $this->config->getAppValue('updatenotification', 'update_check_errors', '0');
@ -124,40 +128,42 @@ class Notifier implements INotifier {
$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.'));
} elseif ($notification->getObjectType() === 'core') {
$this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
} else {
if ($notification->getObjectType() === 'core') {
$this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
$parameters = $notification->getSubjectParameters();
$notification->setParsedSubject($l->t('Update to %1$s is available.', [$parameters['version']]))
->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
'serverAndVersion' => [
'type' => 'highlight',
$parameters = $notification->getSubjectParameters();
$notification->setParsedSubject($l->t('Update to %1$s is available.', [$parameters['version']]))
->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' => $parameters['version'],
'name' => $appName,
]
]);
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());
if ($this->isAdmin()) {
$notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
}
}
}

View File

@ -3,7 +3,7 @@
"info": {
"title": "updatenotification",
"version": "0.0.1",
"description": "Displays update notifications for Nextcloud and provides the SSO for the updater.",
"description": "Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.",
"license": {
"name": "agpl"
}
@ -203,6 +203,144 @@
}
}
}
},
"/ocs/v2.php/apps/updatenotification/api/{apiVersion}/changelog/{appId}": {
"get": {
"operationId": "api-get-app-changelog-entry",
"summary": "Get changelog entry for an app",
"description": "This endpoint requires admin access",
"tags": [
"api"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "version",
"in": "query",
"description": "The version to search the changelog entry for (defaults to the latest installed)",
"schema": {
"type": "string",
"nullable": true
}
},
{
"name": "apiVersion",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"v1"
],
"default": "v1"
}
},
{
"name": "appId",
"in": "path",
"description": "App to search changelog entry for",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Changelog entry returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"appName",
"content",
"version"
],
"properties": {
"appName": {
"type": "string"
},
"content": {
"type": "string"
},
"version": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"404": {
"description": "No changelog found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
},
"tags": []

View File

@ -0,0 +1,96 @@
<template>
<NcDialog content-classes="app-changelog-dialog"
:buttons="dialogButtons"
:name="t('updatenotification', 'What\'s new in {app} {version}', { app: appName, version: appVersion })"
:open="open && markdown !== undefined"
size="normal"
@update:open="$emit('update:open', $event)">
<Markdown class="app-changelog-dialog__text" :markdown="markdown" :min-heading-level="3" />
</NcDialog>
</template>
<script setup lang="ts">
import { translate as t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { ref, watchEffect } from 'vue'
import axios from '@nextcloud/axios'
import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
import Markdown from './Markdown.vue'
const props = withDefaults(
defineProps<{
appId: string
version?: string
open?: boolean
}>(),
// Default values
{
open: true,
version: undefined,
},
)
const emit = defineEmits<{
/**
* Event that is called when the "Get started"-button is pressed
*/
(e: 'dismiss'): void
(e: 'update:open', v: boolean): void
}>()
const dialogButtons = [
{
label: t('updatenotification', 'Give feedback'),
callback: () => {
window.open(`https://apps.nextcloud.com/apps/${props.appId}#comments`, '_blank', 'noreferrer noopener')
},
},
{
label: t('updatenotification', 'Get started'),
type: 'primary',
callback: () => {
emit('dismiss')
emit('update:open', false)
},
},
]
const appName = ref(props.appId)
const appVersion = ref(props.version ?? '')
const markdown = ref<string>('')
watchEffect(() => {
const url = props.version
? generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}?version={version}', { version: props.version, app: props.appId })
: generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}', { version: props.version, app: props.appId })
axios.get(url)
.then(({ data }) => {
appName.value = data.ocs.data.appName
appVersion.value = data.ocs.data.version
markdown.value = data.ocs.data.content
})
.catch((error) => {
if (error?.response?.status === 404) {
appName.value = props.appId
markdown.value = t('updatenotification', 'No changelog available')
} else {
console.error('Failed to load changelog entry', error)
emit('update:open', false)
}
})
})
</script>
<style scoped lang="scss">
:deep(.app-changelog-dialog) {
min-height: 50vh !important;
}
.app-changelog-dialog__text {
padding-inline: 14px;
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="markdown" v-html="html" />
</template>
<script setup lang="ts">
import { toRef } from 'vue'
import { useMarkdown } from '../composables/useMarkdown'
const props = withDefaults(
defineProps<{
markdown: string
minHeadingLevel?: 1|2|3|4|5|6
}>(),
{
minHeadingLevel: 2,
},
)
const { html } = useMarkdown(toRef(props, 'markdown'), toRef(props, 'minHeadingLevel'))
</script>
<style scoped lang="scss">
.markdown {
:deep {
ul {
list-style: disc;
padding-inline-start: 20px;
}
h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.5;
margin-top: 24px;
margin-bottom: 12px;
color: var(--color-main-text);
}
h3 {
font-size: 20px;
}
h4 {
font-size: 18px;
}
h5 {
font-size: 17px;
}
h6 {
font-size: var(--default-font-size);
}
}
}
</style>

View File

@ -0,0 +1,62 @@
import type { Ref } from 'vue'
import { marked } from 'marked'
import { computed } from 'vue'
import dompurify from 'dompurify'
export const useMarkdown = (text: Ref<string|undefined|null>, minHeadingLevel: Ref<number|undefined>) => {
const minHeading = computed(() => Math.min(Math.max(minHeadingLevel.value ?? 1, 1), 6))
const renderer = new marked.Renderer()
renderer.link = function(href, title, text) {
let out = `<a href="${href}" rel="noreferrer noopener" target="_blank"`
if (title) {
out += ' title="' + title + '"'
}
out += '>' + text + '</a>'
return out
}
renderer.image = function(href, title, text) {
if (text) {
return text
}
return title ?? ''
}
renderer.heading = (text: string, level: number) => {
const headingLevel = Math.max(minHeading.value, level)
return `<h${headingLevel}>${text}</h${headingLevel}>`
}
const html = computed(() => dompurify.sanitize(
marked((text.value ?? '').trim(), {
renderer,
gfm: false,
breaks: false,
pedantic: false,
}),
{
SAFE_FOR_JQUERY: true,
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'strong',
'p',
'a',
'ul',
'ol',
'li',
'em',
'del',
'blockquote',
],
},
))
return { html }
}

View File

@ -0,0 +1,75 @@
import { subscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
import Vue, { defineAsyncComponent } from 'vue'
import axios from '@nextcloud/axios'
const navigationEntries = loadState('core', 'apps', {})
const DialogVue = defineAsyncComponent(() => import('./components/AppChangelogDialog.vue'))
/**
* Show the app changelog dialog
*
* @param appId The app to show the changelog for
* @param version Optional version to show
*/
function showDialog(appId: string, version?: string) {
const element = document.createElement('div')
document.body.appendChild(element)
return new Promise((resolve) => {
let dismissed = false
const dialog = new Vue({
el: element,
render: (h) => h(DialogVue, {
props: {
appId,
version,
},
on: {
dismiss: () => { dismissed = true },
'update:open': (open: boolean) => {
if (!open) {
dialog.$destroy?.()
resolve(dismissed)
if (dismissed && appId in navigationEntries) {
window.location = navigationEntries[appId].href
}
}
},
},
}),
})
})
}
interface INotificationActionEvent {
cancelAction: boolean
notification: Readonly<{
notificationId: number
objectId: string
objectType: string
}>
action: Readonly<{
url: string
type: 'WEB'|'GET'|'POST'|'DELETE'
}>,
}
subscribe('notifications:action:execute', (event: INotificationActionEvent) => {
if (event.notification.objectType === 'app_updated') {
event.cancelAction = true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, app, version, __] = event.action.url.match(/(?<=\/)([^?]+)?version=((\d+.?)+)/) ?? []
showDialog((app as string|undefined) || (event.notification.objectId as string), version)
.then((dismissed) => {
if (dismissed) {
axios.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{id}', { id: event.notification.notificationId }))
}
})
}
})

View File

@ -0,0 +1,8 @@
import Vue from 'vue'
import App from './views/App.vue'
export default new Vue({
name: 'ViewChangelogPage',
render: (h) => h(App),
el: '#content',
})

View File

@ -0,0 +1,39 @@
<template>
<NcContent app-name="updatenotification">
<NcAppContent :page-heading="t('updatenotification', 'Changelog for app {app}', { app: appName })">
<div class="changelog__wrapper">
<h2 class="changelog__heading">
{{ t('updatenotification', 'What\'s new in {app} version {version}', { app: appName, version: appVersion }) }}
</h2>
<Markdown :markdown="markdown" :min-heading-level="3" />
</div>
</NcAppContent>
</NcContent>
</template>
<script setup lang="ts">
import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
import Markdown from '../components/Markdown.vue'
const {
appName,
appVersion,
text: markdown,
} = loadState<{ appName: string, appVersion: string, text: string }>('updatenotification', 'changelog')
</script>
<style scoped>
.changelog__wrapper {
max-width: max(50vw,700px);
margin-inline: auto;
}
.changelog__heading {
font-size: 30px;
margin-block: var(--app-navigation-padding, 8px) 1em;
}
</style>

View File

@ -0,0 +1,4 @@
<?php
/**
* Empty as Vue will take over
*/

View File

@ -114,7 +114,9 @@ module.exports = {
settings: path.join(__dirname, 'apps/twofactor_backupcodes/src', 'settings.js'),
},
updatenotification: {
updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'init.js'),
init: path.join(__dirname, 'apps/updatenotification/src', 'init.ts'),
'view-changelog-page': path.join(__dirname, 'apps/updatenotification/src', 'view-changelog-page.ts'),
updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'updatenotification.js'),
},
user_status: {
menu: path.join(__dirname, 'apps/user_status/src', 'menu.js'),