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:
@@ -174,6 +174,18 @@ function createTables(db: Database.Database): void {
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (folder_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instance_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
|
||||
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
|
||||
bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
|
||||
allowed_resolutions TEXT NOT NULL DEFAULT '540,720,1080',
|
||||
allowed_framerates TEXT NOT NULL DEFAULT '30,45,60',
|
||||
max_resolution INTEGER NOT NULL DEFAULT 1080,
|
||||
max_framerate INTEGER NOT NULL DEFAULT 60,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'owner_id', type: 'TEXT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
columns: [
|
||||
{ name: 'is_admin', type: 'INTEGER DEFAULT 0' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -82,9 +88,38 @@ export function runMigrations(db: Database.Database): void {
|
||||
// ─── RBAC Migration: Ensure @everyone roles exist for all servers ─────────
|
||||
migrateEveryoneRoles(db);
|
||||
|
||||
// ─── Instance settings: ensure default row exists ──────────────────────────
|
||||
migrateInstanceSettings(db);
|
||||
|
||||
// ─── Admin flag: ensure at least one admin exists (first registered user) ──
|
||||
migrateFirstAdmin(db);
|
||||
|
||||
console.log('Migrations complete.');
|
||||
}
|
||||
|
||||
/** Ensure the single-row instance_settings row exists */
|
||||
function migrateInstanceSettings(db: Database.Database): void {
|
||||
const row = db.prepare('SELECT id FROM instance_settings WHERE id = 1').get();
|
||||
if (!row) {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO instance_settings (id, max_bitrate_kbps, min_bitrate_kbps, bitrate_step_kbps, allowed_resolutions, allowed_framerates, max_resolution, max_framerate, updated_at) VALUES (1, 20000, 500, 500, ?, ?, 1080, 60, ?)'
|
||||
).run('540,720,1080', '30,45,60', Date.now());
|
||||
console.log('Migrating: Inserted default instance_settings row');
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure at least one user has is_admin = 1 (the earliest registered user) */
|
||||
function migrateFirstAdmin(db: Database.Database): void {
|
||||
const anyAdmin = db.prepare('SELECT id FROM users WHERE is_admin = 1 LIMIT 1').get();
|
||||
if (!anyAdmin) {
|
||||
const firstUser = db.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1').get() as { id: string } | undefined;
|
||||
if (firstUser) {
|
||||
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(firstUser.id);
|
||||
console.log(`Migrating: Set first user ${firstUser.id} as instance admin`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** For each server, ensure an @everyone role exists with id === server.id */
|
||||
function migrateEveryoneRoles(db: Database.Database): void {
|
||||
const servers = db.prepare('SELECT id FROM servers').all() as { id: string }[];
|
||||
|
||||
@@ -8,6 +8,7 @@ export const users = sqliteTable('users', {
|
||||
avatar: text('avatar'),
|
||||
status: text('status').default('offline'),
|
||||
customStatus: text('custom_status'),
|
||||
isAdmin: integer('is_admin').default(0),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -173,3 +174,15 @@ export const serverFolderMembers = sqliteTable('server_folder_members', {
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.folderId, table.serverId] }),
|
||||
}));
|
||||
|
||||
export const instanceSettings = sqliteTable('instance_settings', {
|
||||
id: integer('id').primaryKey().default(1),
|
||||
maxBitrateKbps: integer('max_bitrate_kbps').notNull().default(20000),
|
||||
minBitrateKbps: integer('min_bitrate_kbps').notNull().default(500),
|
||||
bitrateStepKbps: integer('bitrate_step_kbps').notNull().default(500),
|
||||
allowedResolutions: text('allowed_resolutions').notNull().default('540,720,1080'),
|
||||
allowedFramerates: text('allowed_framerates').notNull().default('30,45,60'),
|
||||
maxResolution: integer('max_resolution').notNull().default(1080),
|
||||
maxFramerate: integer('max_framerate').notNull().default(60),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function seedDatabase(): Promise<void> {
|
||||
displayName: 'Admin',
|
||||
passwordHash: adminPasswordHash,
|
||||
status: 'offline',
|
||||
isAdmin: 1,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { uploadRoutes } from './routes/uploads.js';
|
||||
import { dmRoutes } from './routes/dm.js';
|
||||
import { livekitRoutes } from './routes/livekit.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { utilRoutes } from './routes/utils.js';
|
||||
import { registerWebSocket } from './ws/handler.js';
|
||||
import path from 'path';
|
||||
@@ -72,6 +73,7 @@ async function main(): Promise<void> {
|
||||
await app.register(dmRoutes);
|
||||
await app.register(livekitRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
await app.register(registerWebSocket);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -67,12 +68,17 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const userId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
// First registered user becomes instance admin
|
||||
const userCount = db.select().from(schema.users).all().length;
|
||||
const isFirstUser = userCount === 0;
|
||||
|
||||
db.insert(schema.users).values({
|
||||
id: userId,
|
||||
username: trimmedUsername,
|
||||
displayName: displayName?.trim() || null,
|
||||
passwordHash,
|
||||
status: 'online',
|
||||
isAdmin: isFirstUser ? 1 : 0,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
avatar: row.avatar,
|
||||
status: (row.status ?? 'offline') as User['status'],
|
||||
customStatus: row.customStatus,
|
||||
isAdmin: row.isAdmin === 1,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user