Compare commits

...

6 Commits

Author SHA1 Message Date
0xsysr3ll
fd33336a39 fix(api): description should be consistent
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-10-09 19:34:19 +02:00
0xsysr3ll
7756ec89e1 feat(requests): allow admins to bypass user quota limits
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-10-09 19:16:53 +02:00
Gauthier
34fcc5d2c7 chore: merge upstream (#2024) 2025-10-08 23:34:31 -04:00
Joe Harrison
f292d93d10 ci: ci: update node hash to be multi arch sha index (#2023) 2025-10-08 14:10:38 +01:00
Joe Harrison
c29ba2ce8e ci: updating digests re pr-1998 (#2020) 2025-10-08 14:35:01 +02:00
Joe Harrison
4846c6abc4 ci(ci.yml,release.yml): removes the need for 3rd party actions (#2018)
* ci(ci.yml,release.yml): removes the need for 3rd party actions

This will stop the need to use `sarisia/actions-status-discord` &
`technote-space/workflow-conclusion-action`

* revert(ci.yml): added the logic back to the if condition
2025-10-08 12:47:08 +01:00
21 changed files with 551 additions and 193 deletions

View File

@@ -23,7 +23,7 @@ jobs:
name: Lint & Test Build
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
container: node:22-alpine
container: node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -173,20 +173,36 @@ jobs:
if: always() && github.event_name != 'pull_request' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-24.04
steps:
- name: Combine Job Status
- name: Determine Workflow Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ ${{ needs.publish.result }} ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
else
echo "status=${{ needs.publish.result }}" >> $GITHUB_OUTPUT
fi
case "${{ needs.publish.result }}" in
success) echo "status=Success" >> $GITHUB_OUTPUT; echo "colour=3066993" >> $GITHUB_OUTPUT ;;
failure) echo "status=Failure" >> $GITHUB_OUTPUT; echo "colour=15158332" >> $GITHUB_OUTPUT ;;
cancelled) echo "status=Cancelled" >> $GITHUB_OUTPUT; echo "colour=10181046" >> $GITHUB_OUTPUT ;;
*) echo "status=Skipped" >> $GITHUB_OUTPUT; echo "colour=9807270" >> $GITHUB_OUTPUT ;;
esac
- name: Post Status to Discord
uses: sarisia/actions-status-discord@58667542682a27f270460405363e3a982f57a7ee # v1.0.0
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true
- name: Send Discord notification
shell: bash
run: |
WEBHOOK="${{ secrets.DISCORD_WEBHOOK }}"
PAYLOAD=$(cat <<EOF
{
"embeds": [{
"title": "${{ steps.status.outputs.status }}: ${{ github.workflow }}",
"color": ${{ steps.status.outputs.colour }},
"fields": [
{ "name": "Repository", "value": "[${{ github.repository }}](${{ github.server_url }}/${{ github.repository }})", "inline": true },
{ "name": "Ref", "value": "${{ github.ref }}", "inline": true },
{ "name": "Event", "value": "${{ github.event_name }}", "inline": true },
{ "name": "Triggered by", "value": "${{ github.actor }}", "inline": true },
{ "name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
],
}]
}
EOF
)
curl -sS -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$WEBHOOK" || true

View File

@@ -76,7 +76,7 @@ jobs:
fi
- name: Upload artifacts
uses: actions/upload-artifact@c7d193f32edcb7bfad88892161225aeda64e9392 # v4.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: steps.check-artifacts.outputs.has_artifacts == 'true'
with:
name: artifacts

View File

@@ -40,7 +40,7 @@ jobs:
uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0
- name: Ensure documentation is updated
uses: docker://jnorwood/helm-docs@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c # v1.14.2
uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c
- name: Run chart-testing (list-changed)
id: list-changed

View File

@@ -191,23 +191,36 @@ jobs:
if: always()
runs-on: ubuntu-24.04
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@45ce8e0eb155657ab8ccf346ade734257fd196a5 # v3.0.3
- name: Combine Job Status
- name: Determine Workflow Status
id: status
run: |
failures=(neutral, skipped, timed_out, action_required)
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
echo "status=failure" >> $GITHUB_OUTPUT
else
echo "status=$WORKFLOW_CONCLUSION" >> $GITHUB_OUTPUT
fi
case "${{ needs.publish.result }}" in
success) echo "status=Success" >> $GITHUB_OUTPUT; echo "colour=3066993" >> $GITHUB_OUTPUT ;;
failure) echo "status=Failure" >> $GITHUB_OUTPUT; echo "colour=15158332" >> $GITHUB_OUTPUT ;;
cancelled) echo "status=Cancelled" >> $GITHUB_OUTPUT; echo "colour=10181046" >> $GITHUB_OUTPUT ;;
*) echo "status=Skipped" >> $GITHUB_OUTPUT; echo "colour=9807270" >> $GITHUB_OUTPUT ;;
esac
- name: Post Status to Discord
uses: sarisia/actions-status-discord@58667542682a27f270460405363e3a982f57a7ee # v1.0.0
with:
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ steps.status.outputs.status }}
title: ${{ github.workflow }}
nofail: true
- name: Send Discord notification
shell: bash
run: |
WEBHOOK="${{ secrets.DISCORD_WEBHOOK }}"
PAYLOAD=$(cat <<EOF
{
"embeds": [{
"title": "${{ steps.status.outputs.status }}: ${{ github.workflow }}",
"color": ${{ steps.status.outputs.colour }},
"fields": [
{ "name": "Repository", "value": "[${{ github.repository }}](${{ github.server_url }}/${{ github.repository }})", "inline": true },
{ "name": "Ref", "value": "${{ github.ref }}", "inline": true },
{ "name": "Event", "value": "${{ github.event_name }}", "inline": true },
{ "name": "Triggered by", "value": "${{ github.actor }}", "inline": true },
{ "name": "Workflow", "value": "[${{ github.workflow }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
],
}]
}
EOF
)
curl -sS -H "Content-Type: application/json" -X POST -d "$PAYLOAD" "$WEBHOOK" || true

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-alpine3.22@sha256:096829fd4bb8c2ce2340ed64dd2f857951f8704325f7acf53e3dc0561c36a214 AS build_image
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7 AS build_image
ARG SOURCE_DATE_EPOCH
ARG TARGETPLATFORM
@@ -33,7 +33,7 @@ RUN pnpm prune --prod --ignore-scripts && \
touch config/DOCKER && \
echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json
FROM node:22.20.0-alpine3.22@sha256:096829fd4bb8c2ce2340ed64dd2f857951f8704325f7acf53e3dc0561c36a214
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,4 +1,4 @@
FROM node:22.20.0-alpine3.22@sha256:096829fd4bb8c2ce2340ed64dd2f857951f8704325f7acf53e3dc0561c36a214
FROM node:22.20.0-alpine3.22@sha256:cb3143549582cc5f74f26f0992cdef4a422b22128cb517f94173a5f910fa4ee7
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1263,6 +1263,10 @@ components:
type: number
rootFolder:
type: string
ignoreQuota:
type: boolean
example: false
description: If true, this request will not count against the user's quota. Requires MANAGE_REQUESTS permission.
required:
- id
- status
@@ -6139,6 +6143,10 @@ paths:
userId:
type: number
nullable: true
ignoreQuota:
type: boolean
example: false
description: If true, this request will not count against the user's quota. Requires MANAGE_REQUESTS permission.
required:
- mediaType
- mediaId

View File

@@ -111,10 +111,20 @@ export class MediaRequest {
const quotas = await requestUser.getQuota();
if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) {
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.');
const canBypassQuota = user.hasPermission([Permission.MANAGE_REQUESTS]);
if (!canBypassQuota) {
if (
requestBody.mediaType === MediaType.MOVIE &&
quotas.movie.restricted
) {
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (
requestBody.mediaType === MediaType.TV &&
quotas.tv.restricted
) {
throw new QuotaRestrictedError('Series Quota exceeded.');
}
}
const tmdbMedia =
@@ -371,6 +381,7 @@ export class MediaRequest {
rootFolder: rootFolder,
tags: tags,
isAutoRequest: options.isAutoRequest ?? false,
ignoreQuota: requestBody.ignoreQuota ?? false,
});
await requestRepository.save(request);
@@ -434,6 +445,7 @@ export class MediaRequest {
if (finalSeasons.length === 0) {
throw new NoSeasonsAvailableError('No seasons available to request');
} else if (
!canBypassQuota &&
quotas.tv.limit &&
finalSeasons.length > (quotas.tv.remaining ?? 0)
) {
@@ -502,6 +514,7 @@ export class MediaRequest {
})
),
isAutoRequest: options.isAutoRequest ?? false,
ignoreQuota: requestBody.ignoreQuota ?? false,
});
await requestRepository.save(request);
@@ -606,6 +619,9 @@ export class MediaRequest {
@Column({ default: false })
public isAutoRequest: boolean;
@Column({ default: false })
public ignoreQuota: boolean;
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}

