mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-11 09:06:55 -05:00
Compare commits
5 Commits
0xsysr3ll/
...
pr-2273
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb672ec3c4 | ||
|
|
d0c9afc16e | ||
|
|
57d583e1bd | ||
|
|
8bbe7864af | ||
|
|
66b4e2c871 |
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,6 +91,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Additional Context
|
label: Additional Context
|
||||||
description: Please provide any additional information that may be relevant or helpful.
|
description: Please provide any additional information that may be relevant or helpful.
|
||||||
|
- type: checkboxes
|
||||||
|
id: search-existing
|
||||||
|
attributes:
|
||||||
|
label: Search Existing Issues
|
||||||
|
description: Have you searched existing issues to see if this bug has already been reported?
|
||||||
|
options:
|
||||||
|
- label: Yes, I have searched existing issues.
|
||||||
|
required: true
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,6 +27,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Additional Context
|
label: Additional Context
|
||||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||||
|
- type: checkboxes
|
||||||
|
id: search-existing
|
||||||
|
attributes:
|
||||||
|
label: Search Existing Issues
|
||||||
|
description: Have you searched existing issues to see if this feature has already been requested?
|
||||||
|
options:
|
||||||
|
- label: Yes, I have searched existing issues.
|
||||||
|
required: true
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Check out our documentation for instructions on how to install and run Seerr:
|
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||||
|
|
||||||
https://docs.seerr.dev/getting-started/
|
https://docs.seerr.dev/getting-started/
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Seerr is not officially released yet.**
|
||||||
|
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||||
|
|
||||||
|
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||||
|
|
||||||
|
Instead, follow the dedicated migration guide:
|
||||||
|
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||||
|
|
||||||
|
> [!DANGER]
|
||||||
|
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database.**
|
||||||
|
> Doing so **will cause database corruption and/or irreversible data loss**.
|
||||||
|
|
||||||
|
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||
|
|
||||||
<img src="./public/preview.jpg">
|
<img src="./public/preview.jpg">
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@Unique(['endpoint', 'user'])
|
||||||
export class UserPushSubscription {
|
export class UserPushSubscription {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ interface PushNotificationPayload {
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WebPushError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
status?: number;
|
||||||
|
body?: string | unknown;
|
||||||
|
response?: {
|
||||||
|
body?: string | unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class WebPushAgent
|
class WebPushAgent
|
||||||
extends BaseAgent<NotificationAgentConfig>
|
extends BaseAgent<NotificationAgentConfig>
|
||||||
implements NotificationAgent
|
implements NotificationAgent
|
||||||
@@ -188,19 +197,30 @@ class WebPushAgent
|
|||||||
notificationPayload
|
notificationPayload
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} 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(
|
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',
|
label: 'Notifications',
|
||||||
recipient: pushSub.user.displayName,
|
recipient: pushSub.user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage,
|
||||||
|
statusCode: statusCode || 'unknown',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Failed to send notification so we need to remove the subscription
|
if (isPermanentFailure) {
|
||||||
userPushSubRepository.remove(pushSub);
|
await userPushSubRepository.remove(pushSub);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface ProcessOptions {
|
|||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
mediaAddedAt?: Date;
|
mediaAddedAt?: Date;
|
||||||
ratingKey?: string;
|
ratingKey?: string;
|
||||||
|
jellyfinMediaId?: string;
|
||||||
|
imdbId?: string;
|
||||||
serviceId?: number;
|
serviceId?: number;
|
||||||
externalServiceId?: number;
|
externalServiceId?: number;
|
||||||
externalServiceSlug?: string;
|
externalServiceSlug?: string;
|
||||||
@@ -95,6 +97,8 @@ class BaseScanner<T> {
|
|||||||
is4k = false,
|
is4k = false,
|
||||||
mediaAddedAt,
|
mediaAddedAt,
|
||||||
ratingKey,
|
ratingKey,
|
||||||
|
jellyfinMediaId,
|
||||||
|
imdbId,
|
||||||
serviceId,
|
serviceId,
|
||||||
externalServiceId,
|
externalServiceId,
|
||||||
externalServiceSlug,
|
externalServiceSlug,
|
||||||
@@ -133,6 +137,21 @@ class BaseScanner<T> {
|
|||||||
changedExisting = true;
|
changedExisting = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
jellyfinMediaId &&
|
||||||
|
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||||
|
jellyfinMediaId
|
||||||
|
) {
|
||||||
|
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||||
|
jellyfinMediaId;
|
||||||
|
changedExisting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imdbId && !existing.imdbId) {
|
||||||
|
existing.imdbId = imdbId;
|
||||||
|
changedExisting = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
serviceId !== undefined &&
|
serviceId !== undefined &&
|
||||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||||
@@ -173,6 +192,7 @@ class BaseScanner<T> {
|
|||||||
} else {
|
} else {
|
||||||
const newMedia = new Media();
|
const newMedia = new Media();
|
||||||
newMedia.tmdbId = tmdbId;
|
newMedia.tmdbId = tmdbId;
|
||||||
|
newMedia.imdbId = imdbId;
|
||||||
|
|
||||||
newMedia.status =
|
newMedia.status =
|
||||||
!is4k && !processing
|
!is4k && !processing
|
||||||
@@ -203,6 +223,13 @@ class BaseScanner<T> {
|
|||||||
newMedia.ratingKey4k =
|
newMedia.ratingKey4k =
|
||||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jellyfinMediaId) {
|
||||||
|
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||||
|
newMedia.jellyfinMediaId4k =
|
||||||
|
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
await mediaRepository.save(newMedia);
|
await mediaRepository.save(newMedia);
|
||||||
this.log(`Saved new media: ${title}`);
|
this.log(`Saved new media: ${title}`);
|
||||||
}
|
}
|
||||||
@@ -221,11 +248,12 @@ class BaseScanner<T> {
|
|||||||
*/
|
*/
|
||||||
protected async processShow(
|
protected async processShow(
|
||||||
tmdbId: number,
|
tmdbId: number,
|
||||||
tvdbId: number,
|
tvdbId: number | undefined,
|
||||||
seasons: ProcessableSeason[],
|
seasons: ProcessableSeason[],
|
||||||
{
|
{
|
||||||
mediaAddedAt,
|
mediaAddedAt,
|
||||||
ratingKey,
|
ratingKey,
|
||||||
|
jellyfinMediaId,
|
||||||
serviceId,
|
serviceId,
|
||||||
externalServiceId,
|
externalServiceId,
|
||||||
externalServiceSlug,
|
externalServiceSlug,
|
||||||
@@ -257,7 +285,7 @@ class BaseScanner<T> {
|
|||||||
(es) => es.seasonNumber === season.seasonNumber
|
(es) => es.seasonNumber === season.seasonNumber
|
||||||
);
|
);
|
||||||
|
|
||||||
// We update the rating keys in the seasons loop because we need episode counts
|
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||||
media.ratingKey = ratingKey;
|
media.ratingKey = ratingKey;
|
||||||
}
|
}
|
||||||
@@ -271,6 +299,23 @@ class BaseScanner<T> {
|
|||||||
media.ratingKey4k = ratingKey;
|
media.ratingKey4k = ratingKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media &&
|
||||||
|
season.episodes > 0 &&
|
||||||
|
media.jellyfinMediaId !== jellyfinMediaId
|
||||||
|
) {
|
||||||
|
media.jellyfinMediaId = jellyfinMediaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
media &&
|
||||||
|
season.episodes4k > 0 &&
|
||||||
|
this.enable4kShow &&
|
||||||
|
media.jellyfinMediaId4k !== jellyfinMediaId
|
||||||
|
) {
|
||||||
|
media.jellyfinMediaId4k = jellyfinMediaId;
|
||||||
|
}
|
||||||
|
|
||||||
if (existingSeason) {
|
if (existingSeason) {
|
||||||
// Here we update seasons if they already exist.
|
// Here we update seasons if they already exist.
|
||||||
// If the season is already marked as available, we
|
// If the season is already marked as available, we
|
||||||
@@ -491,6 +536,22 @@ class BaseScanner<T> {
|
|||||||
)
|
)
|
||||||
? ratingKey
|
? ratingKey
|
||||||
: undefined,
|
: undefined,
|
||||||
|
jellyfinMediaId: newSeasons.some(
|
||||||
|
(sn) =>
|
||||||
|
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
sn.status === MediaStatus.AVAILABLE
|
||||||
|
)
|
||||||
|
? jellyfinMediaId
|
||||||
|
: undefined,
|
||||||
|
jellyfinMediaId4k:
|
||||||
|
this.enable4kShow &&
|
||||||
|
newSeasons.some(
|
||||||
|
(sn) =>
|
||||||
|
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
sn.status4k === MediaStatus.AVAILABLE
|
||||||
|
)
|
||||||
|
? jellyfinMediaId
|
||||||
|
: undefined,
|
||||||
status: isAllStandardSeasons
|
status: isAllStandardSeasons
|
||||||
? MediaStatus.AVAILABLE
|
? MediaStatus.AVAILABLE
|
||||||
: newSeasons.some(
|
: newSeasons.some(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
|
|||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { UserType } from '@server/constants/user';
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import gravatarUrl from 'gravatar-url';
|
import gravatarUrl from 'gravatar-url';
|
||||||
import { findIndex, sortBy } from 'lodash';
|
import { findIndex, sortBy } from 'lodash';
|
||||||
import { In } from 'typeorm';
|
import type { EntityManager } from 'typeorm';
|
||||||
|
import { In, Not } from 'typeorm';
|
||||||
import userSettingsRoutes from './usersettings';
|
import userSettingsRoutes from './usersettings';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -188,30 +189,82 @@ router.post<
|
|||||||
}
|
}
|
||||||
>('/registerPushSubscription', async (req, res, next) => {
|
>('/registerPushSubscription', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
// This prevents race conditions where two requests both pass the checks
|
||||||
|
await dataSource.transaction(
|
||||||
|
async (transactionalEntityManager: EntityManager) => {
|
||||||
|
const transactionalRepo =
|
||||||
|
transactionalEntityManager.getRepository(UserPushSubscription);
|
||||||
|
|
||||||
const existingSubs = await userPushSubRepository.find({
|
// Check for existing subscription by auth or endpoint within transaction
|
||||||
relations: { user: true },
|
const existingSubscription = await transactionalRepo.findOne({
|
||||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
relations: { user: true },
|
||||||
});
|
where: [
|
||||||
|
{ auth: req.body.auth, user: { id: req.user?.id } },
|
||||||
|
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
if (existingSubs.length > 0) {
|
if (existingSubscription) {
|
||||||
logger.debug(
|
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
|
||||||
'User push subscription already exists. Skipping registration.',
|
if (
|
||||||
{ label: 'API' }
|
existingSubscription.endpoint === req.body.endpoint &&
|
||||||
);
|
existingSubscription.auth !== req.body.auth
|
||||||
return res.status(204).send();
|
) {
|
||||||
}
|
existingSubscription.auth = req.body.auth;
|
||||||
|
existingSubscription.p256dh = req.body.p256dh;
|
||||||
|
existingSubscription.userAgent = req.body.userAgent;
|
||||||
|
|
||||||
const userPushSubscription = new UserPushSubscription({
|
await transactionalRepo.save(existingSubscription);
|
||||||
auth: req.body.auth,
|
|
||||||
endpoint: req.body.endpoint,
|
|
||||||
p256dh: req.body.p256dh,
|
|
||||||
userAgent: req.body.userAgent,
|
|
||||||
user: req.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
userPushSubRepository.save(userPushSubscription);
|
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();
|
return res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
|
|||||||
|
|
||||||
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
||||||
{
|
{
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
|
|||||||
),
|
),
|
||||||
{
|
{
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
|
|||||||
// Deletes/disables corresponding push subscription from database
|
// Deletes/disables corresponding push subscription from database
|
||||||
const disablePushNotifications = async (endpoint?: string) => {
|
const disablePushNotifications = async (endpoint?: string) => {
|
||||||
try {
|
try {
|
||||||
await unsubscribeToPushNotifications(user?.id, endpoint);
|
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
|
||||||
|
user?.id,
|
||||||
// Delete from backend if endpoint is available
|
endpoint
|
||||||
if (subEndpoint) {
|
);
|
||||||
await deletePushSubscriptionFromBackend(subEndpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem('pushNotificationsEnabled', 'false');
|
localStorage.setItem('pushNotificationsEnabled', 'false');
|
||||||
setWebPushEnabled(false);
|
setWebPushEnabled(false);
|
||||||
|
|
||||||
|
// Only delete the current browser's subscription, not all devices
|
||||||
|
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;
|
||||||
|
if (endpointToDelete) {
|
||||||
|
try {
|
||||||
|
await axios.delete(
|
||||||
|
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
|
||||||
|
endpointToDelete
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore deletion failures - backend cleanup is best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const verifyWebPush = async () => {
|
const verifyWebPush = async () => {
|
||||||
const enabled = await verifyPushSubscription(user?.id, currentSettings);
|
const enabled = await verifyPushSubscription(user?.id, currentSettings);
|
||||||
setWebPushEnabled(enabled);
|
let isEnabled = enabled;
|
||||||
|
|
||||||
|
if (!enabled && 'serviceWorker' in navigator) {
|
||||||
|
const { subscription } = await getPushSubscription();
|
||||||
|
if (subscription) {
|
||||||
|
isEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEnabled && dataDevices && dataDevices.length > 0) {
|
||||||
|
const currentUserAgent = navigator.userAgent;
|
||||||
|
const hasMatchingDevice = dataDevices.some(
|
||||||
|
(device) => device.userAgent === currentUserAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasMatchingDevice) {
|
||||||
|
isEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWebPushEnabled(isEnabled);
|
||||||
|
if (localStorage.getItem('pushNotificationsEnabled') === null) {
|
||||||
|
localStorage.setItem(
|
||||||
|
'pushNotificationsEnabled',
|
||||||
|
isEnabled ? 'true' : 'false'
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
|
|||||||
@@ -49,13 +49,17 @@ export const verifyPushSubscription = async (
|
|||||||
currentSettings.vapidPublic
|
currentSettings.vapidPublic
|
||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
|
if (currentServerKey !== expectedServerKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const endpoint = subscription.endpoint;
|
const endpoint = subscription.endpoint;
|
||||||
|
|
||||||
const { data } = await axios.get<UserPushSubscription>(
|
const { data } = await axios.get<UserPushSubscription>(
|
||||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return expectedServerKey === currentServerKey && data.endpoint === endpoint;
|
return data.endpoint === endpoint;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -65,20 +69,39 @@ export const verifyAndResubscribePushSubscription = async (
|
|||||||
userId: number | undefined,
|
userId: number | undefined,
|
||||||
currentSettings: PublicSettingsResponse
|
currentSettings: PublicSettingsResponse
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
|
if (!userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscription } = await getPushSubscription();
|
||||||
const isValid = await verifyPushSubscription(userId, currentSettings);
|
const isValid = await verifyPushSubscription(userId, currentSettings);
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentSettings.enablePushRegistration) {
|
if (currentSettings.enablePushRegistration) {
|
||||||
try {
|
try {
|
||||||
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
|
const oldEndpoint = await unsubscribeToPushNotifications(userId);
|
||||||
await unsubscribeToPushNotifications(userId);
|
|
||||||
|
|
||||||
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
|
|
||||||
await subscribeToPushNotifications(userId, currentSettings);
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
||||||
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
|
|||||||
export const unsubscribeToPushNotifications = async (
|
export const unsubscribeToPushNotifications = async (
|
||||||
userId: number | undefined,
|
userId: number | undefined,
|
||||||
endpoint?: string
|
endpoint?: string
|
||||||
) => {
|
): Promise<string | null> => {
|
||||||
if (!('serviceWorker' in navigator) || !userId) {
|
if (!('serviceWorker' in navigator) || !userId) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { subscription } = await getPushSubscription();
|
const { subscription } = await getPushSubscription();
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { endpoint: currentEndpoint } = subscription.toJSON();
|
const { endpoint: currentEndpoint } = subscription.toJSON();
|
||||||
|
|
||||||
if (!endpoint || endpoint === currentEndpoint) {
|
if (!endpoint || endpoint === currentEndpoint) {
|
||||||
await subscription.unsubscribe();
|
await subscription.unsubscribe();
|
||||||
return true;
|
return currentEndpoint ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Issue unsubscribing to push notifications: ${error.message}`
|
`Issue unsubscribing to push notifications: ${error.message}`
|
||||||
|
|||||||
Reference in New Issue
Block a user