From 84f10b4c60dec43631fda251e20a027883514fcc Mon Sep 17 00:00:00 2001 From: Sivert Date: Mon, 5 Oct 2026 19:35:36 +0200 Subject: [PATCH] A server setting for other servers' emoji, off by default (GRYT-1660) Messages only show images from their own server now (client#791). This adds the owner's switch for the one exception: other Gryt servers' custom emoji. It's off by default, because the emoji loads from the other server, which then sees the address of everyone reading. external_emojis in server_config, externalEmojis in the settings patch and view, and external_emojis in server_info for clients to read. Co-Authored-By: Claude Opus 5.5 --- src/db/interfaces.ts | 2 ++ src/db/sqlite/connection.ts | 5 ++++ src/db/sqlite/servers.ts | 3 ++ src/settings/externalEmojis.test.ts | 45 +++++++++++++++++++++++++++++ src/settings/serverSettings.ts | 4 +++ src/socket/handlers/admin.ts | 1 + src/socket/utils/server.ts | 4 +++ 7 files changed, 64 insertions(+) create mode 100644 src/settings/externalEmojis.test.ts diff --git a/src/db/interfaces.ts b/src/db/interfaces.ts index 3192506d..a4a12258 100644 --- a/src/db/interfaces.ts +++ b/src/db/interfaces.ts @@ -269,6 +269,8 @@ export interface ServerConfigRecord { spam_filter_sensitivity: SpamSensitivity; /** Whether members may use a video avatar or banner, where the worker can transcode one. */ video_profiles_enabled: boolean; + /** Whether messages here may show other Gryt servers' custom emoji. Off: they load from another host. */ + external_emojis_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 ae682119..a3867bc0 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -93,6 +93,7 @@ function createSchema(d: DatabaseSync): void { spam_filter INTEGER NOT NULL DEFAULT 1, spam_sensitivity TEXT NOT NULL DEFAULT 'normal', video_profiles INTEGER NOT NULL DEFAULT 1, + external_emojis INTEGER NOT NULL DEFAULT 0, is_configured INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -946,6 +947,10 @@ function runMigrations(d: DatabaseSync): void { if (!hasColumn(d, "server_config", "video_profiles")) { d.exec("ALTER TABLE server_config ADD COLUMN video_profiles INTEGER NOT NULL DEFAULT 1"); } + // Off by default: another server's emoji loads from that host, which then sees who read it (GRYT-1660). + if (!hasColumn(d, "server_config", "external_emojis")) { + d.exec("ALTER TABLE server_config ADD COLUMN external_emojis INTEGER NOT NULL DEFAULT 0"); + } // 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 a2d95650..88d2172d 100644 --- a/src/db/sqlite/servers.ts +++ b/src/db/sqlite/servers.ts @@ -78,6 +78,7 @@ function rowToConfig(r: Record): ServerConfigRecord { 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, + external_emojis_enabled: (r.external_emojis as number) === 1, is_configured: (r.is_configured as number) === 1, created_at: fromIso(r.created_at as string), updated_at: fromIso(r.updated_at as string), @@ -243,6 +244,7 @@ export async function updateServerConfig(patch: { spamFilter?: boolean; spamSensitivity?: SpamSensitivity; videoProfiles?: boolean; + externalEmojis?: boolean; isConfigured?: boolean; }): Promise { const db = getSqliteDb(); @@ -273,6 +275,7 @@ export async function updateServerConfig(patch: { 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 }, + externalEmojis: { col: "external_emojis", transform: (v) => v ? 1 : 0 }, isConfigured: { col: "is_configured", transform: (v) => v ? 1 : 0 }, }; diff --git a/src/settings/externalEmojis.test.ts b/src/settings/externalEmojis.test.ts new file mode 100644 index 00000000..a4b6fdb6 --- /dev/null +++ b/src/settings/externalEmojis.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; + +import { initSqlite } from "../db/sqlite/connection"; +import { createServerConfigIfNotExists, getServerConfig } from "../db/sqlite/servers"; +import { applyServerSettings, settingsView } from "./serverSettings"; + +let dir: string; + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "gryt-ext-emoji-")); + process.env.DATA_DIR = dir; + await initSqlite(); + await createServerConfigIfNotExists(); +}); + +after(() => { + delete process.env.DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("other servers' emoji (GRYT-1660)", () => { + it("is off until the owner turns it on, and stays as set", async () => { + const cfg = await getServerConfig(); + assert.ok(cfg); + assert.equal(cfg.external_emojis_enabled, false); + assert.equal(settingsView(cfg, "s", true).externalEmojis, false); + + const on = await applyServerSettings({ externalEmojis: true }, { serverUserId: null, via: "management" }); + assert.equal(on.external_emojis_enabled, true); + assert.equal(settingsView(on, "s", true).externalEmojis, true); + + // Leaving it out of a patch changes nothing, and anything but a boolean is ignored. + const untouched = await applyServerSettings({ displayName: "Elsewhere" }, { serverUserId: null, via: "management" }); + assert.equal(untouched.external_emojis_enabled, true); + const junk = await applyServerSettings({ externalEmojis: "yes" as unknown as boolean }, { serverUserId: null, via: "management" }); + assert.equal(junk.external_emojis_enabled, true); + + const off = await applyServerSettings({ externalEmojis: false }, { serverUserId: null, via: "management" }); + assert.equal(off.external_emojis_enabled, false); + }); +}); diff --git a/src/settings/serverSettings.ts b/src/settings/serverSettings.ts index eb6af5fa..dac1a99f 100644 --- a/src/settings/serverSettings.ts +++ b/src/settings/serverSettings.ts @@ -36,6 +36,7 @@ export interface SettingsPatch { spamFilter?: boolean; spamSensitivity?: string; videoProfiles?: boolean; + externalEmojis?: boolean; } /** Who asked for the change, for the audit trail. */ @@ -106,6 +107,7 @@ export async function applyServerSettings( : undefined; const videoProfiles: boolean | undefined = typeof patch.videoProfiles === "boolean" ? patch.videoProfiles : undefined; + const externalEmojis: boolean | undefined = typeof patch.externalEmojis === "boolean" ? patch.externalEmojis : undefined; const updated = await updateServerConfig({ displayName: displayName === undefined ? undefined : (displayName.length > 0 ? displayName : null), @@ -124,6 +126,7 @@ export async function applyServerSettings( spamFilter, spamSensitivity, videoProfiles, + externalEmojis, }); if (systemChannelId !== undefined) invalidateSystemChannelCache(); @@ -175,6 +178,7 @@ export function settingsView(cfg: ServerConfigRecord, serverId: string, isOwner: spamFilter: cfg.spam_filter_enabled !== false, spamSensitivity: cfg.spam_filter_sensitivity ?? "normal", videoProfiles: cfg.video_profiles_enabled !== false, + externalEmojis: cfg.external_emojis_enabled === true, // 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 6ec74ec8..e86dac0d 100644 --- a/src/socket/handlers/admin.ts +++ b/src/socket/handlers/admin.ts @@ -299,6 +299,7 @@ export function registerAdminHandlers(ctx: HandlerContext): EventHandlerMap { spamFilter?: boolean; spamSensitivity?: string; videoProfiles?: boolean; + externalEmojis?: 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 688f8238..440c40ad 100644 --- a/src/socket/utils/server.ts +++ b/src/socket/utils/server.ts @@ -376,6 +376,7 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in let cfgAvatarMaxBytes: number = DEFAULT_AVATAR_MAX_BYTES; let cfgUploadMaxBytes: number = DEFAULT_UPLOAD_MAX_BYTES; let cfgVideoProfiles = true; + let cfgExternalEmojis = false; let isOwner = false; let role = FALLBACK_ROLE_ID; let roleIds: string[] = []; @@ -391,6 +392,7 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in 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; + cfgExternalEmojis = cfg?.external_emojis_enabled === true; 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); @@ -445,6 +447,8 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in /** Uploads go through the worker's sandbox first, and whether a video avatar or banner is taken. */ uploads_checked: workerClearsQuarantine(), video_profiles: workerTranscodesVideo() && cfgVideoProfiles, + /** Messages here may show other Gryt servers' custom emoji. */ + external_emojis: cfgExternalEmojis, 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. */