Skip to content

Commit 57ecfb5

Browse files
authored
fix(api): validate and sanitize slot groups on the save programming path (#2061)
Fixes #2023
1 parent 3c4935e commit 57ecfb5

2 files changed

Lines changed: 152 additions & 1 deletion

File tree

server/src/api/channelsApi.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
536536
body: UpdateChannelProgrammingRequestSchema,
537537
response: {
538538
200: CondensedChannelProgrammingSchema,
539+
400: z.string(),
539540
404: z.void(),
540541
500: z.void(),
541542
501: z.void(),
@@ -547,9 +548,46 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
547548
return res.status(404).send();
548549
}
549550

551+
// The schedule-time-slots / schedule-slots preview endpoints validate
552+
// slot groups, but this save path persisted whatever schedule it was
553+
// given verbatim. Run the same validation here and persist the
554+
// sanitized slots (linkMode stripped from slots without an
555+
// iterationGroup) so an invalid or unsanitized group can't be saved
556+
// and then regenerated badly by RegenerateChannelLineupCommand.
557+
let programmingRequest = req.body;
558+
if (req.body.type === 'time') {
559+
const groupValidation = validateSlotGroups(req.body.schedule.slots, {
560+
scheduleType: 'time',
561+
});
562+
if (!groupValidation.valid) {
563+
return res.status(400).send(groupValidation.errors.join('; '));
564+
}
565+
programmingRequest = {
566+
...req.body,
567+
schedule: {
568+
...req.body.schedule,
569+
slots: groupValidation.sanitizedSlots,
570+
},
571+
};
572+
} else if (req.body.type === 'random') {
573+
const groupValidation = validateSlotGroups(req.body.schedule.slots, {
574+
scheduleType: 'random',
575+
});
576+
if (!groupValidation.valid) {
577+
return res.status(400).send(groupValidation.errors.join('; '));
578+
}
579+
programmingRequest = {
580+
...req.body,
581+
schedule: {
582+
...req.body.schedule,
583+
slots: groupValidation.sanitizedSlots,
584+
},
585+
};
586+
}
587+
550588
const result = await req.serverCtx.channelDB.updateLineup(
551589
req.params.id,
552-
req.body,
590+
programmingRequest,
553591
);
554592

555593
if (isNil(result)) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { SaveableChannel } from '@tunarr/types';
2+
import { FastifyInstance } from 'fastify';
3+
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
4+
import { v4 } from 'uuid';
5+
import { container } from '../src/container.ts';
6+
import { ChannelDB } from '../src/db/ChannelDB.ts';
7+
import { TranscodeConfigDB } from '../src/db/TranscodeConfigDB.ts';
8+
import { KEYS } from '../src/types/inject.ts';
9+
import { getAvailablePort } from '../src/util/net.ts';
10+
import { initTestApp } from './testServer.js';
11+
12+
let app: FastifyInstance;
13+
let validTranscodeConfigId: string;
14+
15+
const NON_EXISTENT_UUID = '00000000-0000-0000-0000-000000000000';
16+
17+
function makeChannelPayload(
18+
transcodeConfigId: string,
19+
): Partial<SaveableChannel> {
20+
return {
21+
name: 'Test Channel',
22+
number: 8001,
23+
duration: 60000,
24+
groupTitle: 'test',
25+
guideMinimumDuration: 30000,
26+
icon: {
27+
path: '',
28+
width: 0,
29+
duration: 0,
30+
position: 'bottom-right',
31+
},
32+
id: NON_EXISTENT_UUID,
33+
startTime: 0,
34+
stealth: false,
35+
offline: { mode: 'pic' },
36+
streamMode: 'hls',
37+
transcodeConfigId,
38+
disableFillerOverlay: false,
39+
subtitlesEnabled: false,
40+
};
41+
}
42+
43+
beforeAll(async () => {
44+
app = await initTestApp(await getAvailablePort());
45+
const transcodeConfigDB = container.get(TranscodeConfigDB);
46+
const defaultConfig = await transcodeConfigDB.getDefaultConfig();
47+
if (!defaultConfig) {
48+
throw new Error('Default transcode config not found after bootstrap');
49+
}
50+
validTranscodeConfigId = defaultConfig.uuid;
51+
});
52+
53+
afterAll(async () => {
54+
await app?.close();
55+
});
56+
57+
async function createChannel(): Promise<string> {
58+
const channelDB = container.get<ChannelDB>(KEYS.ChannelDB);
59+
const result = await channelDB.saveChannel({
60+
...makeChannelPayload(validTranscodeConfigId),
61+
name: 'Programming Validation Channel',
62+
} as SaveableChannel);
63+
return result.channel.uuid;
64+
}
65+
66+
describe('POST /channels/:id/programming - slot group validation on the save path', () => {
67+
test('rejects a schedule whose slots share an iterationGroup with mismatched ordering', async () => {
68+
const channelId = await createChannel();
69+
const groupId = v4();
70+
71+
const res = await app.inject({
72+
method: 'POST',
73+
url: `/api/channels/${channelId}/programming`,
74+
payload: {
75+
type: 'time',
76+
programs: [],
77+
schedule: {
78+
type: 'time',
79+
flexPreference: 'distribute',
80+
latenessMs: 0,
81+
maxDays: 1,
82+
padMs: 0,
83+
period: 'day',
84+
timeZoneOffset: 0,
85+
slots: [
86+
{
87+
type: 'movie',
88+
id: v4(),
89+
startTime: 0,
90+
order: 'next',
91+
direction: 'asc',
92+
iterationGroup: groupId,
93+
},
94+
{
95+
type: 'movie',
96+
id: v4(),
97+
startTime: 0,
98+
order: 'shuffle',
99+
direction: 'asc',
100+
iterationGroup: groupId,
101+
},
102+
],
103+
},
104+
},
105+
});
106+
107+
// The two slots share an iterationGroup but disagree on ordering — the
108+
// schedule must be rejected on the save path just like the preview
109+
// endpoints reject it, instead of being persisted and regenerated badly.
110+
expect(res.statusCode).toBe(400);
111+
expect(res.body).toContain('mismatched orderings');
112+
});
113+
});

0 commit comments

Comments
 (0)