diff --git a/packages/server/src/routes/dm.spaceInvite.test.ts b/packages/server/src/routes/dm.spaceInvite.test.ts index 142e31de..d694fa96 100644 --- a/packages/server/src/routes/dm.spaceInvite.test.ts +++ b/packages/server/src/routes/dm.spaceInvite.test.ts @@ -59,11 +59,13 @@ vi.mock('../utils/federationAuth.js', async (importActual) => { return { ...actual, getOurOrigin: () => 'https://local.test' }; }); -// Mock the snapshot fetch so tests don't make real HTTP calls. +// Mock the snapshot helpers so tests don't make real HTTP calls and we can +// observe which lookup path the route takes (local DB vs. cross-instance HTTP). vi.mock('../utils/spaceInviteSnapshot.js', () => ({ fetchSpaceInviteSnapshot: vi.fn(), + getLocalInviteSnapshot: vi.fn(), })); -import { fetchSpaceInviteSnapshot } from '../utils/spaceInviteSnapshot.js'; +import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js'; function applyMigrations(db: Database.Database): void { const migrationsDir = path.resolve(__dirname, '../../drizzle'); @@ -130,6 +132,7 @@ describe('POST /api/dm/space-invite', () => { seedFriendship('alice', 'bob'); currentUserId = 'alice'; (fetchSpaceInviteSnapshot as unknown as ReturnType).mockReset(); + (getLocalInviteSnapshot as unknown as ReturnType).mockReset(); app = await buildApp(); }); @@ -147,12 +150,13 @@ describe('POST /api/dm/space-invite', () => { }); expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).error).toBe('not_a_friend'); - // Snapshot should not be fetched if friendship gate fails first. + // Snapshot should not be looked up if friendship gate fails first. expect(fetchSpaceInviteSnapshot).not.toHaveBeenCalled(); + expect(getLocalInviteSnapshot).not.toHaveBeenCalled(); }); - it('rejects 400 invite_invalid when upstream preview 404s (snapshot null)', async () => { - (fetchSpaceInviteSnapshot as unknown as ReturnType).mockResolvedValueOnce(null); + it('rejects 400 invite_invalid when local snapshot lookup returns null', async () => { + (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValueOnce(null); const res = await app.inject({ method: 'POST', url: '/api/dm/space-invite', @@ -171,7 +175,7 @@ describe('POST /api/dm/space-invite', () => { }); it('rejects 400 invite_invalid when snapshot.spaceId mismatches the requested spaceId', async () => { - (fetchSpaceInviteSnapshot as unknown as ReturnType).mockResolvedValueOnce({ + (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValueOnce({ spaceId: 'WRONG', spaceName: 'X', description: null, @@ -197,7 +201,7 @@ describe('POST /api/dm/space-invite', () => { }); it('inserts a type=system message with parseable space_invite content on success', async () => { - (fetchSpaceInviteSnapshot as unknown as ReturnType).mockResolvedValueOnce({ + (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValueOnce({ spaceId: 'S1', spaceName: 'Aether', description: 'desc', @@ -247,7 +251,7 @@ describe('POST /api/dm/space-invite', () => { }); it('reuses an existing 1-on-1 DM rather than creating a new one', async () => { - (fetchSpaceInviteSnapshot as unknown as ReturnType).mockResolvedValue({ + (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValue({ spaceId: 'S1', spaceName: 'Aether', description: null, @@ -297,4 +301,89 @@ describe('POST /api/dm/space-invite', () => { expect(messages.length).toBe(2); expect(messages.every(m => m.type === 'system')).toBe(true); }); + + it('uses local DB lookup (skips HTTP) when spaceInstanceOrigin is empty/local', async () => { + // Regression guard for the production hang: when the space is local, the + // route MUST NOT call fetchSpaceInviteSnapshot — that path tries to reach + // our own public domain over HTTPS, which fails inside Docker (NAT loopback). + (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValueOnce({ + spaceId: 'S1', + spaceName: 'Aether', + description: null, + icon: null, + avatarColor: null, + memberCount: 1, + instanceName: 'Backspace', + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/dm/space-invite', + payload: { + target: { userId: 'bob' }, + spaceId: 'S1', + spaceInstanceOrigin: '', + inviteCode: 'abc', + }, + }); + expect(res.statusCode).toBe(200); + expect(getLocalInviteSnapshot).toHaveBeenCalledWith('abc'); + // CRITICAL: the HTTP fetch path must NOT run for local invites. + expect(fetchSpaceInviteSnapshot).not.toHaveBeenCalled(); + }); + + it('uses local DB lookup when spaceInstanceOrigin equals our own origin', async () => { + // Same fast-path applies if the client sends our origin explicitly. + (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValueOnce({ + spaceId: 'S1', + spaceName: 'Aether', + description: null, + icon: null, + avatarColor: null, + memberCount: 1, + instanceName: 'Backspace', + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/dm/space-invite', + payload: { + target: { userId: 'bob' }, + spaceId: 'S1', + spaceInstanceOrigin: 'https://local.test', + inviteCode: 'abc', + }, + }); + expect(res.statusCode).toBe(200); + expect(getLocalInviteSnapshot).toHaveBeenCalledWith('abc'); + expect(fetchSpaceInviteSnapshot).not.toHaveBeenCalled(); + }); + + it('uses HTTP fetch path for cross-instance invites', async () => { + // Regression guard: when the space is on a different instance, we must + // hit the SSRF-validated HTTP path, not the local DB. + (fetchSpaceInviteSnapshot as unknown as ReturnType).mockResolvedValueOnce({ + spaceId: 'S1', + spaceName: 'Remote', + description: null, + icon: null, + avatarColor: null, + memberCount: 5, + instanceName: 'OtherHost', + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/dm/space-invite', + payload: { + target: { userId: 'bob' }, + spaceId: 'S1', + spaceInstanceOrigin: 'https://remote.example', + inviteCode: 'abc', + }, + }); + expect(res.statusCode).toBe(200); + expect(fetchSpaceInviteSnapshot).toHaveBeenCalledWith('https://remote.example', 'abc'); + expect(getLocalInviteSnapshot).not.toHaveBeenCalled(); + }); }); diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 3ce93397..6a8074aa 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -22,7 +22,7 @@ import { type SpaceInviteResponse, type SpaceInviteSystemPayload, } from '@backspace/shared'; -import { fetchSpaceInviteSnapshot } from '../utils/spaceInviteSnapshot.js'; +import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; @@ -1731,10 +1731,15 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'not_a_friend', statusCode: 400 }); } - // 3. Server-to-server snapshot fetch from the space's home instance. + // 3. Snapshot lookup. For local spaces, read the DB directly — fetching + // our own /preview endpoint over HTTPS fails inside Docker (NAT loopback). + // Cross-instance previews still go through the SSRF-validated HTTP path. const ourOrigin = getOurOrigin(); const spaceOrigin = body.spaceInstanceOrigin || ourOrigin; - const snapshot = await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode); + const isLocal = !body.spaceInstanceOrigin || body.spaceInstanceOrigin === ourOrigin; + const snapshot = isLocal + ? getLocalInviteSnapshot(body.inviteCode) + : await fetchSpaceInviteSnapshot(spaceOrigin, body.inviteCode); if (!snapshot) { return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 }); } diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 0e6c6c76..688f7bf0 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -26,6 +26,7 @@ import type { import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { checkVoicePermissions } from '../ws/events.js'; +import { getLocalInviteSnapshot } from '../utils/spaceInviteSnapshot.js'; function rowToSpace(row: typeof schema.spaces.$inferSelect): Space { return { @@ -1227,29 +1228,11 @@ export async function spaceRoutes(app: FastifyInstance): Promise { // GET /api/spaces/invite/:code/preview — Public invite preview (no auth) app.get<{ Params: { code: string } }>('/api/spaces/invite/:code/preview', async (request, reply) => { const { code } = request.params; - const db = getDb(); - - const space = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, code)).get(); - if (!space) { + const snapshot = getLocalInviteSnapshot(code); + if (!snapshot) { return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 }); } - - const memberCount = db.select().from(schema.spaceMembers) - .where(eq(schema.spaceMembers.spaceId, space.id)) - .all().length; - - const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); - const instanceName = settings?.instanceName ?? 'Backspace'; - - return reply.code(200).send({ - spaceId: space.id, - spaceName: space.name, - description: space.description ?? null, - icon: space.icon ?? null, - avatarColor: space.avatarColor ?? null, - memberCount, - instanceName, - }); + return reply.code(200).send(snapshot); }); // ─── Ban Management ─────────────────────────────────────────────────────── diff --git a/packages/server/src/utils/spaceInviteSnapshot.test.ts b/packages/server/src/utils/spaceInviteSnapshot.test.ts index 4c0d4098..799fa43f 100644 --- a/packages/server/src/utils/spaceInviteSnapshot.test.ts +++ b/packages/server/src/utils/spaceInviteSnapshot.test.ts @@ -1,5 +1,25 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { fetchSpaceInviteSnapshot } from './spaceInviteSnapshot'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { fetchSpaceInviteSnapshot, getLocalInviteSnapshot } from './spaceInviteSnapshot'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable DB state — the vi.mock factory below closes over these +// bindings via a getter, so reassignment in beforeEach is observed. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); // Mock the ssrf module so tests don't need real DNS resolution. // Default: validateExternalUrl resolves (allow). Individual tests override as needed. @@ -8,6 +28,19 @@ vi.mock('./ssrf.js', () => ({ isPrivateIp: vi.fn().mockReturnValue(false), })); +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); + } + } +} + describe('fetchSpaceInviteSnapshot', () => { const originalFetch = global.fetch; beforeEach(() => { global.fetch = vi.fn() as any; }); @@ -74,3 +107,86 @@ describe('fetchSpaceInviteSnapshot', () => { spy.mockRestore(); }); }); + +describe('getLocalInviteSnapshot', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + // Seed an owner user so the FK on spaces.ownerId is satisfied. + testDb.insert(schema.users).values({ + id: 'owner-1', + username: 'owner', + displayName: null, + passwordHash: 'x', + status: 'offline', + isAdmin: 0, + isDeleted: 0, + discoverable: 1, + homeInstance: null, + homeUserId: null, + createdAt: Date.now(), + }).run(); + }); + + it('returns snapshot for an existing local invite code', () => { + testDb.insert(schema.spaces).values({ + id: 'S1', + name: 'Aether', + icon: null, + banner: null, + avatarColor: 'mint', + ownerId: 'owner-1', + inviteCode: 'abc123', + visibility: 'private', + description: 'a calm space', + createdAt: Date.now(), + }).run(); + testDb.insert(schema.spaceMembers).values({ + spaceId: 'S1', + userId: 'owner-1', + nickname: null, + joinedAt: Date.now(), + }).run(); + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'TestHost', + updatedAt: Date.now(), + }).run(); + + const snap = getLocalInviteSnapshot('abc123'); + expect(snap).toEqual({ + spaceId: 'S1', + spaceName: 'Aether', + description: 'a calm space', + icon: null, + avatarColor: 'mint', + memberCount: 1, + instanceName: 'TestHost', + }); + }); + + it('returns null for unknown code', () => { + expect(getLocalInviteSnapshot('does-not-exist')).toBeNull(); + }); + + it('falls back to "Backspace" when no instance settings row exists', () => { + testDb.insert(schema.spaces).values({ + id: 'S2', + name: 'NoSettings', + icon: null, + banner: null, + avatarColor: null, + ownerId: 'owner-1', + inviteCode: 'nosettings', + visibility: 'private', + description: null, + createdAt: Date.now(), + }).run(); + + const snap = getLocalInviteSnapshot('nosettings'); + expect(snap).not.toBeNull(); + expect(snap?.instanceName).toBe('Backspace'); + expect(snap?.memberCount).toBe(0); + }); +}); diff --git a/packages/server/src/utils/spaceInviteSnapshot.ts b/packages/server/src/utils/spaceInviteSnapshot.ts index 046fdb75..1d46432e 100644 --- a/packages/server/src/utils/spaceInviteSnapshot.ts +++ b/packages/server/src/utils/spaceInviteSnapshot.ts @@ -1,4 +1,6 @@ import type { AvatarColor } from '@backspace/shared'; +import { eq } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; import { validateExternalUrl } from './ssrf.js'; export interface SpaceInviteSnapshot { @@ -11,6 +13,38 @@ export interface SpaceInviteSnapshot { instanceName: string; } +/** + * Build a local invite snapshot directly from the DB. Used when the space + * lives on this instance — avoids an HTTP roundtrip through our own domain + * (which fails inside Docker due to NAT loopback) and is faster anyway. + * + * Returns the same shape as `fetchSpaceInviteSnapshot` so callers don't + * branch downstream of the snapshot lookup. + */ +export function getLocalInviteSnapshot(inviteCode: string): SpaceInviteSnapshot | null { + const db = getDb(); + const space = db.select().from(schema.spaces) + .where(eq(schema.spaces.inviteCode, inviteCode)).get(); + if (!space) return null; + + const memberCount = db.select().from(schema.spaceMembers) + .where(eq(schema.spaceMembers.spaceId, space.id)).all().length; + + const settings = db.select().from(schema.instanceSettings) + .where(eq(schema.instanceSettings.id, 1)).get(); + const instanceName = settings?.instanceName ?? 'Backspace'; + + return { + spaceId: space.id, + spaceName: space.name, + description: space.description ?? null, + icon: space.icon ?? null, + avatarColor: (space.avatarColor as AvatarColor | null) ?? null, + memberCount, + instanceName, + }; +} + /** * Fetch a space invite preview from a (possibly remote) instance. * Returns null on 4xx, network error, or timeout — caller should treat as