diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index 872b8150..af4be62b 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -263,6 +263,8 @@ export interface ServerConfigRecord { /** On unless the owner turns it off. */ spam_filter_enabled: boolean; spam_filter_sensitivity: SpamSensitivity; + /** Whether members may use a video avatar or banner, where the worker can transcode one. */ + video_profiles_enabled: boolean; is_configured: boolean; created_at: Date; updated_at: Date; diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 8ecca0d3..ae682119 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -92,6 +92,7 @@ function createSchema(d: DatabaseSync): void { allow_dms INTEGER NOT NULL DEFAULT 1, spam_filter INTEGER NOT NULL DEFAULT 1, spam_sensitivity TEXT NOT NULL DEFAULT 'normal', + video_profiles INTEGER NOT NULL DEFAULT 1, is_configured INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -941,6 +942,10 @@ function runMigrations(d: DatabaseSync): void { if (!hasColumn(d, "server_config", "spam_sensitivity")) { d.exec("ALTER TABLE server_config ADD COLUMN spam_sensitivity TEXT NOT NULL DEFAULT 'normal'"); } + // On by default: it only takes effect where the worker can transcode video (GRYT-1664). + if (!hasColumn(d, "server_config", "video_profiles")) { + d.exec("ALTER TABLE server_config ADD COLUMN video_profiles INTEGER NOT NULL DEFAULT 1"); + } // Stored and handed back whole, and never read here. One column rather than a // key beside a signature, so the two cannot be mixed between members. diff --git a/src/db/sqlite/servers.ts b/src/db/sqlite/servers.ts index c0a25f0e..a2d95650 100644 --- a/src/db/sqlite/servers.ts +++ b/src/db/sqlite/servers.ts @@ -77,6 +77,7 @@ function rowToConfig(r: Record): ServerConfigRecord { allow_dms: (r.allow_dms as number) !== 0, spam_filter_enabled: (r.spam_filter as number) !== 0, spam_filter_sensitivity: normalizeSpamSensitivity(r.spam_sensitivity), + video_profiles_enabled: (r.video_profiles as number) !== 0, is_configured: (r.is_configured as number) === 1, created_at: fromIso(r.created_at as string), updated_at: fromIso(r.updated_at as string), @@ -241,6 +242,7 @@ export async function updateServerConfig(patch: { allowDms?: boolean; spamFilter?: boolean; spamSensitivity?: SpamSensitivity; + videoProfiles?: boolean; isConfigured?: boolean; }): Promise { const db = getSqliteDb(); @@ -270,6 +272,7 @@ export async function updateServerConfig(patch: { allowDms: { col: "allow_dms", transform: (v) => v ? 1 : 0 }, spamFilter: { col: "spam_filter", transform: (v) => v ? 1 : 0 }, spamSensitivity: { col: "spam_sensitivity", transform: (v) => normalizeSpamSensitivity(v) }, + videoProfiles: { col: "video_profiles", transform: (v) => v ? 1 : 0 }, isConfigured: { col: "is_configured", transform: (v) => v ? 1 : 0 }, }; diff --git a/src/routes/uploadQuarantine.test.ts b/src/routes/uploadQuarantine.test.ts index 741f5e1f..fa6e3193 100644 --- a/src/routes/uploadQuarantine.test.ts +++ b/src/routes/uploadQuarantine.test.ts @@ -11,7 +11,7 @@ import sharp from "sharp"; import { getSqliteDb, initSqlite } from "../db/sqlite/connection"; import { getFile } from "../db/sqlite/messages"; -import { createServerConfigIfNotExists, setServerRole } from "../db/sqlite/servers"; +import { createServerConfigIfNotExists, setServerRole, updateServerConfig } from "../db/sqlite/servers"; import { getUserByServerId, upsertUser } from "../db/sqlite/users"; import { refreshWorkerCapabilities } from "../services/workerCapabilities"; import { buildMemberList } from "../socket/utils/clients"; @@ -189,6 +189,12 @@ describe("uploads with a worker that clears quarantine", () => { assert.equal(file?.mime, "video/mp4"); workerWritesOut(videoId, `banners/${videoId}.mp4`, "video/mp4"); await until(async () => (await getUserByServerId(dave.serverUserId))?.banner_file_id === videoId); + await updateServerConfig({ videoProfiles: false }); + const off = await sendVideo(); + assert.equal(off.status, 415, "the owner turned video off"); + assert.equal(off.body.error, "video_disabled"); + await updateServerConfig({ videoProfiles: true }); + const row = (await buildMemberList({})).find((m) => m.serverUserId === dave.serverUserId); assert.equal(row?.bannerVideo, true, "the member list says it plays"); assert.equal(row?.avatarVideo, false); diff --git a/src/routes/uploads.ts b/src/routes/uploads.ts index 86eab7bb..34470b78 100644 --- a/src/routes/uploads.ts +++ b/src/routes/uploads.ts @@ -209,6 +209,10 @@ const storeAvatarImage = (purpose: "avatar" | "group" | "webhook" | "banner") => Promise.resolve() .then(async () => { const cfg = await getServerConfig().catch(() => null); + if (isVideo && cfg?.video_profiles_enabled === false) { + res.status(415).json({ error: "video_disabled", message: "This server's owner has turned off video avatars and banners. Use a picture instead." }); + return; + } const maxBytes = isVideo ? (typeof cfg?.upload_max_bytes === "number" ? cfg.upload_max_bytes : DEFAULT_UPLOAD_MAX_BYTES) : (typeof cfg?.avatar_max_bytes === "number" ? cfg.avatar_max_bytes : DEFAULT_AVATAR_MAX_BYTES); diff --git a/src/settings/serverSettings.ts b/src/settings/serverSettings.ts index 819e6329..eb6af5fa 100644 --- a/src/settings/serverSettings.ts +++ b/src/settings/serverSettings.ts @@ -13,6 +13,7 @@ import { broadcastServerUiUpdate } from "../socket/utils/server"; import { syncMdnsAdvertising } from "../mdns"; import { VALID_CENSOR_STYLES, type CensorStyle } from "../utils/profanityFilter"; import { SPAM_SENSITIVITIES, type SpamSensitivity } from "../moderation/spamFilter"; +import { workerClearsQuarantine, workerTranscodesVideo } from "../services/workerCapabilities"; /** * One place, for the side effects rather than the validation: a caller that only @@ -34,6 +35,7 @@ export interface SettingsPatch { discoverable?: boolean; spamFilter?: boolean; spamSensitivity?: string; + videoProfiles?: boolean; } /** Who asked for the change, for the audit trail. */ @@ -103,6 +105,8 @@ export async function applyServerSettings( ? patch.spamSensitivity as SpamSensitivity : undefined; + const videoProfiles: boolean | undefined = typeof patch.videoProfiles === "boolean" ? patch.videoProfiles : undefined; + const updated = await updateServerConfig({ displayName: displayName === undefined ? undefined : (displayName.length > 0 ? displayName : null), description: description === undefined ? undefined : (description.length > 0 ? description : null), @@ -119,6 +123,7 @@ export async function applyServerSettings( discoverable, spamFilter, spamSensitivity, + videoProfiles, }); if (systemChannelId !== undefined) invalidateSystemChannelCache(); @@ -169,5 +174,8 @@ export function settingsView(cfg: ServerConfigRecord, serverId: string, isOwner: discoverable: cfg.discoverable !== false, spamFilter: cfg.spam_filter_enabled !== false, spamSensitivity: cfg.spam_filter_sensitivity ?? "normal", + videoProfiles: cfg.video_profiles_enabled !== false, + // What this host can do, so settings can say why a switch is off rather than hide it. + media: { checked: workerClearsQuarantine(), video: workerTranscodesVideo() }, }; } diff --git a/src/socket/handlers/admin.ts b/src/socket/handlers/admin.ts index 82ad2c3a..6ec74ec8 100644 --- a/src/socket/handlers/admin.ts +++ b/src/socket/handlers/admin.ts @@ -298,6 +298,7 @@ export function registerAdminHandlers(ctx: HandlerContext): EventHandlerMap { discoverable?: boolean; spamFilter?: boolean; spamSensitivity?: string; + videoProfiles?: boolean; }) => { try { const rl = rlCheck("server:settings:update", ctx, RL_SETTINGS); diff --git a/src/socket/utils/server.ts b/src/socket/utils/server.ts index 28b09643..29afeb10 100644 --- a/src/socket/utils/server.ts +++ b/src/socket/utils/server.ts @@ -32,6 +32,7 @@ import { } from "../../db"; import { pushVoiceCapabilities } from "./voiceCapabilities"; +import { workerClearsQuarantine, workerTranscodesVideo } from "../../services/workerCapabilities"; import { MLS_CIPHERSUITE_ID } from "../../services/mlsWire"; import { mlsRetentionDays } from "../../jobs/mlsRetention"; @@ -366,6 +367,7 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in let cfgIconUrl: string | null = null; let cfgAvatarMaxBytes: number = DEFAULT_AVATAR_MAX_BYTES; let cfgUploadMaxBytes: number = DEFAULT_UPLOAD_MAX_BYTES; + let cfgVideoProfiles = true; let isOwner = false; let role = FALLBACK_ROLE_ID; let roleIds: string[] = []; @@ -380,6 +382,7 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in if (cfg?.icon_url) cfgIconUrl = cfg.icon_url; if (typeof cfg?.avatar_max_bytes === "number") cfgAvatarMaxBytes = cfg.avatar_max_bytes; if (typeof cfg?.upload_max_bytes === "number") cfgUploadMaxBytes = cfg.upload_max_bytes; + cfgVideoProfiles = cfg?.video_profiles_enabled !== false; isOwner = !!(cfg?.owner_gryt_user_id && cfg.owner_gryt_user_id === client.grytUserId); if (client.serverUserId && !client.serverUserId.startsWith("temp_")) { const standing = await getEffectiveStanding(client.serverUserId, client.grytUserId); @@ -431,6 +434,9 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in voice_enabled: !!sfuHost, avatar_max_bytes: cfgAvatarMaxBytes, upload_max_bytes: cfgUploadMaxBytes, + /** Uploads go through the worker's sandbox first, and whether a video avatar or banner is taken. */ + uploads_checked: workerClearsQuarantine(), + video_profiles: workerTranscodesVideo() && cfgVideoProfiles, version: process.env.SERVER_VERSION || "1.0.0", /** What code sits between a member and the people they talk to, so it is not configurable. No version: that names which known problem applies. */