Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7a24394
feat(webpush): Receive push notifications with web push
p1gp1g Nov 27, 2025
3560525
feat(webpush): Do not fetch on (web) push if notify_push is used
p1gp1g Nov 28, 2025
d9c03cb
feat(webpush): Fix webpush setup after permission granted
p1gp1g Nov 28, 2025
26355cb
feat(webpush): Use new appTypes format
p1gp1g Feb 12, 2026
6fe15ae
feat(webpush): Lint
p1gp1g Feb 12, 2026
3e34f6c
feat(webpush): Add serviceWorker to OpenApi
p1gp1g Feb 12, 2026
8b1ea99
feat(webpush): Fix force param
p1gp1g Feb 12, 2026
b766876
feat(webpush): Lint js
p1gp1g Feb 12, 2026
78d4d65
feat(webpush): Allow using webpush from web session
p1gp1g Feb 12, 2026
fab581e
feat(webpush): typo
p1gp1g Feb 12, 2026
ded6dd6
feat(webpush): Add push subscription options
p1gp1g Feb 12, 2026
85cf5b6
feat(webpush): Fix OpenAPI for service worker
p1gp1g Feb 12, 2026
bf102db
feat(webpush): Improve notification content from the background
p1gp1g Feb 13, 2026
1e69c30
feat(webpush): Add hack to avoid being unregistered on silent notific…
p1gp1g Feb 13, 2026
e4ec6ef
feat(webpush): Subscribe with server VAPID key
p1gp1g Feb 16, 2026
3c750da
feat(webpush): Fix push for web session with ID=0
p1gp1g Feb 16, 2026
bf8f2f4
feat(webpush): Use single B64 URL-safe function
p1gp1g Feb 16, 2026
7e12f36
feat(webpush): Register with userVisibleOnly, only if silent push are…
p1gp1g Feb 16, 2026
df0292f
feat(webpush): Show silent notification only if needed
p1gp1g Feb 16, 2026
fa9d612
fix(webpush): Add worker src domain self
nickvergessen Feb 17, 2026
c270710
Update lib/Controller/WebController.php
p1gp1g Feb 17, 2026
993c304
feat(webpush): Call callback(false) if browser doesn't support servic…
p1gp1g Feb 17, 2026
3f733f1
feat(webpush): Use full name for arguments
p1gp1g Feb 17, 2026
0347024
feat(webpush): Keep requestWebNotificationPermissions async
p1gp1g Feb 17, 2026
730f420
feat(webpush): Move web push function to dedicated service file
p1gp1g Feb 17, 2026
9017d4d
fix: Add missing class imports
nickvergessen Feb 17, 2026
1129957
feat(webpush): Use better secure context check
p1gp1g Feb 17, 2026
fa870cb
feat(webpush): Lint debugging line
p1gp1g Feb 17, 2026
11109c7
feat(webpush): Always catch when browser need userVisibleOnly
p1gp1g Feb 17, 2026
17d09f6
feat(webpush): Avoid redundant code to set web push
p1gp1g Feb 17, 2026
b962973
feat(webpush): Use web push dedicated function to fetch notifications
p1gp1g Feb 17, 2026
8c22c5e
feat(webpush): Add SPDX header
p1gp1g Feb 17, 2026
d380839
Fix hasNotifyPush check
p1gp1g Feb 17, 2026
946a5e7
chore(assets): Recompile assets
nickvergessen Feb 24, 2026
a42e9db
fix(webpush): Fix psalm in WebController
nickvergessen Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use OCA\Notifications\Capabilities;
use OCA\Notifications\Listener\AddMissingIndicesListener;
use OCA\Notifications\Listener\BeforeTemplateRenderedListener;
use OCA\Notifications\Listener\CSPListener;
use OCA\Notifications\Listener\UserCreatedListener;
use OCA\Notifications\Listener\UserDeletedListener;
use OCA\Notifications\Notifier\AdminNotifications;
Expand All @@ -23,6 +24,7 @@
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\DB\Events\AddMissingIndicesEvent;
use OCP\Notification\IManager;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;
use OCP\User\Events\UserCreatedEvent;
use OCP\User\Events\UserDeletedEvent;

