diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index d90f6d3b0c..99fa5eb132 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -9,6 +9,7 @@ import { import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; @@ -213,6 +214,249 @@ describe('PUT /request/:requestId (movie)', () => { }); }); +describe('PUT /request/:requestId (tv, season quota enforcement)', () => { + async function seedTvRequestAtQuota(tmdbId: number) { + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + + const friend = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + friend.tvQuotaLimit = 2; + friend.tvQuotaDays = undefined; + await userRepo.save(friend); + + const media = await mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + + const created = await requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media, + requestedBy: friend, + is4k: false, + seasons: [1, 2].map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); + + return requestRepo.findOneOrFail({ + where: { id: created.id }, + relations: { requestedBy: true, seasons: true }, + }); + } + + it('rejects adding a season beyond the requester quota', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90001); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); + + it('rejects an admin edit beyond quota without the explicit ignoreQuota flag', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90002); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); + + it('rejects a non-admin attempting to set ignoreQuota themselves', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90003); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + ignoreQuota: true, + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); + + it('allows an admin to bypass quota via the explicit ignoreQuota flag', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90004); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2, 3], + ignoreQuota: true, + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2, 3] + ); + assert.strictEqual(saved.ignoreQuota, true); + }); + + it('allows swapping seasons at full quota when the count stays the same', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRequest = await seedTvRequestAtQuota(90005); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [3, 4], + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [3, 4] + ); + }); + + it('rejects a non-bypass edit that swaps seasons on an ignoreQuota-exempt request past quota used elsewhere', async () => { + const userRepo = getRepository(User); + const mediaRepo = getRepository(Media); + const requestRepo = getRepository(MediaRequest); + + const friend = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + friend.tvQuotaLimit = 2; + friend.tvQuotaDays = undefined; + await userRepo.save(friend); + + // A request already exempted from quota counting (ignoreQuota persisted + // from a prior authorized edit or from creation). + const exemptMedia = await mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId: 90006, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + const exemptRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media: exemptMedia, + requestedBy: friend, + is4k: false, + ignoreQuota: true, + seasons: [1, 2].map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); + + // A separate, normally-counted request that already consumes the full quota. + const countedMedia = await mediaRepo.save( + new Media({ + mediaType: MediaType.TV, + tmdbId: 90007, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + await requestRepo.save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media: countedMedia, + requestedBy: friend, + is4k: false, + seasons: [3, 4].map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${exemptRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 5], + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: exemptRequest.id }, + relations: { seasons: true }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort(), + [1, 2] + ); + }); +}); + describe('POST /request/:requestId/:status', () => { const cases = [ { action: 'approve', expected: MediaRequestStatus.APPROVED }, diff --git a/server/routes/request.ts b/server/routes/request.ts index fafa90692e..bf3c2cadbd 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -568,6 +568,45 @@ requestRoutes.put<{ requestId: string }>( (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) ); + // Seasons dropped by this same edit are still counted in quota.tv.used + // (getQuota() reads the persisted rows before we save below), so they + // must be credited back before comparing against newSeasons. But if + // the request already has ignoreQuota persisted, those seasons were + // never counted in quota.tv.used in the first place, so crediting + // them back would inflate the allowed threshold. + const removedSeasonsCount = request.ignoreQuota + ? 0 + : request.seasons.filter( + (rs) => !requestedSeasons.includes(rs.seasonNumber) + ).length; + + if (newSeasons.length > 0) { + const quota = await requestUser.getQuota(); + const canBypassQuota = !!req.user?.hasPermission( + Permission.MANAGE_REQUESTS + ); + const ignoreQuota = + req.body.ignoreQuota === true && + canBypassQuota && + (quota.tv.limit ?? 0) > 0; + + if (!ignoreQuota) { + if (req.body.ignoreQuota && !canBypassQuota) { + throw new RequestPermissionError( + 'You do not have permission to bypass user quota limits.' + ); + } else if ( + quota.tv.limit && + quota.tv.used - removedSeasonsCount + newSeasons.length > + quota.tv.limit + ) { + throw new QuotaRestrictedError('Series Quota exceeded.'); + } + } else { + request.ignoreQuota = true; + } + } + request.seasons = request.seasons.filter((rs) => filteredSeasons.includes(rs.seasonNumber) ); @@ -593,6 +632,13 @@ requestRoutes.put<{ requestId: string }>( return res.status(200).json(request); } catch (e) { + if ( + e instanceof QuotaRestrictedError || + e instanceof RequestPermissionError + ) { + return next({ status: 403, message: e.message }); + } + next({ status: 500, message: e.message }); } } diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index f704b09c02..01c444351c 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -57,10 +57,7 @@ const CollectionRequestModal = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const { data: quota } = useSWR( - user && - (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) - ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` - : null + user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null ); const currentlyRemaining = diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 9ee163317c..801f778120 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -64,10 +64,7 @@ const MovieRequestModal = ({ const intl = useIntl(); const { user, hasPermission } = useUser(); const { data: quota } = useSWR( - user && - (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) - ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` - : null + user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null ); useEffect(() => { diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 2dfed160a3..e9e06c9424 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -89,10 +89,7 @@ const TvRequestModal = ({ }); const [tvdbId, setTvdbId] = useState(undefined); const { data: quota } = useSWR( - user && - (!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS)) - ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` - : null + user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null ); const currentlyRemaining =