Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b1438de
fix(webpush): improve iOS push subscription endpoint cleanup
0xSysR3ll Nov 11, 2025
aedcd32
fix(webpush): improve push notification error handling
0xSysR3ll Nov 15, 2025
db96a8c
fix(webpush): add logs for AggregateError error
0xSysR3ll Nov 18, 2025
636dfbc
fix(webpush): update existing subscriptions with new keys
0xSysR3ll Nov 25, 2025
f793f85
fix(webpush): clean up stale push subscriptions for the same device
0xSysR3ll Nov 25, 2025
dbfc516
fix(webpush): cleanup is too agressive - avoid removing active subscr…
0xSysR3ll Dec 3, 2025
8489468
fix(webpush): preserve original creation timestamp when updating subs…
0xSysR3ll Dec 3, 2025
b52da81
fix(webpush): use transaction for race condition prevention
0xSysR3ll Dec 6, 2025
60d4d15
fix(webpush): store push notification status in localStorage
0xSysR3ll Dec 7, 2025
14bb6bb
fix(webpush): add backend subscription check to determine if a valid …
0xSysR3ll Dec 7, 2025
db25c27
refactor(webpush): Remove nested error checks
0xSysR3ll Dec 7, 2025
19f9906
fix(webpush): add user ID validation to push subscription verification
0xSysR3ll Dec 7, 2025
0b70215
fix(webpush): remove the redundant userId check
0xSysR3ll Dec 7, 2025
c5a5be4
fix(webpush): update existing subscriptions with new keys only if the…
0xSysR3ll Dec 7, 2025
fa32814
fix(webpush): update localStorage handling for push notification status
0xSysR3ll Dec 7, 2025
4663290
fix(webpush): remove unnecessary dependency for user ID verification
0xSysR3ll Dec 7, 2025
8bc0c1e
fix(webpush): remove redundant backend subscription checks
0xSysR3ll Dec 7, 2025
790ecf4
fix(webpush): delete push subscriptions for multiple devices
0xSysR3ll Dec 7, 2025
e21bb46
fix(webpush): remove backend checks
0xSysR3ll Dec 7, 2025
edc4352
fix(webpush): notification must reflect the actual outcome
0xSysR3ll Dec 7, 2025
ad40b43
fix(webpush): throw error after notification failure
0xSysR3ll Dec 7, 2025
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
28 changes: 24 additions & 4 deletions server/lib/notifications/agents/webpush.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ interface PushNotificationPayload {
isAdmin?: boolean;
}

interface WebPushError extends Error {
statusCode?: number;
status?: number;
body?: string | unknown;
response?: {
body?: string | unknown;
};
}

class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent
Expand Down Expand Up @@ -188,19 +197,30 @@ class WebPushAgent
notificationPayload
);
} catch (e) {
const webPushError = e as WebPushError;
const statusCode = webPushError.statusCode || webPushError.status;
const errorMessage = webPushError.message || String(e);

// RFC 8030: 410/404 are permanent failures, others are transient
const isPermanentFailure = statusCode === 410 || statusCode === 404;

logger.error(
'Error sending web push notification; removing subscription',
isPermanentFailure
? 'Error sending web push notification; removing invalid subscription'
: 'Error sending web push notification (transient error, keeping subscription)',
{
label: 'Notifications',
recipient: pushSub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
errorMessage,
statusCode: statusCode || 'unknown',
}
);

// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(pushSub);
if (isPermanentFailure) {
await userPushSubRepository.remove(pushSub);
}
}
};

