Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions server/routes/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -213,6 +214,143 @@ 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);
});
});

describe('POST /request/:requestId/:status', () => {
const cases = [
{ action: 'approve', expected: MediaRequestStatus.APPROVED },
Expand Down
33 changes: 33 additions & 0 deletions server/routes/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,32 @@ requestRoutes.put<{ requestId: string }>(
(sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn)
);

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 &&
newSeasons.length > (quota.tv.remaining ?? 0)
) {
throw new QuotaRestrictedError('Series Quota exceeded.');
}
} else {
request.ignoreQuota = true;
}
}

request.seasons = request.seasons.filter((rs) =>
filteredSeasons.includes(rs.seasonNumber)
);
Expand All @@ -593,6 +619,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 });
}
}
Expand Down
5 changes: 1 addition & 4 deletions src/components/RequestModal/CollectionRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,7 @@ const CollectionRequestModal = ({
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
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 =
Expand Down
5 changes: 1 addition & 4 deletions src/components/RequestModal/MovieRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,7 @@ const MovieRequestModal = ({
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
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(() => {
Expand Down
5 changes: 1 addition & 4 deletions src/components/RequestModal/TvRequestModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,7 @@ const TvRequestModal = ({
});
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
const { data: quota } = useSWR<QuotaResponse>(
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
);
Comment on lines 91 to 93

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the modal and related backend permission checks.
git ls-files | rg 'src/components/RequestModal/TvRequestModal\.tsx|src/components/RequestModal/.*RequestModal\.tsx|api/v1/user/.*/quota|quota|MANAGE_USERS|MANAGE_REQUESTS|permission' 

# Show an outline of the target modal before reading focused slices.
ast-grep outline src/components/RequestModal/TvRequestModal.tsx --view expanded || true

# Read the relevant ranges in the modal.
sed -n '1,260p' src/components/RequestModal/TvRequestModal.tsx

# Search for the quota endpoint / permission checks across the repo.
rg -n "GET /.*quota|/quota|MANAGE_USERS|MANAGE_REQUESTS|AND-permission|permission.*quota|quota.*permission|GET.*quota" src .

Repository: seerr-team/seerr

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the quota route and nearby permission logic.
sed -n '780,840p' server/routes/user/index.ts

# Inspect the TvRequestModal branches that depend on quota.
sed -n '380,500p' src/components/RequestModal/TvRequestModal.tsx
sed -n '680,740p' src/components/RequestModal/TvRequestModal.tsx

# Compare with the other request modals' quota handling.
sed -n '40,120p' src/components/RequestModal/MovieRequestModal.tsx
sed -n '40,120p' src/components/RequestModal/CollectionRequestModal.tsx

# Inspect the shared quota display component.
sed -n '1,220p' src/components/RequestModal/QuotaDisplay.tsx

Repository: seerr-team/seerr

Length of output: 13582


Quota fetch 403 leaves quota safeguards off for some admins src/components/RequestModal/TvRequestModal.tsx:91-93

For users with MANAGE_REQUESTS but not MANAGE_USERS, /api/v1/user/:id/quota returns 403, so quota stays undefined. That hides QuotaDisplay and bypasses the season-limit checks in okDisabled, letting the modal accept selections that the backend rejects on submit. Surface the quota error or skip the fetch for that role.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/RequestModal/TvRequestModal.tsx` around lines 91 - 93, Update
the quota-loading flow near useSWR and the okDisabled safeguards so users with
MANAGE_REQUESTS but without MANAGE_USERS do not rely on an undefined quota after
a 403. Either skip the quota request for that role or surface the fetch error
and keep the modal’s quota safeguards enforced; preserve normal quota display
and season-limit checks for authorized users.


const currentlyRemaining =
Expand Down
Loading