fix(server): local-fast-path for invite snapshot — skip HTTP self-reach
POST /api/dm/space-invite was hanging 5s and returning invite_invalid for any local-space invite. fetchSpaceInviteSnapshot was being called against our own public domain from inside the backspace container, which fails (Docker NAT loopback) and aborts on timeout. Add getLocalInviteSnapshot — reads the snapshot directly from the DB — and branch in dm.ts so local invites bypass the HTTP roundtrip entirely. Cross-instance invites still go through fetchSpaceInviteSnapshot with its existing SSRF guard. Also refactor the GET /api/spaces/invite/:code/preview handler to use the same helper, keeping the snapshot shape in one place. Tests assert fetchSpaceInviteSnapshot is NOT called for the local case (critical regression guard) and that the cross-instance path still hits the HTTP fetch.
This commit is contained in:
@@ -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<typeof drizzle<typeof schema>>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user