View File

@@ -290,6 +290,7 @@ export class User {
createdAt: AfterDate(movieDate),
type: MediaType.MOVIE,
status: Not(MediaRequestStatus.DECLINED),
ignoreQuota: false,
},
})
: 0;
@@ -323,6 +324,9 @@ export class User {
.andWhere('request.status != :declinedStatus', {
declinedStatus: MediaRequestStatus.DECLINED,
})
.andWhere('request.ignoreQuota = :ignoreQuota', {
ignoreQuota: false,
})
.addSelect((subQuery) => {
return subQuery
.select('COUNT(season.id)', 'seasonCount')

View File

@@ -22,4 +22,5 @@ export type MediaRequestBody = {
languageProfileId?: number;
userId?: number;
tags?: number[];
ignoreQuota?: boolean;
};

View File

@@ -8,7 +8,7 @@ import { getHostname } from '@server/utils/getHostname';
const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
const mediaServerType = settings.main.mediaServerType;
if (
!settings.jellyfin.apiKey &&
!settings.jellyfin?.apiKey &&
(mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY)
) {

View File

@@ -0,0 +1,19 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIgnoreQuotaToMediaRequest1760028688313
implements MigrationInterface
{
name = 'AddIgnoreQuotaToMediaRequest1760028688313';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" ADD "ignoreQuota" boolean NOT NULL DEFAULT false`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" DROP COLUMN "ignoreQuota"`
);
}
}

View File

@@ -0,0 +1,33 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIgnoreQuotaToMediaRequest1760028688313
implements MigrationInterface
{
name = 'AddIgnoreQuotaToMediaRequest1760028688313';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedBy" integer, "modifiedBy" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), "ignoreQuota" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_media_request_media" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user" FOREIGN KEY ("requestedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user_2" FOREIGN KEY ("modifiedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "media_request"`
);
await queryRunner.query(`DROP TABLE "media_request"`);
await queryRunner.query(
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
);
await queryRunner.query(
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedBy" integer, "modifiedBy" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_media_request_media" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user" FOREIGN KEY ("requestedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_media_request_user_2" FOREIGN KEY ("modifiedBy") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedBy", "modifiedBy", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "temporary_media_request"`
);
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
}
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import SlideCheckbox from '@app/components/Common/SlideCheckbox';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
@@ -38,6 +39,9 @@ const messages = defineMessages('components.RequestModal.AdvancedRequester', {
tags: 'Tags',
selecttags: 'Select tags',
notagoptions: 'No tags.',
ignoreQuotaTitle: 'Bypass User Quota',
ignoreQuotaDescription:
"This request will not count against the user's quota limits. Use with caution.",
});
export type RequestOverrides = {
@@ -47,6 +51,7 @@ export type RequestOverrides = {
tags?: number[];
language?: number;
user?: User;
ignoreQuota?: boolean;
};
interface AdvancedRequesterProps {
@@ -55,6 +60,7 @@ interface AdvancedRequesterProps {
isAnime?: boolean;
defaultOverrides?: RequestOverrides;
requestUser?: User;
quota?: { movie: { limit?: number }; tv: { limit?: number } };
onChange: (overrides: RequestOverrides) => void;
}
@@ -64,6 +70,7 @@ const AdvancedRequester = ({
isAnime = false,
defaultOverrides,
requestUser,
quota,
onChange,
}: AdvancedRequesterProps) => {
const intl = useIntl();
@@ -97,6 +104,10 @@ const AdvancedRequester = ({
defaultOverrides?.tags ?? []
);
const [ignoreQuota, setIgnoreQuota] = useState<boolean>(
defaultOverrides?.ignoreQuota ?? false
);
const { data: serverData, isValidating } =
useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null
@@ -273,6 +284,7 @@ const AdvancedRequester = ({
user: selectedUser ?? undefined,
language: selectedLanguage !== -1 ? selectedLanguage : undefined,
tags: selectedTags,
ignoreQuota: ignoreQuota || undefined,
});
}
}, [
@@ -282,6 +294,7 @@ const AdvancedRequester = ({
selectedUser,
selectedLanguage,
selectedTags,
ignoreQuota,
]);
if (!data && !error) {
@@ -540,6 +553,25 @@ const AdvancedRequester = ({
/>
</div>
)}
{currentHasPermission([Permission.MANAGE_REQUESTS]) &&
quota &&
((type === 'movie' && quota.movie.limit && quota.movie.limit > 0) ||
(type === 'tv' && quota.tv.limit && quota.tv.limit > 0)) && (
<div className="mb-2">
<label htmlFor="ignoreQuota">
{intl.formatMessage(messages.ignoreQuotaTitle)}
</label>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-400">
{intl.formatMessage(messages.ignoreQuotaDescription)}
</p>
<SlideCheckbox
checked={ignoreQuota}
onClick={() => setIgnoreQuota(!ignoreQuota)}
/>
</div>
</div>
)}
{currentHasPermission([
Permission.MANAGE_REQUESTS,
Permission.MANAGE_USERS,

View File

@@ -94,6 +94,7 @@ const MovieRequestModal = ({
mediaId: data?.id,
mediaType: 'movie',
is4k,
ignoreQuota: requestOverrides?.ignoreQuota,
...overrideParams,
});
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
@@ -320,7 +321,10 @@ const MovieRequestModal = ({
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating || quota?.movie.restricted}
okDisabled={
isUpdating ||
(quota?.movie.restricted && !requestOverrides?.ignoreQuota)
}
title={intl.formatMessage(
is4k ? messages.requestmovie4ktitle : messages.requestmovietitle
)}
@@ -359,6 +363,7 @@ const MovieRequestModal = ({
<AdvancedRequester
type="movie"
is4k={is4k}
quota={quota}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}

View File

@@ -199,6 +199,7 @@ const TvRequestModal = ({
tvdbId: tvdbId ?? data?.externalIds.tvdbId,
mediaType: 'tv',
is4k,
ignoreQuota: requestOverrides?.ignoreQuota,
seasons: settings.currentSettings.partialRequestsEnabled
? selectedSeasons.sort((a, b) => a - b)
: getAllSeasons().filter(
@@ -439,7 +440,8 @@ const TvRequestModal = ({
? false
: !settings.currentSettings.partialRequestsEnabled &&
quota?.tv.limit &&
unrequestedSeasons.length > quota.tv.limit
unrequestedSeasons.length > quota.tv.limit &&
!requestOverrides?.ignoreQuota
? true
: getAllRequestedSeasons().length >= getAllSeasons().length ||
(settings.currentSettings.partialRequestsEnabled &&
@@ -726,6 +728,7 @@ const TvRequestModal = ({
isAnime={data?.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
)}
quota={quota}
onChange={(overrides) => setRequestOverrides(overrides)}
requestUser={editRequest?.requestedBy}
defaultOverrides={

View File

@@ -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;
};

View File

@@ -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) => {
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
<ConfirmButton
onClick={() => disablePushNotifications(device.endpoint)}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deletesubscription)}</span>
</ConfirmButton>
{subEndpoint === device.endpoint ? (
<Button buttonType="primary" className="w-full" disabled>
<LockClosedIcon />{' '}
<span>{intl.formatMessage(messages.activesubscription)}</span>
</Button>
) : (
<ConfirmButton
onClick={() => deletePushSubscriptionFromBackend(device.endpoint)}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deletesubscription)}</span>
</ConfirmButton>
)}
</div>
</div>
);

View File

@@ -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<string | null>(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<UserPushSubscription>(
`/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 <LoadingSpinner />;
@@ -324,22 +311,18 @@ const UserWebPushSettings = () => {
{intl.formatMessage(messages.managedevices)}
</h3>
<div className="section">
{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) => (
<div className="py-2" key={`device-list-${index}`}>
<DeviceItem
key={index}
disablePushNotifications={disablePushNotifications}
device={device}
/>
</div>
))
{sortedDevices?.length ? (
sortedDevices.map((device) => (
<div className="py-2" key={`device-list-${device.endpoint}`}>
<DeviceItem
deletePushSubscriptionFromBackend={
deletePushSubscriptionFromBackend
}
device={device}
subEndpoint={subEndpoint}
/>
</div>
))
) : (
<>
<Alert

View File

@@ -520,6 +520,8 @@
"components.RequestModal.AdvancedRequester.default": "{name} (Default)",
"components.RequestModal.AdvancedRequester.destinationserver": "Destination Server",
"components.RequestModal.AdvancedRequester.folder": "{path} ({space})",
"components.RequestModal.AdvancedRequester.ignoreQuotaDescription": "This request will not count against the user's quota limits. Use with caution.",
"components.RequestModal.AdvancedRequester.ignoreQuotaTitle": "Bypass User Quota",
"components.RequestModal.AdvancedRequester.languageprofile": "Language Profile",
"components.RequestModal.AdvancedRequester.notagoptions": "No tags.",
"components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",
@@ -1436,6 +1438,7 @@
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorExists": "This account is already linked to a Plex user",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.plexErrorUnauthorized": "Unable to connect to Plex using your credentials",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.activesubscription": "Active Subscription",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.browser": "Browser",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.created": "Created",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.deletesubscription": "Delete Subscription",

View File

@@ -0,0 +1,162 @@
import type { UserPushSubscription } from '@server/entity/UserPushSubscription';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
import axios from 'axios';
// Taken from https://www.npmjs.com/package/web-push
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i)
outputArray[i] = rawData.charCodeAt(i);
return outputArray;
}
export const getPushSubscription = async () => {
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<boolean> => {
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<UserPushSubscription>(
`/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<boolean> => {
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}`
);
}
};