feat: instance-level streaming limits with admin settings panel
Add a server-side instance_settings table (single-row, CHECK(id=1)) that stores admin-configurable streaming bounds: bitrate min/max/step, allowed resolutions, and allowed framerates. Backend: - New instance_settings schema + migrations (is_admin on users, default settings row, first-registered-user promoted to admin) - GET/PATCH /api/settings/streaming endpoints with admin-only writes and full input validation including cross-field checks Frontend: - settingsStore fetches limits on WebSocket ready, exposes isAdmin flag - ScreenShareSettingsPopover reads bounds from store instead of hardcoded constants, auto-clamps stale localStorage values - buildScreenShareOptions() clamps bitrate to server limits at build time as enforcement backstop - ServerSettings modal gains a "Streaming" tab (admin-only) with bitrate range sliders, resolution/framerate toggles, and save/reset
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import type { InstanceStreamingLimits } from '@opencord/shared';
|
||||
|
||||
const VALID_RESOLUTIONS = [540, 720, 1080];
|
||||
const VALID_FRAMERATES = [30, 45, 60];
|
||||
|
||||
function rowToLimits(row: typeof schema.instanceSettings.$inferSelect): InstanceStreamingLimits {
|
||||
return {
|
||||
maxBitrateKbps: row.maxBitrateKbps,
|
||||
minBitrateKbps: row.minBitrateKbps,
|
||||
bitrateStepKbps: row.bitrateStepKbps,
|
||||
allowedResolutions: row.allowedResolutions.split(',').map(Number).filter((n) => VALID_RESOLUTIONS.includes(n)),
|
||||
allowedFramerates: row.allowedFramerates.split(',').map(Number).filter((n) => VALID_FRAMERATES.includes(n)),
|
||||
maxResolution: row.maxResolution,
|
||||
maxFramerate: row.maxFramerate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/settings/streaming — any authenticated user can read instance limits
|
||||
app.get('/api/settings/streaming', { preHandler: authenticate }, async (_request, reply) => {
|
||||
const db = getDb();
|
||||
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!row) {
|
||||
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
|
||||
}
|
||||
return reply.code(200).send(rowToLimits(row));
|
||||
});
|
||||
|
||||
// PATCH /api/settings/streaming — admin only
|
||||
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// Verify caller is an instance admin
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can modify streaming settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
|
||||
|
||||
if (body.maxBitrateKbps !== undefined) {
|
||||
if (typeof body.maxBitrateKbps !== 'number' || body.maxBitrateKbps < 500 || body.maxBitrateKbps > 50000) {
|
||||
return reply.code(400).send({ error: 'maxBitrateKbps must be between 500 and 50000', statusCode: 400 });
|
||||
}
|
||||
updateData.maxBitrateKbps = body.maxBitrateKbps;
|
||||
}
|
||||
|
||||
if (body.minBitrateKbps !== undefined) {
|
||||
if (typeof body.minBitrateKbps !== 'number' || body.minBitrateKbps < 100 || body.minBitrateKbps > 50000) {
|
||||
return reply.code(400).send({ error: 'minBitrateKbps must be between 100 and 50000', statusCode: 400 });
|
||||
}
|
||||
updateData.minBitrateKbps = body.minBitrateKbps;
|
||||
}
|
||||
|
||||
if (body.bitrateStepKbps !== undefined) {
|
||||
if (typeof body.bitrateStepKbps !== 'number' || body.bitrateStepKbps < 50 || body.bitrateStepKbps > 5000) {
|
||||
return reply.code(400).send({ error: 'bitrateStepKbps must be between 50 and 5000', statusCode: 400 });
|
||||
}
|
||||
updateData.bitrateStepKbps = body.bitrateStepKbps;
|
||||
}
|
||||
|
||||
if (body.allowedResolutions !== undefined) {
|
||||
if (!Array.isArray(body.allowedResolutions) || body.allowedResolutions.length === 0) {
|
||||
return reply.code(400).send({ error: 'allowedResolutions must be a non-empty array', statusCode: 400 });
|
||||
}
|
||||
const invalid = body.allowedResolutions.filter((r) => !VALID_RESOLUTIONS.includes(r));
|
||||
if (invalid.length > 0) {
|
||||
return reply.code(400).send({ error: `Invalid resolutions: ${invalid.join(', ')}. Allowed: ${VALID_RESOLUTIONS.join(', ')}`, statusCode: 400 });
|
||||
}
|
||||
updateData.allowedResolutions = body.allowedResolutions.sort((a, b) => a - b).join(',');
|
||||
}
|
||||
|
||||
if (body.allowedFramerates !== undefined) {
|
||||
if (!Array.isArray(body.allowedFramerates) || body.allowedFramerates.length === 0) {
|
||||
return reply.code(400).send({ error: 'allowedFramerates must be a non-empty array', statusCode: 400 });
|
||||
}
|
||||
const invalid = body.allowedFramerates.filter((f) => !VALID_FRAMERATES.includes(f));
|
||||
if (invalid.length > 0) {
|
||||
return reply.code(400).send({ error: `Invalid framerates: ${invalid.join(', ')}. Allowed: ${VALID_FRAMERATES.join(', ')}`, statusCode: 400 });
|
||||
}
|
||||
updateData.allowedFramerates = body.allowedFramerates.sort((a, b) => a - b).join(',');
|
||||
}
|
||||
|
||||
if (body.maxResolution !== undefined) {
|
||||
if (!VALID_RESOLUTIONS.includes(body.maxResolution)) {
|
||||
return reply.code(400).send({ error: `maxResolution must be one of: ${VALID_RESOLUTIONS.join(', ')}`, statusCode: 400 });
|
||||
}
|
||||
updateData.maxResolution = body.maxResolution;
|
||||
}
|
||||
|
||||
if (body.maxFramerate !== undefined) {
|
||||
if (!VALID_FRAMERATES.includes(body.maxFramerate)) {
|
||||
return reply.code(400).send({ error: `maxFramerate must be one of: ${VALID_FRAMERATES.join(', ')}`, statusCode: 400 });
|
||||
}
|
||||
updateData.maxFramerate = body.maxFramerate;
|
||||
}
|
||||
|
||||
// Cross-field validation: min < max
|
||||
const currentRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!currentRow) {
|
||||
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
|
||||
}
|
||||
|
||||
const effectiveMin = (updateData.minBitrateKbps as number | undefined) ?? currentRow.minBitrateKbps;
|
||||
const effectiveMax = (updateData.maxBitrateKbps as number | undefined) ?? currentRow.maxBitrateKbps;
|
||||
if (effectiveMin >= effectiveMax) {
|
||||
return reply.code(400).send({ error: 'minBitrateKbps must be less than maxBitrateKbps', statusCode: 400 });
|
||||
}
|
||||
|
||||
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
|
||||
|
||||
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!updatedRow) {
|
||||
return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 });
|
||||
}
|
||||
|
||||
return reply.code(200).send(rowToLimits(updatedRow));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user