Expand All @@ -41,6 +43,7 @@ public function register(IRegistrationContext $context): void {

$context->registerNotifierService(AdminNotifications::class);

$context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);
$context->registerEventListener(AddMissingIndicesEvent::class, AddMissingIndicesListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
Expand Down
55 changes: 55 additions & 0 deletions lib/Controller/WebController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Notifications\Controller;

use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\StreamResponse;
use OCP\IRequest;

#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class WebController extends Controller {
public function __construct(
string $appName,
IRequest $request,
) {
parent::__construct($appName, $request);
}
/**
* Return the service worker with `Service-Worker-Allowed: /` header
*
* @return StreamResponse<Http::STATUS_OK, array{'Content-Type': 'application/javascript', 'Service-Worker-Allowed': '/'}>
*
* 200: The service worker
*/
#[PublicPage]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'GET', url: '/service-worker.js')]
public function serviceWorker(): StreamResponse {
$response = new StreamResponse(
__DIR__ . '/../../service-worker.js',
headers: [
'Content-Type' => 'application/javascript',
'Service-Worker-Allowed' => '/'
]
);
$policy = new ContentSecurityPolicy();
$policy->addAllowedWorkerSrcDomain("'self'");
$policy->addAllowedScriptDomain("'self'");
$policy->addAllowedConnectDomain("'self'");
$response->setContentSecurityPolicy($policy);
return $response;
}
}
81 changes: 53 additions & 28 deletions lib/Controller/WebPushController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use OCP\ISession;
use OCP\IUser;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
use Symfony\Component\Uid\Uuid;

enum NewSubStatus: int {
Expand All @@ -53,6 +54,7 @@ public function __construct(
protected IUserSession $userSession,
protected IProvider $tokenProvider,
protected Manager $identityProof,
protected LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -114,13 +116,15 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth,
}

$tokenId = $this->session->get('token-id');
if (!\is_int($tokenId)) {
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST);
}
try {
$token = $this->tokenProvider->getTokenById($tokenId);
} catch (InvalidTokenException) {
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST);
if (\is_null($tokenId)) {
$token = $this->session;
} else {
try {
$token = $this->tokenProvider->getTokenById($tokenId);
} catch (InvalidTokenException $e) {
$this->logger->error('Invalid token exception', ['exception' => $e]);
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST);
}
}

[$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes);
Expand Down Expand Up @@ -158,11 +162,16 @@ public function activateWP(string $activationToken): DataResponse {
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
}

$tokenId = (int)$this->session->get('token-id');
try {
$token = $this->tokenProvider->getTokenById($tokenId);
} catch (InvalidTokenException) {
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST);
$tokenId = $this->session->get('token-id');
if (\is_null($tokenId)) {
$token = $this->session;
} else {
try {
$token = $this->tokenProvider->getTokenById($tokenId);
} catch (InvalidTokenException $e) {
$this->logger->error('Invalid token exception', ['exception' => $e]);
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST);
}
}

$status = $this->activateSubscription($user, $token, $activationToken);
Expand Down Expand Up @@ -193,11 +202,16 @@ public function removeWP(): DataResponse {
return new DataResponse([], Http::STATUS_UNAUTHORIZED);
}

$tokenId = (int)$this->session->get('token-id');
try {
$token = $this->tokenProvider->getTokenById($tokenId);
} catch (InvalidTokenException) {
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST);
$tokenId = $this->session->get('token-id');
if (\is_null($tokenId)) {
$token = $this->session;
} else {
try {
$token = $this->tokenProvider->getTokenById($tokenId);
} catch (InvalidTokenException $e) {
$this->logger->error('Invalid token exception', ['exception' => $e]);
return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST);
}
}