Expand Down
92 changes: 80 additions & 12 deletions server/routes/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import dataSource, { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
Expand All @@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm';
import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import userSettingsRoutes from './usersettings';

const router = Router();
Expand Down Expand Up @@ -190,28 +191,95 @@ router.post<
try {
const userPushSubRepository = getRepository(UserPushSubscription);

const existingSubs = await userPushSubRepository.find({
const existingByAuth = await userPushSubRepository.findOne({
relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } },
});

if (existingSubs.length > 0) {
if (existingByAuth) {
logger.debug(
'User push subscription already exists. Skipping registration.',
{ label: 'API' }
);
return res.status(204).send();
}

const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
// This prevents race conditions where two requests both pass the checks
await dataSource.transaction(
async (transactionalEntityManager: EntityManager) => {
const transactionalRepo =
transactionalEntityManager.getRepository(UserPushSubscription);

// Check for existing subscription by auth or endpoint within transaction
const existingSubscription = await transactionalRepo.findOne({
relations: { user: true },
where: [
{ auth: req.body.auth, user: { id: req.user?.id } },
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
],
});

userPushSubRepository.save(userPushSubscription);
if (existingSubscription) {
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
if (
existingSubscription.endpoint === req.body.endpoint &&
existingSubscription.auth !== req.body.auth
) {
existingSubscription.auth = req.body.auth;
existingSubscription.p256dh = req.body.p256dh;
existingSubscription.userAgent = req.body.userAgent;

await transactionalRepo.save(existingSubscription);

logger.debug(
'Updated existing push subscription with new keys for same endpoint.',
{ label: 'API' }
);
return;
}

logger.debug(
'Duplicate subscription detected. Skipping registration.',
{ label: 'API' }
);
return;
}

// Clean up old subscriptions from the same device (userAgent) for this user
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
// Only clean up if we're creating a new subscription (not updating an existing one)
if (req.body.userAgent) {
const staleSubscriptions = await transactionalRepo.find({
relations: { user: true },
where: {
userAgent: req.body.userAgent,
user: { id: req.user?.id },
// Only remove subscriptions with different endpoints (stale ones)
// Keep subscriptions that might be from different browsers/tabs
endpoint: Not(req.body.endpoint),
},
});

if (staleSubscriptions.length > 0) {
await transactionalRepo.remove(staleSubscriptions);
logger.debug(
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
{ label: 'API' }
);
}
}

const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});

await transactionalRepo.save(userPushSubscription);
}
);

return res.status(204).send();
} catch (e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,34 @@ const UserWebPushSettings = () => {
// Deletes/disables corresponding push subscription from database
const disablePushNotifications = async (endpoint?: string) => {
try {
await unsubscribeToPushNotifications(user?.id, endpoint);
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
user?.id,
endpoint
);

// Delete from backend if endpoint is available
if (subEndpoint) {
await deletePushSubscriptionFromBackend(subEndpoint);
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;

if (endpointToDelete) {
await deletePushSubscriptionFromBackend(endpointToDelete);
} else if (dataDevices && dataDevices.length > 0) {
let hasFailures = false;

for (const device of dataDevices) {
try {
await deletePushSubscriptionFromBackend(device.endpoint);
} catch (error) {
hasFailures = true;
}
}

if (hasFailures) {
addToast(intl.formatMessage(messages.disablingwebpusherror), {
autoDismiss: true,
appearance: 'error',
});
return;
}
}

localStorage.setItem('pushNotificationsEnabled', 'false');
Expand Down Expand Up @@ -149,6 +172,7 @@ const UserWebPushSettings = () => {
autoDismiss: true,
appearance: 'error',
});
throw error;
} finally {
revalidateDevices();
}
Expand All @@ -158,6 +182,10 @@ const UserWebPushSettings = () => {
const verifyWebPush = async () => {
const enabled = await verifyPushSubscription(user?.id, currentSettings);
setWebPushEnabled(enabled);
localStorage.setItem(
'pushNotificationsEnabled',
enabled ? 'true' : 'false'
);
};

if (user?.id) {
Expand Down
63 changes: 52 additions & 11 deletions src/utils/pushSubscriptionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,24 @@ export const verifyPushSubscription = async (
currentSettings.vapidPublic
).toString();

const endpoint = subscription.endpoint;
if (currentServerKey !== expectedServerKey) {
return false;
}

const { data } = await axios.get<UserPushSubscription>(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
);
const endpoint = subscription.endpoint;

return expectedServerKey === currentServerKey && data.endpoint === endpoint;
} catch {
try {
const { data } = await axios.get<UserPushSubscription>(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
endpoint
)}`
);

return data.endpoint === endpoint;
} catch {
return false;
}
} catch (error) {
return false;
}
};
Expand All @@ -65,20 +75,49 @@ export const verifyAndResubscribePushSubscription = async (
userId: number | undefined,
currentSettings: PublicSettingsResponse
): Promise<boolean> => {
if (!userId) {
return false;
}

const isValid = await verifyPushSubscription(userId, currentSettings);

if (isValid) {
return true;
}

try {
const { data: backendSubscriptions } = await axios.get<
UserPushSubscription[]
>(`/api/v1/user/${userId}/pushSubscriptions`);

if (backendSubscriptions.length > 0) {
return true;
}
} catch {
// Continue with resubscribe logic
}

if (currentSettings.enablePushRegistration) {
try {
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
await unsubscribeToPushNotifications(userId);
const oldEndpoint = await unsubscribeToPushNotifications(userId);

// Subscribe again to generate a fresh push subscription with updated keys and endpoint
await subscribeToPushNotifications(userId, currentSettings);

if (oldEndpoint) {
try {
await axios.delete(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
oldEndpoint
)}`
);
} catch (error) {
// Ignore errors when deleting old endpoint (it might not exist)
// This is expected when the endpoint was already cleaned up
}
}

return true;
} catch (error) {
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
Expand Down Expand Up @@ -136,24 +175,26 @@ export const subscribeToPushNotifications = async (
export const unsubscribeToPushNotifications = async (
userId: number | undefined,
endpoint?: string
) => {
): Promise<string | null> => {
if (!('serviceWorker' in navigator) || !userId) {
return;
return null;
}

try {
const { subscription } = await getPushSubscription();

if (!subscription) {
return false;
return null;
}

const { endpoint: currentEndpoint } = subscription.toJSON();

if (!endpoint || endpoint === currentEndpoint) {
await subscription.unsubscribe();
return true;
return currentEndpoint ?? null;
}

return null;
} catch (error) {
throw new Error(
`Issue unsubscribing to push notifications: ${error.message}`
Expand Down
Loading