From a06776fd86ff46cac1ca164ba9fc925a380e7922 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 10 May 2026 18:23:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20PATCH=20/api/dm/:id=20=E2=80=94?= =?UTF-8?q?=20group=20name=20+=20icon=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/dm.metadataUpdate.test.ts | 576 ++++++++++++++++++ packages/server/src/routes/dm.ts | 262 +++++++- packages/server/src/routes/users.ts | 2 +- 3 files changed, 838 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/routes/dm.metadataUpdate.test.ts diff --git a/packages/server/src/routes/dm.metadataUpdate.test.ts b/packages/server/src/routes/dm.metadataUpdate.test.ts new file mode 100644 index 00000000..36999b3b --- /dev/null +++ b/packages/server/src/routes/dm.metadataUpdate.test.ts @@ -0,0 +1,576 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let currentUserId = 'owner-A'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = currentUserId; + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + getRoom: () => undefined, + getUserRoom: () => undefined, + }, +})); + +// Federation: keep the real queueGroupMetadataRelay AND its underlying outbox +// writes (queueOutboxEvent / appendMutationLog) so we can assert the wire +// payload by reading the federation_outbox table directly. Same-module callers +// inside the helper bypass vi.mock, so spy-on-the-helper is the wrong axis — +// inspect the persisted outbox row instead. The unrelated queue helpers +// (queueDmRelay, queueDmCloseRelay, sendTypingRelay) are still stubbed because +// they are not under test here. +vi.mock('../utils/federationOutbox.js', async () => { + const actual = await vi.importActual('../utils/federationOutbox.js'); + return { + ...actual, + isFederationRelayEnabled: () => true, + queueDmCloseRelay: vi.fn(), + sendTypingRelay: vi.fn(), + queueDmRelay: vi.fn(), + }; +}); + +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://local.test' }; +}); + +// Mock fileCleanup so we can observe icon-deletion calls without touching disk. +vi.mock('../utils/fileCleanup.js', async () => { + const actual = await vi.importActual('../utils/fileCleanup.js'); + return { + ...actual, + deleteUploadFile: vi.fn(), + deleteAttachmentByFilename: vi.fn(), + deleteAttachmentFiles: vi.fn(), + }; +}); +import { deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js'; + +// Connection manager mock retrieval for spy assertions +import { connectionManager } from '../ws/handler.js'; + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedUsers(): void { + testDb.insert(schema.users).values({ + id: 'owner-A', + username: 'alice', + displayName: 'Alice', + passwordHash: 'x', + homeUserId: 'owner-A', + homeInstance: 'https://local.test', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: 'member-B', + username: 'bob', + displayName: 'Bob', + passwordHash: 'x', + homeUserId: 'member-B', + homeInstance: 'https://local.test', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: 'remote-C', + username: 'carol@remote.test', + displayName: 'Carol', + passwordHash: 'x', + homeUserId: 'remote-carol', + homeInstance: 'https://remote.test', + createdAt: Date.now(), + }).run(); + + testDb.insert(schema.users).values({ + id: 'stranger-D', + username: 'dan', + displayName: 'Dan', + passwordHash: 'x', + homeUserId: 'stranger-D', + homeInstance: 'https://local.test', + createdAt: Date.now(), + }).run(); +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + federationRelayEnabled: 1, + updatedAt: Date.now(), + }).run(); +} + +function seedFederationPeer(origin: string): void { + testDb.insert(schema.federationPeers).values({ + id: `peer-${origin}`, + origin, + status: 'active', + hmacSecret: 'x', + createdAt: Date.now(), + }).run(); +} + +interface GroupSeed { + id: string; + ownerId: string; + members: string[]; + federatedId?: string | null; + name?: string | null; + icon?: string | null; + metadataUpdatedAt?: number; +} + +function seedGroupDm(opts: GroupSeed): void { + testDb.insert(schema.dmChannels).values({ + id: opts.id, + ownerId: opts.ownerId, + federatedId: opts.federatedId ?? null, + name: opts.name ?? null, + icon: opts.icon ?? null, + metadataUpdatedAt: opts.metadataUpdatedAt ?? 0, + createdAt: Date.now(), + }).run(); + for (const userId of opts.members) { + testDb.insert(schema.dmMembers).values({ + dmChannelId: opts.id, + userId, + }).run(); + } +} + +function seed1on1Dm(id: string, a: string, b: string): void { + testDb.insert(schema.dmChannels).values({ + id, + ownerId: null, + createdAt: Date.now(), + }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: id, userId: a }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: id, userId: b }).run(); +} + +function seedAttachment(opts: { + id: string; + filename: string; + uploaderId: string; + mimetype: string; + size: number; +}): void { + testDb.insert(schema.attachments).values({ + id: opts.id, + uploaderId: opts.uploaderId, + filename: opts.filename, + originalName: opts.filename, + mimetype: opts.mimetype, + size: opts.size, + createdAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { dmRoutes } = await import('./dm.js'); + await app.register(dmRoutes); + await app.ready(); + return app; +} + +describe('PATCH /api/dm/:id — group metadata update', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + seedUsers(); + seedFederationPeer('https://remote.test'); + currentUserId = 'owner-A'; + vi.clearAllMocks(); + app = await buildApp(); + }); + + it('owner rename succeeds → 200, channel updated, broadcast, system msg, outbox payload', async () => { + seedGroupDm({ + id: 'dm-1', + ownerId: 'owner-A', + members: ['owner-A', 'member-B', 'remote-C'], + federatedId: 'fed-abc-1', + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-1', + payload: { name: 'My Group' }, + }); + expect(res.statusCode).toBe(200); + + // Channel row updated + const updated = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm-1')).get(); + expect(updated?.name).toBe('My Group'); + expect(updated?.icon).toBeNull(); + expect(updated?.metadataUpdatedAt).toBeGreaterThan(0); + + // dm_channel_updated broadcast + const sendCalls = (connectionManager.sendToDmMembers as ReturnType).mock.calls; + const updatedBroadcast = sendCalls.find((c) => c[1]?.type === 'dm_channel_updated'); + expect(updatedBroadcast).toBeDefined(); + expect(updatedBroadcast?.[1]).toMatchObject({ type: 'dm_channel_updated', dmChannelId: 'dm-1', name: 'My Group', icon: null }); + + // Single system message for name change + const sysRows = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, 'dm-1')).all(); + expect(sysRows.length).toBe(1); + expect(sysRows[0]!.type).toBe('system'); + const parsed = JSON.parse(sysRows[0]!.content!); + expect(parsed).toEqual({ event: 'name_changed', oldName: null, newName: 'My Group' }); + expect(sysRows[0]!.sourceMessageId).toMatch(/:name$/); + + // Federation outbox row queued — full payload + const outboxRows = testDb.select().from(schema.federationOutbox).all(); + const metaRows = outboxRows.filter((r) => r.eventType === 'group_metadata_update'); + expect(metaRows.length).toBe(1); + const wire = JSON.parse(metaRows[0]!.payload); + expect(wire.federatedId).toBe('fed-abc-1'); + expect(wire.metadata.name).toBe('My Group'); + expect(wire.metadata.icon).toBeNull(); + expect(wire.metadata.metadataUpdatedAt).toBeGreaterThan(0); + expect(wire.metadata.actor.homeUserId).toBe('owner-A'); + expect(wire.metadata.actor.homeInstance).toBe('https://local.test'); + }); + + it('owner sets icon (filename) → outbox icon is absolute URL ${ourOrigin}/api/uploads/', async () => { + seedGroupDm({ + id: 'dm-2', + ownerId: 'owner-A', + members: ['owner-A', 'member-B', 'remote-C'], + federatedId: 'fed-abc-2', + }); + seedAttachment({ + id: 'att-1', + filename: 'icon123.png', + uploaderId: 'owner-A', + mimetype: 'image/png', + size: 1024, + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-2', + payload: { icon: 'icon123.png' }, + }); + expect(res.statusCode).toBe(200); + + const outboxRows = testDb.select().from(schema.federationOutbox).all(); + const metaRows = outboxRows.filter((r) => r.eventType === 'group_metadata_update'); + expect(metaRows.length).toBe(1); + const wire = JSON.parse(metaRows[0]!.payload); + expect(wire.metadata.icon).toBe('https://local.test/api/uploads/icon123.png'); + + // Bare filename stored in DB (not absolute URL) + const updated = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm-2')).get(); + expect(updated?.icon).toBe('icon123.png'); + }); + + it('non-owner → 403', async () => { + seedGroupDm({ + id: 'dm-3', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + }); + currentUserId = 'member-B'; + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-3', + payload: { name: 'Nope' }, + }); + expect(res.statusCode).toBe(403); + const body = res.json(); + expect(body.error).toMatch(/owner/i); + }); + + it('1-on-1 DM → 400', async () => { + seed1on1Dm('dm-1on1', 'owner-A', 'member-B'); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-1on1', + payload: { name: 'Cant' }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/1-on-1/i); + }); + + it('name length 0 (after trim) → cleared (stored null), system message reflects oldName/newName', async () => { + seedGroupDm({ + id: 'dm-4', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + name: 'Old Name', + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-4', + payload: { name: ' ' }, + }); + expect(res.statusCode).toBe(200); + + const updated = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm-4')).get(); + expect(updated?.name).toBeNull(); + + const sys = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, 'dm-4')).all(); + expect(sys.length).toBe(1); + expect(JSON.parse(sys[0]!.content!)).toEqual({ event: 'name_changed', oldName: 'Old Name', newName: null }); + }); + + it('name length 51 → 400', async () => { + seedGroupDm({ + id: 'dm-5', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-5', + payload: { name: 'x'.repeat(51) }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/between 1 and 50/i); + }); + + it('icon clear (icon: null) → row updated, icon_changed system message, old local file deletion called', async () => { + seedGroupDm({ + id: 'dm-6', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + icon: 'oldicon.png', + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-6', + payload: { icon: null }, + }); + expect(res.statusCode).toBe(200); + + const updated = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm-6')).get(); + expect(updated?.icon).toBeNull(); + + const sys = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, 'dm-6')).all(); + expect(sys.length).toBe(1); + expect(JSON.parse(sys[0]!.content!)).toEqual({ event: 'icon_changed' }); + expect(sys[0]!.sourceMessageId).toMatch(/:icon$/); + + expect(deleteUploadFile).toHaveBeenCalledWith('oldicon.png'); + expect(deleteAttachmentByFilename).toHaveBeenCalledWith('oldicon.png'); + }); + + it('icon filename uploaded by another user → 403', async () => { + seedGroupDm({ + id: 'dm-7', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + }); + seedAttachment({ + id: 'att-2', + filename: 'someoneelse.png', + uploaderId: 'stranger-D', + mimetype: 'image/png', + size: 1024, + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-7', + payload: { icon: 'someoneelse.png' }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toMatch(/own/i); + }); + + it('icon filename with non-image mimetype → 400', async () => { + seedGroupDm({ + id: 'dm-8', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + }); + seedAttachment({ + id: 'att-3', + filename: 'notanimage.txt', + uploaderId: 'owner-A', + mimetype: 'text/plain', + size: 100, + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-8', + payload: { icon: 'notanimage.txt' }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/image/i); + }); + + it('icon filename oversize → 400', async () => { + seedGroupDm({ + id: 'dm-9', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + }); + seedAttachment({ + id: 'att-4', + filename: 'huge.png', + uploaderId: 'owner-A', + mimetype: 'image/png', + size: 9 * 1024 * 1024, // > 8 MB cap + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-9', + payload: { icon: 'huge.png' }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/MB/i); + }); + + it('icon absolute URL accepted (no attachment lookup attempted)', async () => { + seedGroupDm({ + id: 'dm-10', + ownerId: 'owner-A', + members: ['owner-A', 'member-B'], + federatedId: 'fed-abc-10', + }); + // Note: NO attachment seeded — proves no lookup happens for absolute URLs. + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-10', + payload: { icon: 'https://remote.test/api/uploads/icon999.png' }, + }); + expect(res.statusCode).toBe(200); + + const updated = testDb.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, 'dm-10')).get(); + expect(updated?.icon).toBe('https://remote.test/api/uploads/icon999.png'); + }); + + it('no-op (same name + same icon) → 200, no system messages, no broadcast, no outbox row', async () => { + seedGroupDm({ + id: 'dm-11', + ownerId: 'owner-A', + members: ['owner-A', 'member-B', 'remote-C'], + federatedId: 'fed-abc-11', + name: 'Stable', + icon: 'stable.png', + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-11', + payload: { name: 'Stable', icon: 'stable.png' }, + }); + expect(res.statusCode).toBe(200); + + // No system messages + const sys = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, 'dm-11')).all(); + expect(sys.length).toBe(0); + + // No broadcast + const sendCalls = (connectionManager.sendToDmMembers as ReturnType).mock.calls; + expect(sendCalls.length).toBe(0); + + // No outbox row + const outboxRows = testDb.select().from(schema.federationOutbox).all(); + expect(outboxRows.length).toBe(0); + + // No icon deletion + expect(deleteUploadFile).not.toHaveBeenCalled(); + }); + + it('concurrent change of both fields → two system messages with deterministic dedup-suffixed sourceMessageId', async () => { + seedGroupDm({ + id: 'dm-12', + ownerId: 'owner-A', + members: ['owner-A', 'member-B', 'remote-C'], + federatedId: 'fed-abc-12', + name: 'Old', + icon: 'old.png', + }); + seedAttachment({ + id: 'att-12', + filename: 'newicon.png', + uploaderId: 'owner-A', + mimetype: 'image/png', + size: 2048, + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/dm/dm-12', + payload: { name: 'New Name', icon: 'newicon.png' }, + }); + expect(res.statusCode).toBe(200); + + const sys = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.dmChannelId, 'dm-12')).all(); + expect(sys.length).toBe(2); + + const nameRow = sys.find((r) => JSON.parse(r.content!).event === 'name_changed'); + const iconRow = sys.find((r) => JSON.parse(r.content!).event === 'icon_changed'); + expect(nameRow).toBeDefined(); + expect(iconRow).toBeDefined(); + + // Both rows share the same eventMessageId root, suffixed by :name / :icon. + expect(nameRow!.sourceMessageId).toMatch(/:name$/); + expect(iconRow!.sourceMessageId).toMatch(/:icon$/); + const nameRoot = nameRow!.sourceMessageId!.replace(/:name$/, ''); + const iconRoot = iconRow!.sourceMessageId!.replace(/:icon$/, ''); + expect(nameRoot).toBe(iconRoot); + + // Old icon was a local filename — cleanup should fire + expect(deleteUploadFile).toHaveBeenCalledWith('old.png'); + expect(deleteAttachmentByFilename).toHaveBeenCalledWith('old.png'); + }); +}); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 8b89a2e6..8756e4a6 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -24,13 +24,21 @@ import { } from '@backspace/shared'; import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js'; import { sanitizeUser } from '../utils/sanitize.js'; -import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; +import { deleteAttachmentFiles, deleteUploadFile, deleteAttachmentByFilename } from '../utils/fileCleanup.js'; +import { isValidAssetUrl } from './users.js'; +import { + GROUP_DM_NAME_MIN_LENGTH, + GROUP_DM_NAME_MAX_LENGTH, + GROUP_DM_ICON_MAX_BYTES, + GROUP_DM_ICON_MIME_PREFIX, +} from '@backspace/shared/src/constants.js'; import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; import { appendMutationLog, queueOutboxEvent, queueDmRelay, queueDmCloseRelay, + queueGroupMetadataRelay, getDmParticipants, getGroupDmTargetOrigins, isFederationRelayEnabled, @@ -973,6 +981,258 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(201).send(result); }); + // PATCH /api/dm/:id — Update group DM metadata (name + icon). Owner-only. + // Body: { name?: string | null, icon?: string | null } + // - name: trimmed; empty → cleared (null); otherwise length must be in + // [GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH]. + // - icon: null/empty → cleared; bare filename → must be an attachment + // uploaded by the caller, image/* mimetype, ≤ GROUP_DM_ICON_MAX_BYTES; + // absolute http(s) URL → accepted as-is (federated rebroadcast path). + app.patch<{ Params: { id: string }; Body: { name?: string | null; icon?: string | null } }>('/api/dm/:id', async (request, reply) => { + const { id } = request.params; + const body = request.body ?? {}; + const db = getDb(); + + // Fetch channel + const dmChannel = db.select() + .from(schema.dmChannels) + .where(and(eq(schema.dmChannels.id, id), isNull(schema.dmChannels.deletedAt))) + .get(); + if (!dmChannel) { + return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 }); + } + + // 1-on-1 DMs (ownerId=NULL) cannot have metadata + if (!dmChannel.ownerId) { + return reply.code(400).send({ error: 'Cannot update metadata on a 1-on-1 DM', statusCode: 400 }); + } + + // Caller must be a member + if (!isDmMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + // Caller must be the owner + if (dmChannel.ownerId !== request.userId) { + return reply.code(403).send({ error: 'Only the group owner can update metadata', statusCode: 403 }); + } + + const nameProvided = Object.prototype.hasOwnProperty.call(body, 'name'); + const iconProvided = Object.prototype.hasOwnProperty.call(body, 'icon'); + + if (!nameProvided && !iconProvided) { + // No fields to update — return the channel unchanged. + return reply.code(200).send({ id: dmChannel.id, name: dmChannel.name, icon: dmChannel.icon, metadataUpdatedAt: dmChannel.metadataUpdatedAt }); + } + + const oldName = dmChannel.name ?? null; + const oldIcon = dmChannel.icon ?? null; + + // ---- Resolve next name (with no-op short-circuit) ---- + let nextName: string | null = oldName; + let nameChanged = false; + if (nameProvided) { + const raw = body.name; + let candidate: string | null; + if (raw === null || raw === undefined) { + candidate = null; + } else if (typeof raw !== 'string') { + return reply.code(400).send({ error: 'name must be a string or null', statusCode: 400 }); + } else { + const trimmed = raw.trim(); + candidate = trimmed.length === 0 ? null : trimmed; + } + // Validate length only if the value is actually changing — a no-op repeat + // of the stored value (even one that wouldn't pass current validation, + // e.g. legacy data) must not 400. + if (candidate !== oldName) { + if (candidate !== null) { + if (candidate.length < GROUP_DM_NAME_MIN_LENGTH || candidate.length > GROUP_DM_NAME_MAX_LENGTH) { + return reply.code(400).send({ + error: `Group DM name must be between ${GROUP_DM_NAME_MIN_LENGTH} and ${GROUP_DM_NAME_MAX_LENGTH} characters`, + statusCode: 400, + }); + } + } + nextName = candidate; + nameChanged = true; + } + } + + // ---- Resolve next icon (with no-op short-circuit) ---- + let nextIcon: string | null = oldIcon; + let iconChanged = false; + if (iconProvided) { + const raw = body.icon; + let candidate: string | null; + if (raw === null || raw === undefined) { + candidate = null; + } else if (typeof raw !== 'string') { + return reply.code(400).send({ error: 'icon must be a string or null', statusCode: 400 }); + } else { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + candidate = null; + } else if (!isValidAssetUrl(trimmed)) { + return reply.code(400).send({ error: 'Invalid icon URL', statusCode: 400 }); + } else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + candidate = trimmed; + } else { + // Bare filename or `/api/uploads/` — normalize to bare filename. + candidate = trimmed.startsWith('/api/uploads/') ? trimmed.slice('/api/uploads/'.length) : trimmed; + } + } + // Only validate attachment ownership/mimetype/size when the icon is + // actually changing AND the new value is a local filename (not an + // absolute URL or null clear). + if (candidate !== oldIcon) { + if (candidate !== null + && !candidate.startsWith('http://') + && !candidate.startsWith('https://')) { + const attachment = db.select() + .from(schema.attachments) + .where(eq(schema.attachments.filename, candidate)) + .get(); + + if (!attachment) { + return reply.code(400).send({ error: 'Icon attachment not found', statusCode: 400 }); + } + if (attachment.uploaderId !== request.userId) { + return reply.code(403).send({ error: 'You do not own this icon attachment', statusCode: 403 }); + } + if (!attachment.mimetype.startsWith(GROUP_DM_ICON_MIME_PREFIX)) { + return reply.code(400).send({ error: 'Icon must be an image', statusCode: 400 }); + } + if (attachment.size > GROUP_DM_ICON_MAX_BYTES) { + return reply.code(400).send({ + error: `Icon must be smaller than ${Math.floor(GROUP_DM_ICON_MAX_BYTES / (1024 * 1024))} MB`, + statusCode: 400, + }); + } + } + nextIcon = candidate; + iconChanged = true; + } + } + + // ---- No-op short-circuit ---- + if (!nameChanged && !iconChanged) { + return reply.code(200).send({ + id: dmChannel.id, + name: dmChannel.name, + icon: dmChannel.icon, + metadataUpdatedAt: dmChannel.metadataUpdatedAt, + }); + } + + // Caller user row — needed for federation actor identity + const callerUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get(); + + // Single eventMessageId so name/icon system messages share a federation correlation root. + const eventMessageId = generateSnowflake(); + const sysMessageRows: Array<{ + id: string; + sourceMessageId: string; + content: string; + }> = []; + + // ---- Transaction: persist channel update + system message rows ---- + let metadataUpdatedAt = 0; + db.transaction((tx) => { + metadataUpdatedAt = Date.now(); + tx.update(schema.dmChannels) + .set({ name: nextName, icon: nextIcon, metadataUpdatedAt }) + .where(eq(schema.dmChannels.id, id)) + .run(); + + if (nameChanged) { + const sysId = generateSnowflake(); + const content = JSON.stringify({ event: 'name_changed', oldName, newName: nextName }); + tx.insert(schema.dmMessages).values({ + id: sysId, + dmChannelId: id, + userId: request.userId, + content, + type: 'system', + sourceMessageId: `${eventMessageId}:name`, + createdAt: metadataUpdatedAt, + }).run(); + sysMessageRows.push({ id: sysId, sourceMessageId: `${eventMessageId}:name`, content }); + } + + if (iconChanged) { + const sysId = generateSnowflake(); + const content = JSON.stringify({ event: 'icon_changed' }); + tx.insert(schema.dmMessages).values({ + id: sysId, + dmChannelId: id, + userId: request.userId, + content, + type: 'system', + sourceMessageId: `${eventMessageId}:icon`, + createdAt: metadataUpdatedAt, + }).run(); + sysMessageRows.push({ id: sysId, sourceMessageId: `${eventMessageId}:icon`, content }); + } + }); + + // ---- Broadcast channel update to local members ---- + connectionManager.sendToDmMembers(id, { + type: 'dm_channel_updated', + dmChannelId: id, + name: nextName, + icon: nextIcon, + }); + + // ---- Broadcast each new system message ---- + const sanitizedActor = callerUser ? sanitizeUser(callerUser) : undefined; + for (const sys of sysMessageRows) { + connectionManager.sendToDmMembers(id, { + type: 'dm_message_created', + message: { + id: sys.id, + dmChannelId: id, + userId: request.userId, + content: sys.content, + type: 'system', + createdAt: metadataUpdatedAt, + user: sanitizedActor, + attachments: [], + embeds: [], + reactions: [], + } as DmMessageWithUser, + }); + } + + // ---- Federation relay ---- + if (isFederationRelayEnabled()) { + const domainOrigin = getOurOrigin(); + queueGroupMetadataRelay(id, { + name: nextName, + icon: nextIcon, + metadataUpdatedAt, + actor: { + userId: request.userId, + homeUserId: callerUser?.homeUserId ?? request.userId, + homeInstance: callerUser?.homeInstance ?? domainOrigin, + }, + }); + } + + // ---- Old icon cleanup (mirror users.ts:463-466 precedent) ---- + if (iconChanged && oldIcon && !oldIcon.startsWith('http')) { + deleteUploadFile(oldIcon); + deleteAttachmentByFilename(oldIcon); + } + + return reply.code(200).send({ + id, + name: nextName, + icon: nextIcon, + metadataUpdatedAt, + }); + }); + // DELETE /api/dm/:id - Close (hide) a DM channel for the requesting user app.delete<{ Params: { id: string } }>('/api/dm/:id', async (request, reply) => { const { id } = request.params; diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index b0b9238b..0bb09395 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -17,7 +17,7 @@ import { extractDomain } from './federation.js'; import path from 'path'; /** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */ -function isValidAssetUrl(url: string | null | undefined): boolean { +export function isValidAssetUrl(url: string | null | undefined): boolean { if (!url || url.trim().length === 0) return true; // empty/null = clearing const trimmed = url.trim(); if (trimmed.startsWith('/api/uploads/')) return true;