From 75945778fad0231746eb438dc4da3929359624a8 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:06:01 +0800 Subject: [PATCH] fix(requests): enforce the quota when editing a request Editing a request never checked the requester's quota, so a user could add seasons past their limit on an existing request and an admin could move a request onto a user with no room for it. An edit is charged for every season getQuota did not already count against the owner, so reassignment and a request older than the quota window pay in full while an ordinary edit pays the difference. The handler serializes on the request id from the url so both parties to a reassignment queue behind each other, then takes the owner lock around the quota check and save. Fixes #633 --- server/routes/request.test.ts | 270 ++++++++++++++++++++++++++++---- server/routes/request.ts | 286 ++++++++++++++++++++-------------- 2 files changed, 411 insertions(+), 145 deletions(-) diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index c9455bd61a..9a095bcb60 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -214,45 +214,70 @@ describe('PUT /request/:requestId (movie)', () => { }); }); -describe('PUT /request/:requestId (tv)', () => { - it('does not add a season held by another request', async () => { - const userRepo = getRepository(User); - const mediaRepo = getRepository(Media); - const requestRepo = getRepository(MediaRequest); +async function seedUser( + email: string, + quotas: { + movieQuotaLimit?: number; + tvQuotaLimit?: number; + tvQuotaDays?: number; + } = {} +) { + const userRepo = getRepository(User); + const user = await userRepo.findOneOrFail({ where: { email } }); + Object.assign(user, quotas); - const owner = await userRepo.findOneOrFail({ - where: { email: 'admin@seerr.dev' }, - }); - const otherUser = await userRepo.findOneOrFail({ - where: { email: 'friend@seerr.dev' }, - }); + return userRepo.save(user); +} - const media = await mediaRepo.save( +async function seedTvMedia(tmdbId: number) { + const mediaRepo = getRepository(Media); + + return ( + (await mediaRepo.findOne({ + where: { tmdbId, mediaType: MediaType.TV }, + })) ?? + (await mediaRepo.save( new Media({ mediaType: MediaType.TV, - tmdbId: 67890, + tmdbId, status: MediaStatus.PENDING, status4k: MediaStatus.UNKNOWN, }) - ); + )) + ); +} + +async function seedTvRequest( + requestedBy: User, + seasons: number[], + { tmdbId = 67890, ignoreQuota = false, createdAt = new Date() } = {} +) { + return getRepository(MediaRequest).save( + new MediaRequest({ + type: MediaType.TV, + status: MediaRequestStatus.PENDING, + media: await seedTvMedia(tmdbId), + requestedBy, + is4k: false, + ignoreQuota, + createdAt, + seasons: seasons.map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.PENDING, + }) + ), + }) + ); +} - const seedTvRequest = (requestedBy: User, seasons: number[]) => - requestRepo.save( - new MediaRequest({ - type: MediaType.TV, - status: MediaRequestStatus.PENDING, - media, - requestedBy, - is4k: false, - seasons: seasons.map( - (seasonNumber) => - new SeasonRequest({ - seasonNumber, - status: MediaRequestStatus.PENDING, - }) - ), - }) - ); +describe('PUT /request/:requestId (tv)', () => { + it('does not add a season held by another request', async () => { + const requestRepo = getRepository(MediaRequest); + + const owner = await seedUser('admin@seerr.dev'); + const otherUser = await seedUser('friend@seerr.dev'); const mediaRequest = await seedTvRequest(owner, [1, 2]); const otherRequest = await seedTvRequest(otherUser, [3]); @@ -283,6 +308,189 @@ describe('PUT /request/:requestId (tv)', () => { }); }); +describe('PUT /request/:requestId (quota)', () => { + it('rejects adding seasons beyond the season limit', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev', { tvQuotaLimit: 2 }); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + 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 }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 2] + ); + }); + + it('rejects adding seasons to a request older than the quota window', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev', { + tvQuotaLimit: 2, + tvQuotaDays: 7, + }); + const mediaRequest = await seedTvRequest(owner, [1, 2], { + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }); + + 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 }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 2] + ); + }); + + it('allows swapping seasons at the season limit', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev', { tvQuotaLimit: 2 }); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + 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 }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [3, 4] + ); + }); + + it('rejects reassignment to a user without room for the existing seasons', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('admin@seerr.dev'); + const target = await seedUser('friend@seerr.dev', { tvQuotaLimit: 1 }); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + userId: target.id, + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.strictEqual(saved.requestedBy.id, owner.id); + }); + + it('rejects reassignment of a movie request to a user at their limit', async () => { + const requestRepo = getRepository(MediaRequest); + const mediaRepo = getRepository(Media); + + const owner = await seedUser('admin@seerr.dev'); + const target = await seedUser('friend@seerr.dev', { movieQuotaLimit: 1 }); + + // Uses up the target's single movie request + await seedRequest(); + + const media = await mediaRepo.save( + new Media({ + mediaType: MediaType.MOVIE, + tmdbId: 55555, + status: MediaStatus.PENDING, + status4k: MediaStatus.UNKNOWN, + }) + ); + const mediaRequest = await requestRepo.save( + new MediaRequest({ + type: MediaType.MOVIE, + status: MediaRequestStatus.PENDING, + media, + requestedBy: owner, + is4k: false, + }) + ); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.MOVIE, + userId: target.id, + }); + + assert.strictEqual(res.status, 403); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.strictEqual(saved.requestedBy.id, owner.id); + }); + + it('allows reassignment to a user who bypasses quotas', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev', { tvQuotaLimit: 1 }); + const target = await seedUser('admin@seerr.dev'); + const mediaRequest = await seedTvRequest(owner, [1, 2]); + + const agent = await loginAs('admin@seerr.dev', 'test1234'); + const res = await agent.put(`/request/${mediaRequest.id}`).send({ + mediaType: MediaType.TV, + seasons: [1, 2], + userId: target.id, + }); + + assert.strictEqual(res.status, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.strictEqual(saved.requestedBy.id, target.id); + }); + + it('allows an edit that exceeds the limit when the request ignores quota', async () => { + const requestRepo = getRepository(MediaRequest); + const owner = await seedUser('friend@seerr.dev', { tvQuotaLimit: 2 }); + + await seedTvRequest(owner, [1, 2], { tmdbId: 77777 }); + const mediaRequest = await seedTvRequest(owner, [1, 2], { + ignoreQuota: true, + }); + + 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, 200); + + const saved = await requestRepo.findOneOrFail({ + where: { id: mediaRequest.id }, + }); + assert.deepStrictEqual( + saved.seasons.map((s) => s.seasonNumber).sort((a, b) => a - b), + [1, 2, 3] + ); + }); +}); + 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 18c7a12253..9234d9ca5f 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -25,6 +25,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import requestLock from '@server/utils/requestLock'; import { Router } from 'express'; const requestRoutes = Router(); @@ -462,136 +463,193 @@ requestRoutes.put<{ requestId: string }>( async (req, res, next) => { const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); + const requestId = Number(req.params.requestId); try { - const request = await requestRepository.findOne({ - where: { - id: Number(req.params.requestId), - }, - }); - - if (!request) { - return next({ status: 404, message: 'Request not found.' }); - } - - if ( - (request.requestedBy.id !== req.user?.id || - (req.body.mediaType !== 'tv' && - !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) && - !req.user?.hasPermission(Permission.MANAGE_REQUESTS) - ) { - return next({ - status: 403, - message: 'You do not have permission to modify this request.', + // Ordering is request then owner here, user then media on create, so no cycle + return await requestLock.dispatch(`request:${requestId}`, async () => { + const request = await requestRepository.findOne({ + where: { + id: requestId, + }, }); - } - let requestUser = request.requestedBy; + if (!request) { + return next({ status: 404, message: 'Request not found.' }); + } - if ( - req.body.userId && - req.body.userId !== request.requestedBy.id && - !req.user?.hasPermission([ - Permission.MANAGE_USERS, - Permission.MANAGE_REQUESTS, - ]) - ) { - return next({ - status: 403, - message: 'You do not have permission to modify the request user.', - }); - } else if (req.body.userId) { - requestUser = await userRepository.findOneOrFail({ - where: { id: req.body.userId }, - }); - } + if ( + (request.requestedBy.id !== req.user?.id || + (req.body.mediaType !== 'tv' && + !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) && + !req.user?.hasPermission(Permission.MANAGE_REQUESTS) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify this request.', + }); + } - if (req.body.mediaType === MediaType.MOVIE) { - request.serverId = req.body.serverId; - request.profileId = req.body.profileId; - request.rootFolder = req.body.rootFolder; - request.tags = req.body.tags; - request.requestedBy = requestUser as User; - - await requestRepository.save(request); - } else if (req.body.mediaType === MediaType.TV) { - const mediaRepository = getRepository(Media); - request.serverId = req.body.serverId; - request.profileId = req.body.profileId; - request.rootFolder = req.body.rootFolder; - request.languageProfileId = req.body.languageProfileId; - request.tags = req.body.tags; - request.requestedBy = requestUser as User; - - const requestedSeasons = req.body.seasons as number[] | undefined; - - if (!requestedSeasons || requestedSeasons.length === 0) { - throw new Error( - 'Missing seasons. If you want to cancel a series request, use the DELETE method.' - ); + const previousOwnerId = request.requestedBy.id; + let requestUser = request.requestedBy; + + if ( + req.body.userId && + req.body.userId !== request.requestedBy.id && + !req.user?.hasPermission([ + Permission.MANAGE_USERS, + Permission.MANAGE_REQUESTS, + ]) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.userId) { + requestUser = await userRepository.findOneOrFail({ + where: { id: req.body.userId }, + }); } - // Get existing media so we can work with all the requests - const media = await mediaRepository.findOneOrFail({ - where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV }, - relations: { requests: true }, - }); + // Reassignment moves every season on the request onto the new owner's + // quota, so it is charged in full rather than as a delta + const ownerChanging = requestUser.id !== previousOwnerId; + + return requestLock.dispatch(requestUser.id, async () => { + if (req.body.mediaType === MediaType.MOVIE) { + if (ownerChanging && !request.ignoreQuota) { + const quotas = await requestUser.getQuota(); + + if (quotas.movie.restricted) { + return next({ + status: 403, + message: 'Movie Quota exceeded.', + }); + } + } - // Get all requested seasons that are not part of this request we are editing - const existingSeasons = media.requests - .filter( - (r) => - r.is4k === request.is4k && - r.id !== request.id && - r.status !== MediaRequestStatus.DECLINED && - r.status !== MediaRequestStatus.COMPLETED - ) - .reduce((seasons, r) => { - const combinedSeasons = r.seasons.map( - (season) => season.seasonNumber - ); + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + request.tags = req.body.tags; + request.requestedBy = requestUser as User; + + await requestRepository.save(request); + } else if (req.body.mediaType === MediaType.TV) { + const mediaRepository = getRepository(Media); + request.serverId = req.body.serverId; + request.profileId = req.body.profileId; + request.rootFolder = req.body.rootFolder; + request.languageProfileId = req.body.languageProfileId; + request.tags = req.body.tags; + request.requestedBy = requestUser as User; + + const requestedSeasons = req.body.seasons as number[] | undefined; + + if (!requestedSeasons || requestedSeasons.length === 0) { + throw new Error( + 'Missing seasons. If you want to cancel a series request, use the DELETE method.' + ); + } - return [...seasons, ...combinedSeasons]; - }, [] as number[]); + // Get existing media so we can work with all the requests + const media = await mediaRepository.findOneOrFail({ + where: { + tmdbId: request.media.tmdbId, + mediaType: MediaType.TV, + }, + relations: { requests: true }, + }); + + // Get all requested seasons that are not part of this request we are editing + const existingSeasons = media.requests + .filter( + (r) => + r.is4k === request.is4k && + r.id !== request.id && + r.status !== MediaRequestStatus.DECLINED && + r.status !== MediaRequestStatus.COMPLETED + ) + .reduce((seasons, r) => { + const combinedSeasons = r.seasons.map( + (season) => season.seasonNumber + ); + + return [...seasons, ...combinedSeasons]; + }, [] as number[]); + + const filteredSeasons = requestedSeasons.filter( + (rs) => !existingSeasons.includes(rs) + ); - const filteredSeasons = requestedSeasons.filter( - (rs) => !existingSeasons.includes(rs) - ); + if (filteredSeasons.length === 0) { + return next({ + status: 202, + message: 'No seasons available to request', + }); + } - if (filteredSeasons.length === 0) { - return next({ - status: 202, - message: 'No seasons available to request', - }); - } + const newSeasons = filteredSeasons.filter( + (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) + ); - const newSeasons = filteredSeasons.filter( - (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) - ); + if (!request.ignoreQuota) { + const quotas = await requestUser.getQuota(); + + // Only the seasons getQuota already counted for this owner are + // paid for, so the edit is charged for everything it left out + const quotaWindowStart = new Date(); + if (quotas.tv.days) { + quotaWindowStart.setDate( + quotaWindowStart.getDate() - quotas.tv.days + ); + } + + const countedAlready = + !ownerChanging && + (!quotas.tv.days || request.createdAt > quotaWindowStart); + + const priorSeasonCount = countedAlready + ? request.seasons.length + : 0; + const requiredSeasons = filteredSeasons.length - priorSeasonCount; + + if ( + quotas.tv.limit && + requiredSeasons > (quotas.tv.remaining ?? 0) + ) { + return next({ + status: 403, + message: 'Series Quota exceeded.', + }); + } + } - request.seasons = request.seasons.filter((rs) => - filteredSeasons.includes(rs.seasonNumber) - ); + request.seasons = request.seasons.filter((rs) => + filteredSeasons.includes(rs.seasonNumber) + ); - if (newSeasons.length > 0) { - logger.debug('Adding new seasons to request', { - label: 'Media Request', - newSeasons, - }); - request.seasons.push( - ...newSeasons.map( - (ns) => - new SeasonRequest({ - seasonNumber: ns, - status: MediaRequestStatus.PENDING, - }) - ) - ); - } + if (newSeasons.length > 0) { + logger.debug('Adding new seasons to request', { + label: 'Media Request', + newSeasons, + }); + request.seasons.push( + ...newSeasons.map( + (ns) => + new SeasonRequest({ + seasonNumber: ns, + status: MediaRequestStatus.PENDING, + }) + ) + ); + } - await requestRepository.save(request); - } + await requestRepository.save(request); + } - return res.status(200).json(request); + return res.status(200).json(request); + }); + }); } catch (e) { next({ status: 500, message: e.message }); }