if ($this->deleteSubscription($user, $token)) {
Expand All @@ -218,12 +232,12 @@ protected function getWPClient(): WebPushClient {
* - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth
* - UPDATED if the subscription has been updated (use to change appTypes)
*/
protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): array {
protected function saveSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): array {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_webpush')
->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID())))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId())))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token))))
->andWhere($query->expr()->eq('endpoint', $query->createNamedParameter($endpoint)))
->andWhere($query->expr()->eq('ua_public', $query->createNamedParameter($uaPublicKey)))
->andWhere($query->expr()->eq('auth', $query->createNamedParameter($auth)))
Expand Down Expand Up @@ -256,12 +270,12 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint
* - NO_TOKEN if we don't have this token
* - NO_SUB if we don't have this subscription
*/
protected function activateSubscription(IUser $user, IToken $token, string $activationToken): ActivationSubStatus {
protected function activateSubscription(IUser $user, IToken|ISession $token, string $activationToken): ActivationSubStatus {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_webpush')
->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID())))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId())));
->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token))));
$result = $query->executeQuery();
$row = $result->fetch();
$result->closeCursor();
Expand All @@ -275,7 +289,7 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti
$query->update('notifications_webpush')
->set('activated', $query->createNamedParameter(true))
->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID())))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->eq('activation_token', $query->createNamedParameter($activationToken)));

if ($query->executeStatement() !== 0) {
Expand All @@ -288,12 +302,12 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti
* @param string $appTypes comma separated list of types
* @return bool If the entry was created
*/
protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, string $appTypes): bool {
protected function insertSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, string $appTypes): bool {
$query = $this->db->getQueryBuilder();
$query->insert('notifications_webpush')
->values([
'uid' => $query->createNamedParameter($user->getUID()),
'token' => $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT),
'token' => $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT),
'endpoint' => $query->createNamedParameter($endpoint),
'ua_public' => $query->createNamedParameter($uaPublicKey),
'auth' => $query->createNamedParameter($auth),
Expand All @@ -307,28 +321,39 @@ protected function insertSubscription(IUser $user, IToken $token, string $endpoi
* @param string $appTypes comma separated list of types
* @return bool If the entry was updated
*/
protected function updateSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): bool {
protected function updateSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): bool {
$query = $this->db->getQueryBuilder();
$query->update('notifications_webpush')
->set('endpoint', $query->createNamedParameter($endpoint))
->set('ua_public', $query->createNamedParameter($uaPublicKey))
->set('auth', $query->createNamedParameter($auth))
->set('app_types', $query->createNamedParameter($appTypes))
->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID())))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT)));
->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT)));

return $query->executeStatement() !== 0;
}

/**
* @return bool If the entry was deleted
*/
protected function deleteSubscription(IUser $user, IToken $token): bool {
protected function deleteSubscription(IUser $user, IToken|ISession $token): bool {
$query = $this->db->getQueryBuilder();
$query->delete('notifications_webpush')
->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID())))
->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT)));
->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT)));

return $query->executeStatement() !== 0;
}

/**
* @return Int tokenId from IToken or ISession, negative in case of Session id, positive otherwise
*/
private function getId(IToken|ISession $token): Int {
return match(true) {
$token instanceof IToken => $token->getId(),
// (-id - 1) to avoid session with id = 0
$token instanceof ISession => -1 - (int)$token->getId(),
};
}
}
42 changes: 42 additions & 0 deletions lib/Listener/CSPListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Notifications\Listener;

use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Security\CSP\AddContentSecurityPolicyEvent;

/**
* @template-implements IEventListener<Event>
*/
readonly class CSPListener implements IEventListener {
public function __construct(
protected IUserSession $userSession,
) {
}

#[\Override]
public function handle(Event $event): void {
if (!($event instanceof AddContentSecurityPolicyEvent)) {
return;
}

$user = $this->userSession->getUser();
if (!$user instanceof IUser) {
return;
}

$csp = new ContentSecurityPolicy();
$csp->addAllowedWorkerSrcDomain("'self'");
$event->addPolicy($csp);
}
}
4 changes: 4 additions & 0 deletions lib/Push.php
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,10 @@ protected function sendNotificationsToProxies(): void {
}

protected function validateToken(int $tokenId, int $maxAge): TokenValidation {
// This is a web session token
if ($tokenId < 0) {
return TokenValidation::VALID;
}
$age = $this->cache->get('t' . $tokenId);

if ($age === null) {
Expand Down
Loading
Loading