mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
7 Commits
OwsleyJr/c
...
0xsysr3ll/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd33336a39 | ||
|
|
7756ec89e1 | ||
|
|
34fcc5d2c7 | ||
|
|
f292d93d10 | ||
|
|
c29ba2ce8e | ||
|
|
4846c6abc4 | ||
|
|
1213a1d765 |
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/helm.yml
vendored
2
.github/workflows/helm.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/lint-helm-charts.yml
vendored
2
.github/workflows/lint-helm-charts.yml
vendored
@@ -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
|
||||
|
||||
47
.github/workflows/release.yml
vendored
47
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<img src="./public/logo_full.svg" alt="Jellyseerr" style="margin: 20px 0;">
|
||||
</p>
|
||||
<div align="center">⚠️ <strong>NOTE:</strong> We are currently in the process of merging Overseerr and Jellyseerr into this unified repository.</div>
|
||||
|
||||
<h1 align="center" style="font-size: 4em;">🚧 Seerr</h1>
|
||||
<p align="center">
|
||||
<img src="https://github.com/seerr-team/seerr/actions/workflows/release.yml/badge.svg" alt="Seerr Release" />
|
||||
<img src="https://github.com/seerr-team/seerr/actions/workflows/ci.yml/badge.svg" alt="Seerr CI">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -22,4 +22,5 @@ export type MediaRequestBody = {
|
||||
languageProfileId?: number;
|
||||
userId?: number;
|
||||
tags?: number[];
|
||||
ignoreQuota?: boolean;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
162
src/utils/pushSubscriptionHelpers.ts
Normal file
162
src/utils/pushSubscriptionHelpers.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user