From 34fcc5d2c7786fa130ce2c81e11f9f0585761a53 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 9 Oct 2025 05:34:31 +0200 Subject: [PATCH] chore: merge upstream (#2024) --- .../migrations/0002_migrate_apitokens.ts | 2 +- src/components/ServiceWorkerSetup/index.tsx | 47 +++- .../UserNotificationsWebPush/DeviceItem.tsx | 35 ++- .../UserNotificationsWebPush/index.tsx | 259 ++++++++---------- src/i18n/locale/en.json | 1 + src/utils/pushSubscriptionHelpers.ts | 162 +++++++++++ 6 files changed, 356 insertions(+), 150 deletions(-) create mode 100644 src/utils/pushSubscriptionHelpers.ts diff --git a/server/lib/settings/migrations/0002_migrate_apitokens.ts b/server/lib/settings/migrations/0002_migrate_apitokens.ts index 039067826..dcfa327f8 100644 --- a/server/lib/settings/migrations/0002_migrate_apitokens.ts +++ b/server/lib/settings/migrations/0002_migrate_apitokens.ts @@ -8,7 +8,7 @@ import { getHostname } from '@server/utils/getHostname'; const migrateApiTokens = async (settings: any): Promise => { const mediaServerType = settings.main.mediaServerType; if ( - !settings.jellyfin.apiKey && + !settings.jellyfin?.apiKey && (mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY) ) { diff --git a/src/components/ServiceWorkerSetup/index.tsx b/src/components/ServiceWorkerSetup/index.tsx index 2e0313f4d..929580a7c 100644 --- a/src/components/ServiceWorkerSetup/index.tsx +++ b/src/components/ServiceWorkerSetup/index.tsx @@ -1,10 +1,14 @@ /* eslint-disable no-console */ +import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; +import { verifyAndResubscribePushSubscription } from '@app/utils/pushSubscriptionHelpers'; import { useEffect } from 'react'; const ServiceWorkerSetup = () => { const { user } = useUser(); + const { currentSettings } = useSettings(); + useEffect(() => { if ('serviceWorker' in navigator && user?.id) { navigator.serviceWorker @@ -14,12 +18,53 @@ const ServiceWorkerSetup = () => { '[SW] Registration successful, scope is:', registration.scope ); + + const pushNotificationsEnabled = + localStorage.getItem('pushNotificationsEnabled') === 'true'; + + // Reset the notifications flag if permissions were revoked + if ( + Notification.permission !== 'granted' && + pushNotificationsEnabled + ) { + localStorage.setItem('pushNotificationsEnabled', 'false'); + console.warn( + '[SW] Push permissions not granted — skipping resubscribe' + ); + + return; + } + + // Bypass resubscribing if we have manually disabled push notifications + if (!pushNotificationsEnabled) { + return; + } + + const subscription = await registration.pushManager.getSubscription(); + + console.log( + '[SW] Existing push subscription:', + subscription?.endpoint + ); + + const verified = await verifyAndResubscribePushSubscription( + user.id, + currentSettings + ); + + if (verified) { + console.log('[SW] Push subscription verified or refreshed.'); + } else { + console.warn( + '[SW] Push subscription verification failed or not available.' + ); + } }) .catch(function (error) { console.log('[SW] Service worker registration failed, error:', error); }); } - }, [user]); + }, [currentSettings, user]); return null; }; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx index 7fdefa4a5..55922ed9a 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/DeviceItem.tsx @@ -1,16 +1,18 @@ +import Button from '@app/components/Common/Button'; import ConfirmButton from '@app/components/Common/ConfirmButton'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { ComputerDesktopIcon, DevicePhoneMobileIcon, + LockClosedIcon, TrashIcon, } from '@heroicons/react/24/solid'; import { useIntl } from 'react-intl'; import { UAParser } from 'ua-parser-js'; interface DeviceItemProps { - disablePushNotifications: (p256dh: string) => void; + deletePushSubscriptionFromBackend: (endpoint: string) => void; device: { endpoint: string; p256dh: string; @@ -18,6 +20,7 @@ interface DeviceItemProps { userAgent: string; createdAt: Date; }; + subEndpoint: string | null; } const messages = defineMessages( @@ -28,10 +31,15 @@ const messages = defineMessages( engine: 'Engine', deletesubscription: 'Delete Subscription', unknown: 'Unknown', + activesubscription: 'Active Subscription', } ); -const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => { +const DeviceItem = ({ + deletePushSubscriptionFromBackend, + device, + subEndpoint, +}: DeviceItemProps) => { const intl = useIntl(); const parsedUserAgent = UAParser(device.userAgent); @@ -91,14 +99,21 @@ const DeviceItem = ({ disablePushNotifications, device }: DeviceItemProps) => {
- disablePushNotifications(device.endpoint)} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - {intl.formatMessage(messages.deletesubscription)} - + {subEndpoint === device.endpoint ? ( + + ) : ( + deletePushSubscriptionFromBackend(device.endpoint)} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + {intl.formatMessage(messages.deletesubscription)} + + )}
); diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx index 11e082901..fbe03f7cc 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx @@ -9,17 +9,22 @@ import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { + getPushSubscription, + subscribeToPushNotifications, + unsubscribeToPushNotifications, + verifyPushSubscription, +} from '@app/utils/pushSubscriptionHelpers'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; import { CloudArrowDownIcon, CloudArrowUpIcon, } from '@heroicons/react/24/solid'; -import type { UserPushSubscription } from '@server/entity/UserPushSubscription'; import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces'; import axios from 'axios'; import { Form, Formik } from 'formik'; import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; @@ -53,6 +58,7 @@ const UserWebPushSettings = () => { const { user } = useUser({ id: Number(router.query.userId) }); const { currentSettings } = useSettings(); const [webPushEnabled, setWebPushEnabled] = useState(false); + const [subEndpoint, setSubEndpoint] = useState(null); const { data, error, @@ -72,141 +78,122 @@ const UserWebPushSettings = () => { // Subscribes to the push manager // Will only add to the database if subscribing for the first time - const enablePushNotifications = () => { - if ('serviceWorker' in navigator && user?.id) { - navigator.serviceWorker - .getRegistration('/sw.js') - .then(async (registration) => { - if (currentSettings.enablePushRegistration) { - const sub = await registration?.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: currentSettings.vapidPublic, - }); - const parsedSub = JSON.parse(JSON.stringify(sub)); + const enablePushNotifications = async () => { + try { + const isSubscribed = await subscribeToPushNotifications( + user?.id, + currentSettings + ); - if (parsedSub.keys.p256dh && parsedSub.keys.auth) { - await axios.post('/api/v1/user/registerPushSubscription', { - endpoint: parsedSub.endpoint, - p256dh: parsedSub.keys.p256dh, - auth: parsedSub.keys.auth, - userAgent: navigator.userAgent, - }); - setWebPushEnabled(true); - addToast(intl.formatMessage(messages.webpushhasbeenenabled), { - appearance: 'success', - autoDismiss: true, - }); - } - } - }) - .catch(function () { - addToast(intl.formatMessage(messages.enablingwebpusherror), { - autoDismiss: true, - appearance: 'error', - }); - }) - .finally(function () { - revalidateDevices(); + if (isSubscribed) { + localStorage.setItem('pushNotificationsEnabled', 'true'); + setWebPushEnabled(true); + addToast(intl.formatMessage(messages.webpushhasbeenenabled), { + appearance: 'success', + autoDismiss: true, }); + } else { + throw new Error('Subscription failed'); + } + } catch (error) { + addToast(intl.formatMessage(messages.enablingwebpusherror), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidateDevices(); } }; // Unsubscribes from the push manager // Deletes/disables corresponding push subscription from database const disablePushNotifications = async (endpoint?: string) => { - if ('serviceWorker' in navigator && user?.id) { - navigator.serviceWorker.getRegistration('/sw.js').then((registration) => { - registration?.pushManager - .getSubscription() - .then(async (subscription) => { - const parsedSub = JSON.parse(JSON.stringify(subscription)); + try { + await unsubscribeToPushNotifications(user?.id, endpoint); - await axios.delete( - `/api/v1/user/${user.id}/pushSubscription/${encodeURIComponent( - endpoint ?? parsedSub.endpoint - )}` - ); - - if ( - subscription && - (endpoint === parsedSub.endpoint || !endpoint) - ) { - subscription.unsubscribe(); - setWebPushEnabled(false); - } - addToast( - intl.formatMessage( - endpoint - ? messages.subscriptiondeleted - : messages.webpushhasbeendisabled - ), - { - autoDismiss: true, - appearance: 'success', - } - ); - }) - .catch(function () { - addToast( - intl.formatMessage( - endpoint - ? messages.subscriptiondeleteerror - : messages.disablingwebpusherror - ), - { - autoDismiss: true, - appearance: 'error', - } - ); - }) - .finally(function () { - revalidateDevices(); - }); + localStorage.setItem('pushNotificationsEnabled', 'false'); + setWebPushEnabled(false); + addToast(intl.formatMessage(messages.webpushhasbeendisabled), { + autoDismiss: true, + appearance: 'success', }); + } catch (error) { + addToast(intl.formatMessage(messages.disablingwebpusherror), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidateDevices(); } }; - // Checks our current subscription on page load - // Will set the web push state to true if subscribed - useEffect(() => { - if ('serviceWorker' in navigator && user?.id) { - navigator.serviceWorker - .getRegistration('/sw.js') - .then(async (registration) => { - await registration?.pushManager - .getSubscription() - .then(async (subscription) => { - if (subscription) { - const parsedKey = JSON.parse(JSON.stringify(subscription)); - const currentUserPushSub = - await axios.get( - `/api/v1/user/${ - user.id - }/pushSubscription/${encodeURIComponent( - parsedKey.endpoint - )}` - ); + const deletePushSubscriptionFromBackend = async (endpoint: string) => { + try { + await axios.delete( + `/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent( + endpoint + )}` + ); - if (currentUserPushSub.data.endpoint !== parsedKey.endpoint) { - return; - } - - setWebPushEnabled(true); - } else { - setWebPushEnabled(false); - } - }); - }) - .catch(function (error) { - setWebPushEnabled(false); - // eslint-disable-next-line no-console - console.log( - '[SW] Failure retrieving push manager subscription, error:', - error - ); - }); + addToast(intl.formatMessage(messages.subscriptiondeleted), { + autoDismiss: true, + appearance: 'success', + }); + } catch (error) { + addToast(intl.formatMessage(messages.subscriptiondeleteerror), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidateDevices(); } - }, [user?.id]); + }; + + useEffect(() => { + const verifyWebPush = async () => { + const enabled = await verifyPushSubscription(user?.id, currentSettings); + setWebPushEnabled(enabled); + }; + + if (user?.id) { + verifyWebPush(); + } + }, [user?.id, currentSettings]); + + useEffect(() => { + const getSubscriptionEndpoint = async () => { + if ('serviceWorker' in navigator && 'PushManager' in window) { + const { subscription } = await getPushSubscription(); + + if (subscription) { + setSubEndpoint(subscription.endpoint); + } else { + setSubEndpoint(null); + } + } + }; + + getSubscriptionEndpoint(); + }, [webPushEnabled]); + + const sortedDevices = useMemo(() => { + if (!dataDevices || !subEndpoint) { + return dataDevices; + } + + return [...dataDevices].sort((a, b) => { + if (a.endpoint === subEndpoint) { + return -1; + } + if (b.endpoint === subEndpoint) { + return 1; + } + + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return dateB - dateA; + }); + }, [dataDevices, subEndpoint]); if (!data && !error) { return ; @@ -324,22 +311,18 @@ const UserWebPushSettings = () => { {intl.formatMessage(messages.managedevices)}
- {dataDevices?.length ? ( - dataDevices - ?.sort((a, b) => { - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return dateB - dateA; - }) - .map((device, index) => ( -
- -
- )) + {sortedDevices?.length ? ( + sortedDevices.map((device) => ( +
+ +
+ )) ) : ( <> { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + return { registration, subscription }; +}; + +export const verifyPushSubscription = async ( + userId: number | undefined, + currentSettings: PublicSettingsResponse +): Promise => { + if (!('serviceWorker' in navigator) || !userId) { + return false; + } + + try { + const { subscription } = await getPushSubscription(); + + if (!subscription) { + return false; + } + + const appServerKey = subscription.options?.applicationServerKey; + if (!(appServerKey instanceof ArrayBuffer)) { + return false; + } + + const currentServerKey = new Uint8Array(appServerKey).toString(); + const expectedServerKey = urlBase64ToUint8Array( + currentSettings.vapidPublic + ).toString(); + + const endpoint = subscription.endpoint; + + const { data } = await axios.get( + `/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}` + ); + + return expectedServerKey === currentServerKey && data.endpoint === endpoint; + } catch { + return false; + } +}; + +export const verifyAndResubscribePushSubscription = async ( + userId: number | undefined, + currentSettings: PublicSettingsResponse +): Promise => { + const isValid = await verifyPushSubscription(userId, currentSettings); + + if (isValid) { + return true; + } + + if (currentSettings.enablePushRegistration) { + try { + // Unsubscribe from the backend to clear the existing push subscription (keys and endpoint) + await unsubscribeToPushNotifications(userId); + + // Subscribe again to generate a fresh push subscription with updated keys and endpoint + await subscribeToPushNotifications(userId, currentSettings); + + return true; + } catch (error) { + throw new Error(`[SW] Resubscribe failed: ${error.message}`); + } + } + + return false; +}; + +export const subscribeToPushNotifications = async ( + userId: number | undefined, + currentSettings: PublicSettingsResponse +) => { + if ( + !('serviceWorker' in navigator) || + !userId || + !currentSettings.enablePushRegistration + ) { + return false; + } + + try { + const { registration } = await getPushSubscription(); + + if (!registration) { + return false; + } + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: currentSettings.vapidPublic, + }); + + const { endpoint, keys } = subscription.toJSON(); + + if (keys?.p256dh && keys?.auth) { + await axios.post('/api/v1/user/registerPushSubscription', { + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + userAgent: navigator.userAgent, + }); + + return true; + } + + return false; + } catch (error) { + throw new Error( + `Issue subscribing to push notifications: ${error.message}` + ); + } +}; + +export const unsubscribeToPushNotifications = async ( + userId: number | undefined, + endpoint?: string +) => { + if (!('serviceWorker' in navigator) || !userId) { + return; + } + + try { + const { subscription } = await getPushSubscription(); + + if (!subscription) { + return false; + } + + const { endpoint: currentEndpoint } = subscription.toJSON(); + + if (!endpoint || endpoint === currentEndpoint) { + await subscription.unsubscribe(); + return true; + } + } catch (error) { + throw new Error( + `Issue unsubscribing to push notifications: ${error.message}` + ); + } +};