Public-release prep: ELv2 license, README/CLA/NOTICE, SSRF safeFetch, identifier genericization, export tooling
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
"name": "@backspace/desktop",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"description": "Backspace",
|
||||
"author": {
|
||||
"name": "youruser",
|
||||
"name": "Jannis Braun",
|
||||
"email": "backspace@backspace.chat"
|
||||
},
|
||||
"homepage": "https://github.com/TheZwiss/backspace",
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
"name": "@backspace/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"author": "Jannis Braun",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "PORT=3005 tsx watch src/index.ts",
|
||||
"start": "node --import tsx/esm src/index.ts",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -52,11 +52,11 @@ beforeEach(() => {
|
||||
testDb = drizzle(sqlite, { schema });
|
||||
applyMigrations(sqlite);
|
||||
sentToUserCalls.length = 0;
|
||||
// Local user (youruser) and replicated stub (pbtest3) — they're friends.
|
||||
// Local user (erin) and replicated stub (pbtest3) — they're friends.
|
||||
testDb.insert(schema.users).values([
|
||||
{
|
||||
id: 'local-youruser', username: 'youruser', passwordHash: 'x', status: 'online', isAdmin: 0,
|
||||
homeUserId: 'local-youruser', createdAt: Date.now(),
|
||||
id: 'local-erin', username: 'erin', passwordHash: 'x', status: 'online', isAdmin: 0,
|
||||
homeUserId: 'local-erin', createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'stub-pbtest3', username: 'pbtest3@orbit.ddns.net', displayName: 'pbtest3',
|
||||
@@ -65,7 +65,7 @@ beforeEach(() => {
|
||||
},
|
||||
]).run();
|
||||
testDb.insert(schema.friends).values({
|
||||
userId: 'local-youruser', friendId: 'stub-pbtest3', createdAt: Date.now(),
|
||||
userId: 'local-erin', friendId: 'stub-pbtest3', createdAt: Date.now(),
|
||||
}).run();
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('processPresenceUpdateEvent', () => {
|
||||
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-pbtest3')).get();
|
||||
expect(row!.status).toBe('online');
|
||||
|
||||
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
|
||||
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-erin');
|
||||
expect(broadcast).toBeDefined();
|
||||
expect(broadcast!.payload.type).toBe('presence_update');
|
||||
expect(broadcast!.payload.userId).toBe('stub-pbtest3');
|
||||
@@ -160,7 +160,7 @@ describe('processPresenceUpdateEvent', () => {
|
||||
},
|
||||
};
|
||||
fed.processPresenceUpdateEvent(event, 'orbit.ddns.net', testDb, [], []);
|
||||
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
|
||||
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-erin');
|
||||
expect(broadcast!.payload.activities).toEqual([{ type: 'playing', name: 'Test' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2566,7 +2566,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// 4. Query mutation log for the relevant channels
|
||||
// Return mutations for ALL locally-created messages (source_instance IS NULL).
|
||||
// This includes messages by replicated users (e.g., Jannis browsing orbit)
|
||||
// This includes messages by replicated users (e.g., Heidi browsing orbit)
|
||||
// because they were created on THIS instance and need to be synced to the peer.
|
||||
if (effectiveChannelFilter) {
|
||||
// Validate that the requested channel is actually shared with this peer
|
||||
@@ -3111,7 +3111,7 @@ export function extractDomain(homeInstance: string): string {
|
||||
* Two valid cases:
|
||||
* 1. **Direct**: author is from the source instance (standard S2S — peer sends events for its own users).
|
||||
* 2. **Homeward relay**: author is from the *receiving* instance. This happens when a client-federation
|
||||
* user (e.g., youruser@nova logged into orbit) sends a message on a remote server, and the
|
||||
* user (e.g., erin@nova logged into orbit) sends a message on a remote server, and the
|
||||
* S2S relay forwards it back to the author's home instance. The trusted peer is just the messenger.
|
||||
*
|
||||
* Both sides are normalized to bare domain before comparison.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* One-time backfill: populate width/height for image embeds that lack them,
|
||||
* and create image embeds for old bare-image-URL messages that never got one
|
||||
* (these predate the `resolveEmbeds` call site in routes/messages.ts).
|
||||
*
|
||||
* Idempotent — re-running skips already-dim'd rows and already-embedded
|
||||
* messages via the WHERE clauses.
|
||||
*
|
||||
* Run from the running container:
|
||||
* docker exec backspace node dist/scripts/backfill-image-embed-dims.js
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { initDatabase, getRawDb, schema } from '../db/index.js';
|
||||
import { probeRemoteImageDimensions } from '../utils/embedResolver.js';
|
||||
import { extractUrls } from '../utils/embedResolver.js';
|
||||
import { classifyUrl } from '../utils/embedClassifier.js';
|
||||
import { probeImageDimensions } from '../utils/thumbnail.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
interface PendingProbe {
|
||||
kind: 'update-embed' | 'create-embed-for-message' | 'create-embed-for-dm-message';
|
||||
url: string;
|
||||
embedId?: string;
|
||||
messageId?: string;
|
||||
dmMessageId?: string;
|
||||
}
|
||||
|
||||
async function withConcurrency<T>(items: T[], n: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||
let i = 0;
|
||||
const runners = Array.from({ length: Math.min(n, items.length) }, async () => {
|
||||
while (true) {
|
||||
const idx = i++;
|
||||
if (idx >= items.length) return;
|
||||
const item = items[idx];
|
||||
if (item === undefined) return;
|
||||
await worker(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
}
|
||||
|
||||
function isLikelyImageUrl(url: string): boolean {
|
||||
return classifyUrl(url).embedType === 'image';
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// initDatabase() runs migrations + ensureDefaults + sets the snowflake
|
||||
// worker ID from instance_settings.worker_id. We rely on that for
|
||||
// generateSnowflake() below.
|
||||
initDatabase();
|
||||
const db = getRawDb();
|
||||
|
||||
// ── Pass 1: image embeds missing dimensions ──────────────────────────────
|
||||
const nullDimEmbeds = db.prepare(
|
||||
`SELECT id, url FROM embeds WHERE embed_type = 'image' AND (width IS NULL OR height IS NULL)`,
|
||||
).all() as Array<{ id: string; url: string }>;
|
||||
console.log(`[backfill] Found ${nullDimEmbeds.length} image embeds missing width/height`);
|
||||
|
||||
// ── Pass 2: messages with bare image URLs but no embed row ───────────────
|
||||
// SQLite has no REGEXP by default; use LIKE with the two known providers
|
||||
// plus a generic suffix match. We re-classify each candidate URL with
|
||||
// classifyUrl() before probing so non-image extensions are filtered out.
|
||||
const candidateMessages = db.prepare(
|
||||
`SELECT m.id, m.content
|
||||
FROM messages m
|
||||
LEFT JOIN embeds e ON e.message_id = m.id
|
||||
WHERE m.content IS NOT NULL
|
||||
AND (
|
||||
m.content LIKE 'https://media.tenor.com/%'
|
||||
OR m.content LIKE 'https://static.klipy.com/%'
|
||||
OR m.content LIKE '%.gif'
|
||||
OR m.content LIKE '%.webp'
|
||||
)
|
||||
AND e.id IS NULL`,
|
||||
).all() as Array<{ id: string; content: string }>;
|
||||
console.log(`[backfill] Found ${candidateMessages.length} channel messages with image URLs and no embed`);
|
||||
|
||||
const candidateDmMessages = db.prepare(
|
||||
`SELECT m.id, m.content
|
||||
FROM dm_messages m
|
||||
LEFT JOIN embeds e ON e.dm_message_id = m.id
|
||||
WHERE m.content IS NOT NULL
|
||||
AND (
|
||||
m.content LIKE 'https://media.tenor.com/%'
|
||||
OR m.content LIKE 'https://static.klipy.com/%'
|
||||
OR m.content LIKE '%.gif'
|
||||
OR m.content LIKE '%.webp'
|
||||
)
|
||||
AND e.id IS NULL`,
|
||||
).all() as Array<{ id: string; content: string }>;
|
||||
console.log(`[backfill] Found ${candidateDmMessages.length} DM messages with image URLs and no embed`);
|
||||
|
||||
// ── Build work queue ─────────────────────────────────────────────────────
|
||||
const work: PendingProbe[] = [];
|
||||
|
||||
for (const row of nullDimEmbeds) {
|
||||
work.push({ kind: 'update-embed', url: row.url, embedId: row.id });
|
||||
}
|
||||
|
||||
for (const m of candidateMessages) {
|
||||
const urls = extractUrls(m.content);
|
||||
for (const url of urls) {
|
||||
if (isLikelyImageUrl(url)) {
|
||||
work.push({ kind: 'create-embed-for-message', url, messageId: m.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of candidateDmMessages) {
|
||||
const urls = extractUrls(m.content);
|
||||
for (const url of urls) {
|
||||
if (isLikelyImageUrl(url)) {
|
||||
work.push({ kind: 'create-embed-for-dm-message', url, dmMessageId: m.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 3: image attachments missing dimensions ─────────────────────────
|
||||
// Probe local files directly (the upload sits on the same filesystem). The
|
||||
// `failOn: 'none'` flag in `probeImageDimensions` handles animated GIFs
|
||||
// whose default sharp probe was failing on frame-data validation.
|
||||
const nullDimAttachments = db.prepare(
|
||||
`SELECT id, filename FROM attachments WHERE mimetype LIKE 'image/%' AND (width IS NULL OR height IS NULL)`,
|
||||
).all() as Array<{ id: string; filename: string }>;
|
||||
console.log(`[backfill] Found ${nullDimAttachments.length} image attachments missing width/height`);
|
||||
|
||||
const updateAttachment = db.prepare(`UPDATE attachments SET width = ?, height = ? WHERE id = ?`);
|
||||
let attachmentSuccesses = 0;
|
||||
let attachmentSkipped = 0;
|
||||
for (const a of nullDimAttachments) {
|
||||
const filepath = path.join(config.uploadDir, a.filename);
|
||||
if (!fs.existsSync(filepath)) {
|
||||
// Federated remote attachments live on the source instance, not here.
|
||||
// Their dims need to be backfilled from the source side, or via the
|
||||
// federation file-replication path. Skip silently.
|
||||
attachmentSkipped++;
|
||||
continue;
|
||||
}
|
||||
const dims = await probeImageDimensions(filepath);
|
||||
if (dims) {
|
||||
updateAttachment.run(dims.width, dims.height, a.id);
|
||||
attachmentSuccesses++;
|
||||
}
|
||||
}
|
||||
console.log(`[backfill] attachments updated=${attachmentSuccesses}, skipped (file not local)=${attachmentSkipped}`);
|
||||
|
||||
// ── Pass 4: image embeds whose URL points at our local uploads ───────────
|
||||
// SSRF correctly blocks the remote probe from hitting our own host. For
|
||||
// these specific embeds we have the file locally — extract the upload
|
||||
// filename from the URL path (`/api/uploads/<filename>`) and probe it
|
||||
// directly, sidestepping HTTP entirely. Federated `/api/uploads/...` URLs
|
||||
// on a different host won't have a local file and are skipped.
|
||||
const localUploadEmbeds = db.prepare(
|
||||
`SELECT id, url FROM embeds WHERE embed_type = 'image' AND (width IS NULL OR height IS NULL) AND url LIKE '%/api/uploads/%'`,
|
||||
).all() as Array<{ id: string; url: string }>;
|
||||
console.log(`[backfill] Found ${localUploadEmbeds.length} image embeds pointing at /api/uploads`);
|
||||
|
||||
const updateEmbedDims = db.prepare(`UPDATE embeds SET width = ?, height = ? WHERE id = ?`);
|
||||
let localEmbedSuccesses = 0;
|
||||
let localEmbedSkipped = 0;
|
||||
for (const e of localUploadEmbeds) {
|
||||
const match = e.url.match(/\/api\/uploads\/([^/?#]+)/);
|
||||
if (!match || !match[1]) { localEmbedSkipped++; continue; }
|
||||
const filepath = path.join(config.uploadDir, match[1]);
|
||||
if (!fs.existsSync(filepath)) { localEmbedSkipped++; continue; }
|
||||
const dims = await probeImageDimensions(filepath);
|
||||
if (dims) {
|
||||
updateEmbedDims.run(dims.width, dims.height, e.id);
|
||||
localEmbedSuccesses++;
|
||||
}
|
||||
}
|
||||
console.log(`[backfill] local-upload embeds updated=${localEmbedSuccesses}, skipped (no local file)=${localEmbedSkipped}`);
|
||||
|
||||
console.log(`[backfill] ${work.length} remote probes to run (concurrency=${CONCURRENCY})`);
|
||||
|
||||
// ── Execute ──────────────────────────────────────────────────────────────
|
||||
const updateEmbed = db.prepare(
|
||||
`UPDATE embeds SET width = ?, height = ? WHERE id = ?`,
|
||||
);
|
||||
const insertEmbed = db.prepare(
|
||||
`INSERT INTO embeds (id, message_id, dm_message_id, url, embed_type, provider, title, description, image, embed_url, width, height, color, created_at)
|
||||
VALUES (?, ?, ?, ?, 'image', NULL, NULL, NULL, ?, NULL, ?, ?, NULL, ?)`,
|
||||
);
|
||||
|
||||
let successes = 0;
|
||||
let probeFailures = 0;
|
||||
let progress = 0;
|
||||
|
||||
await withConcurrency(work, CONCURRENCY, async (item) => {
|
||||
const dims = await probeRemoteImageDimensions(item.url);
|
||||
progress++;
|
||||
if (progress % 25 === 0) {
|
||||
console.log(`[backfill] progress ${progress}/${work.length} (ok=${successes}, fail=${probeFailures})`);
|
||||
}
|
||||
if (!dims) {
|
||||
probeFailures++;
|
||||
return;
|
||||
}
|
||||
if (item.kind === 'update-embed' && item.embedId) {
|
||||
updateEmbed.run(dims.width, dims.height, item.embedId);
|
||||
successes++;
|
||||
} else if (item.kind === 'create-embed-for-message' && item.messageId) {
|
||||
insertEmbed.run(
|
||||
generateSnowflake(),
|
||||
item.messageId,
|
||||
null,
|
||||
item.url,
|
||||
item.url,
|
||||
dims.width,
|
||||
dims.height,
|
||||
Date.now(),
|
||||
);
|
||||
successes++;
|
||||
} else if (item.kind === 'create-embed-for-dm-message' && item.dmMessageId) {
|
||||
insertEmbed.run(
|
||||
generateSnowflake(),
|
||||
null,
|
||||
item.dmMessageId,
|
||||
item.url,
|
||||
item.url,
|
||||
dims.width,
|
||||
dims.height,
|
||||
Date.now(),
|
||||
);
|
||||
successes++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[backfill] done. successes=${successes}, probe-failures=${probeFailures}, total=${work.length}`);
|
||||
// Silence the schema import linter — it's intentionally available for
|
||||
// future per-table tweaks without re-importing.
|
||||
void schema;
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[backfill] fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { getDb, schema } from '../db/index.js';
|
||||
import { generateSnowflake } from './snowflake.js';
|
||||
import { classifyUrl } from './embedClassifier.js';
|
||||
import { fetchUrlMetadata } from './metadataFetcher.js';
|
||||
import { validateExternalUrl } from './ssrf.js';
|
||||
import { safeFetch } from './ssrf.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
|
||||
const MAX_EMBEDS_PER_MESSAGE = 5;
|
||||
@@ -18,27 +18,20 @@ const PROBE_TIMEOUT_MS = 3_000;
|
||||
* Uses Range request to avoid downloading the entire file.
|
||||
* Returns null on any failure (timeout, network, unrecognized format, SSRF block).
|
||||
*/
|
||||
async function probeRemoteImageDimensions(
|
||||
export async function probeRemoteImageDimensions(
|
||||
url: string,
|
||||
): Promise<{ width: number; height: number } | null> {
|
||||
try {
|
||||
await validateExternalUrl(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await safeFetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'BackspaceBot/1.0',
|
||||
Accept: 'image/*',
|
||||
Range: `bytes=0-${PROBE_BYTES - 1}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
// NOTE: Do NOT clearTimeout here — keep the abort active during body read.
|
||||
// The finally block handles cleanup after all reads complete.
|
||||
|
||||
@@ -73,7 +73,7 @@ beforeEach(() => {
|
||||
}).run();
|
||||
// Online native FRIENDED with the stub — should be snapshotted
|
||||
testDb.insert(schema.users).values({
|
||||
id: 'native-friend', username: 'youruser', passwordHash: 'x',
|
||||
id: 'native-friend', username: 'erin', passwordHash: 'x',
|
||||
status: 'online', isAdmin: 0, homeUserId: 'native-friend', createdAt: Date.now(),
|
||||
}).run();
|
||||
testDb.insert(schema.friends).values({
|
||||
|
||||
@@ -62,7 +62,7 @@ beforeEach(() => {
|
||||
// Native local user
|
||||
testDb.insert(schema.users).values({
|
||||
id: 'native-1',
|
||||
username: 'youruser',
|
||||
username: 'erin',
|
||||
passwordHash: 'x',
|
||||
status: 'online',
|
||||
isAdmin: 0,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import { validateExternalUrl } from './ssrf.js';
|
||||
import { safeFetch } from './ssrf.js';
|
||||
|
||||
export interface UrlMetadata {
|
||||
title: string | null;
|
||||
@@ -16,21 +16,14 @@ export interface UrlMetadata {
|
||||
}
|
||||
|
||||
export async function fetchUrlMetadata(url: string): Promise<UrlMetadata | null> {
|
||||
try {
|
||||
await validateExternalUrl(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await safeFetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'BackspaceBot/1.0',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
|
||||
@@ -22,11 +22,18 @@ vi.mock('../db/index.js', () => ({
|
||||
}));
|
||||
|
||||
// Mock the ssrf module so tests don't need real DNS resolution.
|
||||
// Default: validateExternalUrl resolves (allow). Individual tests override as needed.
|
||||
vi.mock('./ssrf.js', () => ({
|
||||
validateExternalUrl: vi.fn().mockResolvedValue(undefined),
|
||||
isPrivateIp: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
// safeFetch mirrors the real contract: validate the URL first, then fetch — so a
|
||||
// rejecting validator must prevent the fetch. Default: validateExternalUrl
|
||||
// resolves (allow). Individual tests override via mockRejectedValueOnce.
|
||||
vi.mock('./ssrf.js', () => {
|
||||
const validateExternalUrl = vi.fn().mockResolvedValue(undefined);
|
||||
const isPrivateIp = vi.fn().mockReturnValue(false);
|
||||
const safeFetch = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
await validateExternalUrl(url);
|
||||
return (global.fetch as unknown as typeof fetch)(url, init);
|
||||
});
|
||||
return { validateExternalUrl, isPrivateIp, safeFetch };
|
||||
});
|
||||
|
||||
function applyMigrations(db: Database.Database): void {
|
||||
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||
@@ -95,16 +102,14 @@ describe('fetchSpaceInviteSnapshot', () => {
|
||||
});
|
||||
|
||||
it('returns null when SSRF validator rejects the origin', async () => {
|
||||
// Override the module-level mock to reject for this test only.
|
||||
// Reject validation for this one call; safeFetch must bail before fetching.
|
||||
const ssrf = await import('./ssrf.js');
|
||||
const spy = vi.spyOn(ssrf, 'validateExternalUrl').mockRejectedValueOnce(new Error('blocked'));
|
||||
(ssrf.validateExternalUrl as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('blocked'));
|
||||
const fetchSpy = global.fetch as any;
|
||||
|
||||
const snap = await fetchSpaceInviteSnapshot('http://127.0.0.1:9200', 'abc');
|
||||
expect(snap).toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled(); // CRITICAL — the fetch must NOT happen
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AvatarColor } from '@backspace/shared';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { validateExternalUrl } from './ssrf.js';
|
||||
import { safeFetch } from './ssrf.js';
|
||||
|
||||
export interface SpaceInviteSnapshot {
|
||||
spaceId: string;
|
||||
@@ -59,15 +59,10 @@ export async function fetchSpaceInviteSnapshot(
|
||||
timeoutMs = 5000,
|
||||
): Promise<SpaceInviteSnapshot | null> {
|
||||
const url = `${spaceInstanceOrigin}/api/spaces/invite/${encodeURIComponent(inviteCode)}/preview`;
|
||||
try {
|
||||
await validateExternalUrl(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
const res = await safeFetch(url, { signal: controller.signal });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as Partial<SpaceInviteSnapshot>;
|
||||
if (typeof data?.spaceId !== 'string' || typeof data?.spaceName !== 'string') return null;
|
||||
|
||||
@@ -43,3 +43,38 @@ export async function validateExternalUrl(url: string): Promise<void> {
|
||||
throw new Error('Private IP not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch. Validates the target URL and re-validates the destination of
|
||||
* every redirect hop before following it, so a hostile server cannot 30x-redirect
|
||||
* an outbound request to an internal address (loopback, link-local, RFC1918).
|
||||
*
|
||||
* Use this instead of bare `fetch()` for any request to a user- or peer-supplied
|
||||
* URL. Redirects are followed manually (Node/undici exposes the 3xx + Location
|
||||
* with `redirect: 'manual'`), capped at MAX_REDIRECTS.
|
||||
*
|
||||
* Residual: validateExternalUrl resolves DNS, then fetch resolves again — a
|
||||
* narrow DNS-rebinding TOCTOU window remains. Pinning the resolved IP at connect
|
||||
* time would close it but requires a custom dispatcher; the redirect re-check
|
||||
* here closes the practical, attacker-controlled bypass.
|
||||
*/
|
||||
export async function safeFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
let currentUrl = url;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
await validateExternalUrl(currentUrl);
|
||||
const response = await fetch(currentUrl, { ...init, redirect: 'manual' });
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) return response; // 3xx without a target — hand back as-is
|
||||
// Resolve relative redirects against the current URL, then loop to re-validate.
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
throw new Error('Too many redirects');
|
||||
}
|
||||
|
||||
@@ -17,9 +17,32 @@ import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
dotenvConfig({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
const LIVEKIT_URL = process.env.LIVEKIT_URL || 'wss://nova.ddns.net/livekit';
|
||||
const API_KEY = process.env.LIVEKIT_API_KEY || 'REDACTED_LIVEKIT_KEY';
|
||||
const API_SECRET = process.env.LIVEKIT_API_SECRET || 'REDACTED_LIVEKIT_SECRET';
|
||||
// All values come from the environment (loaded from .env above). Never hardcode
|
||||
// credentials, hostnames, or LAN addresses here — this script ships in the repo.
|
||||
const env = {
|
||||
url: process.env.LIVEKIT_URL,
|
||||
apiKey: process.env.LIVEKIT_API_KEY,
|
||||
apiSecret: process.env.LIVEKIT_API_SECRET,
|
||||
};
|
||||
|
||||
const missing = (['url', 'apiKey', 'apiSecret'] as const)
|
||||
.filter((k) => !env[k])
|
||||
.map((k) => ({ url: 'LIVEKIT_URL', apiKey: 'LIVEKIT_API_KEY', apiSecret: 'LIVEKIT_API_SECRET' }[k]));
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('LIVEKIT VERIFICATION FAILED');
|
||||
console.error(`Missing required environment variable(s): ${missing.join(', ')}`);
|
||||
console.error('Set them in packages/server/.env (or the process environment) and re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Narrowed to string: guaranteed present past the guard above.
|
||||
const LIVEKIT_URL = env.url as string;
|
||||
const API_KEY = env.apiKey as string;
|
||||
const API_SECRET = env.apiSecret as string;
|
||||
// Optional: a LAN-local LiveKit URL to try first (e.g. http://10.0.0.5:7880),
|
||||
// useful when the public domain does not hairpin on the local network.
|
||||
const LIVEKIT_LAN_URL = process.env.LIVEKIT_LAN_URL;
|
||||
|
||||
async function verify() {
|
||||
console.log('=== LiveKit Verification ===');
|
||||
@@ -66,32 +89,35 @@ async function verify() {
|
||||
|
||||
// Step 3: Connect to LiveKit server via RoomServiceClient
|
||||
console.log('[3/3] Connecting to LiveKit server...');
|
||||
// The LiveKit server runs on the Pi at 192.168.1.10:7880 (host network mode).
|
||||
// The DDNS domain (nova.ddns.net) routes externally but may not loop back on LAN.
|
||||
// Try the LAN address first, then fall back to the configured URL.
|
||||
const lanUrl = 'http://192.168.1.10:7880';
|
||||
// Convert the configured ws(s):// URL to its http(s):// form for the REST client.
|
||||
const wanUrl = LIVEKIT_URL.replace('wss://', 'https://').replace('ws://', 'http://');
|
||||
// Optionally try a LAN-local address first (set LIVEKIT_LAN_URL) — useful when
|
||||
// the public domain does not hairpin back to the host on the local network.
|
||||
const candidates = [LIVEKIT_LAN_URL, wanUrl].filter((u): u is string => Boolean(u));
|
||||
|
||||
let roomService: RoomServiceClient;
|
||||
let usedUrl: string;
|
||||
try {
|
||||
roomService = new RoomServiceClient(lanUrl, API_KEY, API_SECRET);
|
||||
const rooms = await roomService.listRooms();
|
||||
usedUrl = lanUrl;
|
||||
console.log(` Server responded via LAN (${lanUrl}). Active rooms: ${rooms.length}`);
|
||||
for (const room of rooms) {
|
||||
console.log(` - ${room.name} (${room.numParticipants} participants)`);
|
||||
}
|
||||
} catch {
|
||||
console.log(` LAN address unreachable, trying WAN (${wanUrl})...`);
|
||||
roomService = new RoomServiceClient(wanUrl, API_KEY, API_SECRET);
|
||||
const rooms = await roomService.listRooms();
|
||||
usedUrl = wanUrl;
|
||||
console.log(` Server responded via WAN (${wanUrl}). Active rooms: ${rooms.length}`);
|
||||
for (const room of rooms) {
|
||||
console.log(` - ${room.name} (${room.numParticipants} participants)`);
|
||||
let usedUrl = '';
|
||||
let lastError: unknown;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const roomService = new RoomServiceClient(candidate, API_KEY, API_SECRET);
|
||||
const rooms = await roomService.listRooms();
|
||||
usedUrl = candidate;
|
||||
console.log(` Server responded via ${candidate}. Active rooms: ${rooms.length}`);
|
||||
for (const room of rooms) {
|
||||
console.log(` - ${room.name} (${room.numParticipants} participants)`);
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.log(` ${candidate} unreachable${candidates.length > 1 ? ', trying next…' : ''}`);
|
||||
}
|
||||
}
|
||||
if (!usedUrl) {
|
||||
throw new Error(
|
||||
`Could not reach the LiveKit server at any of: ${candidates.join(', ')}`,
|
||||
{ cause: lastError },
|
||||
);
|
||||
}
|
||||
console.log(` ✓ LiveKit server is reachable at ${usedUrl} and credentials are valid`);
|
||||
console.log('');
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"name": "@backspace/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"author": "Jannis Braun",
|
||||
"type": "module",
|
||||
"main": "./src/types.ts",
|
||||
"types": "./src/types.ts",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"name": "@backspace/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "Elastic-2.0",
|
||||
"author": "Jannis Braun",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -18,8 +18,8 @@ vi.mock('../../api/client', () => ({
|
||||
|
||||
const actor: User = {
|
||||
id: 'U1',
|
||||
username: 'jannis',
|
||||
displayName: 'Jannis',
|
||||
username: 'heidi',
|
||||
displayName: 'Heidi',
|
||||
avatar: null,
|
||||
banner: null,
|
||||
accentColor: null,
|
||||
@@ -66,18 +66,18 @@ function renderSM(message: MessageWithUser, dmArg: Pick<DmChannel, 'members'> |
|
||||
}
|
||||
|
||||
describe('SystemMessage — name_changed', () => {
|
||||
it('newName="Cool Group" with resolvable actor → "✎ Jannis renamed the group to \\"Cool Group\\""', () => {
|
||||
it('newName="Cool Group" with resolvable actor → "✎ Heidi renamed the group to \\"Cool Group\\""', () => {
|
||||
const msg = buildMessage({ event: 'name_changed', oldName: null, newName: 'Cool Group' });
|
||||
renderSM(msg, dm);
|
||||
expect(screen.getByText('✎')).toBeDefined();
|
||||
expect(screen.getByText(/Jannis renamed the group to "Cool Group"/)).toBeDefined();
|
||||
expect(screen.getByText(/Heidi renamed the group to "Cool Group"/)).toBeDefined();
|
||||
});
|
||||
|
||||
it('newName=null (cleared) with resolvable actor → "✎ Jannis cleared the group name"', () => {
|
||||
it('newName=null (cleared) with resolvable actor → "✎ Heidi cleared the group name"', () => {
|
||||
const msg = buildMessage({ event: 'name_changed', oldName: 'Old', newName: null });
|
||||
renderSM(msg, dm);
|
||||
expect(screen.getByText('✎')).toBeDefined();
|
||||
expect(screen.getByText(/Jannis cleared the group name/)).toBeDefined();
|
||||
expect(screen.getByText(/Heidi cleared the group name/)).toBeDefined();
|
||||
});
|
||||
|
||||
it('unresolvable actor (member missing from roster) → "✎ Unknown renamed …"', () => {
|
||||
@@ -88,11 +88,11 @@ describe('SystemMessage — name_changed', () => {
|
||||
});
|
||||
|
||||
describe('SystemMessage — icon_changed', () => {
|
||||
it('resolvable actor → "🖼 Jannis updated the group icon"', () => {
|
||||
it('resolvable actor → "🖼 Heidi updated the group icon"', () => {
|
||||
const msg = buildMessage({ event: 'icon_changed' });
|
||||
renderSM(msg, dm);
|
||||
// The 🖼 character is U+1F5BC (FRAME WITH PICTURE), not 🖼️ (with VS-16).
|
||||
expect(screen.getByText('\u{1F5BC}')).toBeDefined();
|
||||
expect(screen.getByText(/Jannis updated the group icon/)).toBeDefined();
|
||||
expect(screen.getByText(/Heidi updated the group icon/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,11 +33,11 @@ vi.mock('../audio/AudioManager', () => ({
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-1',
|
||||
username: 'youruser',
|
||||
username: 'erin',
|
||||
homeInstance: null,
|
||||
homeUserId: 'user-1',
|
||||
replicatedInstances: [
|
||||
{ origin: 'https://orbit.example', username: 'youruser@nova.example' },
|
||||
{ origin: 'https://orbit.example', username: 'erin@nova.example' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('instanceStore registry sync gating', () => {
|
||||
registry: [{
|
||||
origin: 'https://orbit.example',
|
||||
label: 'Orbit',
|
||||
username: 'youruser@nova.example',
|
||||
username: 'erin@nova.example',
|
||||
remoteUserId: 'remote-1',
|
||||
status: 'auth_expired',
|
||||
addedAt: 1,
|
||||
|
||||
@@ -242,7 +242,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
||||
}
|
||||
|
||||
// Step 2: Compute the user's true home identity
|
||||
// If we're a federated user (e.g. youruser@nova browsing orbit),
|
||||
// If we're a federated user (e.g. erin@nova browsing orbit),
|
||||
// homeInstance points to the real home, not window.location.host.
|
||||
const trueHomeHost = currentUser.homeInstance ?? window.location.host;
|
||||
const bareUsername = currentUser.username.includes('@')
|
||||
|
||||
@@ -75,28 +75,28 @@ describe('spaceStore.upsertUserView preference rule', () => {
|
||||
});
|
||||
|
||||
it('home view (delivered by user home) wins over an existing stub', () => {
|
||||
// orbit delivers Axel as a federated stub (axel's home is nova).
|
||||
// orbit delivers Frank as a federated stub (frank's home is nova).
|
||||
const stubAxel = makeUser({
|
||||
id: 'orbit-local-id',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
avatar: 'https://nova.ddns.net/api/uploads/old.png',
|
||||
});
|
||||
useSpaceStore.getState().upsertUserView(stubAxel, 'https://orbit.ddns.net');
|
||||
|
||||
// Then nova delivers axel natively (no homeInstance, our home origin '').
|
||||
// Then nova delivers frank natively (no homeInstance, our home origin '').
|
||||
// canonicalUserKey for the home view: needs to match the stub's key.
|
||||
// Stub key = "nova.ddns.net:nova-axel-id".
|
||||
// Home view (nova native): homeInstance=null, homeUserId=null, id="nova-axel-id"
|
||||
// → key = ":nova-axel-id"
|
||||
// Stub key = "nova.ddns.net:nova-frank-id".
|
||||
// Home view (nova native): homeInstance=null, homeUserId=null, id="nova-frank-id"
|
||||
// → key = ":nova-frank-id"
|
||||
// These keys are different on purpose: the home record on its home instance
|
||||
// has no homeInstance/homeUserId. The cross-instance match relies on the
|
||||
// stub being the federated form. Verify behavior accordingly.
|
||||
const homeAxel = makeUser({
|
||||
id: 'nova-axel-id',
|
||||
username: 'axel',
|
||||
id: 'nova-frank-id',
|
||||
username: 'frank',
|
||||
avatar: '',
|
||||
avatarColor: 'teal',
|
||||
});
|
||||
@@ -111,19 +111,19 @@ describe('spaceStore.upsertUserView preference rule', () => {
|
||||
});
|
||||
|
||||
it('two same-canonical-key federated views: home delivery upgrades over sibling stub', () => {
|
||||
// Same person, same canonical key (homeInstance=nova, homeUserId=nova-axel-id),
|
||||
// Same person, same canonical key (homeInstance=nova, homeUserId=nova-frank-id),
|
||||
// but delivered from two different origins.
|
||||
const fromOrbit = makeUser({
|
||||
id: 'orbit-local',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
const fromNova = makeUser({
|
||||
id: 'nova-local',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'teal',
|
||||
});
|
||||
@@ -140,15 +140,15 @@ describe('spaceStore.upsertUserView preference rule', () => {
|
||||
it('stub view does NOT overwrite an existing home view', () => {
|
||||
const fromNova = makeUser({
|
||||
id: 'nova-local',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'teal',
|
||||
});
|
||||
const fromOrbit = makeUser({
|
||||
id: 'orbit-local',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
@@ -165,15 +165,15 @@ describe('spaceStore.upsertUserView preference rule', () => {
|
||||
it('same-tier writes update freshness (later write wins)', () => {
|
||||
const a = makeUser({
|
||||
id: 'orbit-1',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
const b = makeUser({
|
||||
id: 'orbit-1',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'sky', // simulating a later profile-update event
|
||||
});
|
||||
@@ -195,14 +195,14 @@ describe('spaceStore.upsertUserView preference rule', () => {
|
||||
|
||||
it('removeInstanceSpaces prunes entries delivered by the removed origin only', () => {
|
||||
const homeView = makeUser({
|
||||
id: 'nova-axel-id',
|
||||
username: 'axel',
|
||||
id: 'nova-frank-id',
|
||||
username: 'frank',
|
||||
avatarColor: 'teal',
|
||||
});
|
||||
const stubView = makeUser({
|
||||
id: 'orbit-axel-stub',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
id: 'orbit-frank-stub',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
@@ -221,8 +221,8 @@ describe('spaceStore.upsertUserView preference rule', () => {
|
||||
|
||||
it('removeInstanceSpaces of the home origin evicts entries it delivered', () => {
|
||||
const homeView = makeUser({
|
||||
id: 'nova-axel-id',
|
||||
username: 'axel',
|
||||
id: 'nova-frank-id',
|
||||
username: 'frank',
|
||||
avatarColor: 'teal',
|
||||
});
|
||||
useSpaceStore.getState().upsertUserView(homeView, '');
|
||||
@@ -231,20 +231,20 @@ describe('spaceStore.upsertUserView preference rule', () => {
|
||||
});
|
||||
|
||||
it('treats native users delivered by a remote as that remote\'s home view', () => {
|
||||
// jannis is native to orbit (homeInstance=null on orbit). When orbit
|
||||
// heidi is native to orbit (homeInstance=null on orbit). When orbit
|
||||
// delivers him, that's the home view. canonicalKey uses orbit-host.
|
||||
const jannis = makeUser({
|
||||
id: 'orbit-jannis-id',
|
||||
username: 'jannis',
|
||||
const heidi = makeUser({
|
||||
id: 'orbit-heidi-id',
|
||||
username: 'heidi',
|
||||
avatarColor: 'sky',
|
||||
});
|
||||
useSpaceStore.getState().upsertUserView(jannis, 'https://orbit.ddns.net');
|
||||
// Key is built from user.homeInstance — but jannis has none. So the key is
|
||||
// ':orbit-jannis-id'. That's correct: when delivered later from a sibling,
|
||||
// jannis would arrive WITH homeInstance set (synthesized by normalizeUserAssets),
|
||||
useSpaceStore.getState().upsertUserView(heidi, 'https://orbit.ddns.net');
|
||||
// Key is built from user.homeInstance — but heidi has none. So the key is
|
||||
// ':orbit-heidi-id'. That's correct: when delivered later from a sibling,
|
||||
// heidi would arrive WITH homeInstance set (synthesized by normalizeUserAssets),
|
||||
// producing a different (federated) key. The cache holds both, with the
|
||||
// home view winning on a cross-key collision-free basis.
|
||||
const entry = useSpaceStore.getState().userViews.get(`:${jannis.id}`);
|
||||
const entry = useSpaceStore.getState().userViews.get(`:${heidi.id}`);
|
||||
expect(entry?.isHome).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,23 @@ for (const name of ['localStorage', 'sessionStorage'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
// jsdom does not implement navigator.mediaDevices. Provide a default no-op stub
|
||||
// so components that enumerate devices or subscribe to `devicechange` (e.g.
|
||||
// MobileVoiceFullScreen) don't crash during render. Tests that need real device
|
||||
// behavior override it per-test (configurable: true).
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: {
|
||||
enumerateDevices: () => Promise.resolve([]),
|
||||
getUserMedia: () => Promise.reject(new Error('mediaDevices.getUserMedia not available in tests')),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Polyfill ClipboardItem for jsdom (not included in jsdom)
|
||||
if (typeof ClipboardItem === 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -185,8 +185,8 @@ describe('formatDmPreview', () => {
|
||||
|
||||
const actor: User = {
|
||||
id: 'U1',
|
||||
username: 'jannis',
|
||||
displayName: 'Jannis',
|
||||
username: 'heidi',
|
||||
displayName: 'Heidi',
|
||||
avatarColor: 'mint',
|
||||
avatar: null,
|
||||
bio: null,
|
||||
@@ -217,7 +217,7 @@ describe('formatDmSidebarPreview — name_changed system message', () => {
|
||||
content: JSON.stringify({ event: 'name_changed', oldName: null, newName: 'Cool Group' }),
|
||||
createdAt: 1,
|
||||
});
|
||||
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis renamed the group');
|
||||
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi renamed the group');
|
||||
});
|
||||
|
||||
it('newName=null (cleared) → "<actor> cleared the group name"', () => {
|
||||
@@ -227,7 +227,7 @@ describe('formatDmSidebarPreview — name_changed system message', () => {
|
||||
content: JSON.stringify({ event: 'name_changed', oldName: 'Old', newName: null }),
|
||||
createdAt: 1,
|
||||
});
|
||||
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis cleared the group name');
|
||||
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi cleared the group name');
|
||||
});
|
||||
|
||||
it('unresolvable actor → "Unknown renamed the group"', () => {
|
||||
@@ -391,7 +391,7 @@ describe('formatDmSidebarPreview — icon_changed system message', () => {
|
||||
content: JSON.stringify({ event: 'icon_changed' }),
|
||||
createdAt: 1,
|
||||
});
|
||||
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Jannis updated the group icon');
|
||||
expect(formatDmSidebarPreview(dm, { id: 'OTHER', username: 'other' })).toBe('Heidi updated the group icon');
|
||||
});
|
||||
|
||||
it('unresolvable actor → "Unknown updated the group icon"', () => {
|
||||
|
||||
@@ -299,7 +299,7 @@ export function formatDmHeaderName(dm: DmChannel, currentUser: AuthLike): string
|
||||
*
|
||||
* The unnamed-group case intentionally collapses to a generic noun: the
|
||||
* joined-names form is unreadable as a one-line placeholder once a group
|
||||
* has 4+ members ("Message #Test, Nova, youruser, Nova" runs off-screen
|
||||
* has 4+ members ("Message #Test, Nova, erin, Nova" runs off-screen
|
||||
* and obscures the actual call-to-action).
|
||||
*/
|
||||
export function formatDmInputLabel(dm: DmChannel, currentUser: AuthLike): string {
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('normalizeOriginToHost', () => {
|
||||
it('extracts host from full URLs', () => {
|
||||
expect(normalizeOriginToHost('https://nova.ddns.net')).toBe('nova.ddns.net');
|
||||
expect(normalizeOriginToHost('http://localhost:3000')).toBe('localhost:3000');
|
||||
expect(normalizeOriginToHost('https://orbit.example.com:8443/path')).toBe('orbit.example.com:8443');
|
||||
expect(normalizeOriginToHost('https://orbit.ddns.net:8443/path')).toBe('orbit.ddns.net:8443');
|
||||
});
|
||||
|
||||
it('returns bare-domain inputs unchanged', () => {
|
||||
@@ -47,12 +47,12 @@ describe('canonicalUserKey', () => {
|
||||
it('produces the same key for stubs of the same person across instances', () => {
|
||||
const fromOrbit = canonicalUserKey({
|
||||
id: 'orbitLocalId',
|
||||
homeUserId: 'nova-axel',
|
||||
homeUserId: 'nova-frank',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
});
|
||||
const fromAnotherPeer = canonicalUserKey({
|
||||
id: 'otherPeerLocalId',
|
||||
homeUserId: 'nova-axel',
|
||||
homeUserId: 'nova-frank',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
});
|
||||
expect(fromOrbit).toBe(fromAnotherPeer);
|
||||
@@ -109,7 +109,7 @@ describe('isDeliveryFromHome', () => {
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects sibling-stub deliveries (orbit delivering Axel whose home is nova)', () => {
|
||||
it('rejects sibling-stub deliveries (orbit delivering Frank whose home is nova)', () => {
|
||||
expect(isDeliveryFromHome(
|
||||
{ homeInstance: 'nova.ddns.net' },
|
||||
'https://orbit.ddns.net',
|
||||
@@ -141,16 +141,16 @@ describe('isFederationGlobeApplicable', () => {
|
||||
});
|
||||
|
||||
it('returns false for purely-local users (no @domain in username)', () => {
|
||||
expect(isFederationGlobeApplicable({ username: 'axel' })).toBe(false);
|
||||
expect(isFederationGlobeApplicable({ username: 'youruser' })).toBe(false);
|
||||
expect(isFederationGlobeApplicable({ username: 'frank' })).toBe(false);
|
||||
expect(isFederationGlobeApplicable({ username: 'erin' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the username domain matches our own host (the load-bearing case)', () => {
|
||||
// Logged in to nova; viewing orbit-stub of Axel whose username is "axel@nova.ddns.net".
|
||||
expect(isFederationGlobeApplicable({ username: 'axel@nova.ddns.net' })).toBe(false);
|
||||
// Logged in to nova; viewing orbit-stub of Frank whose username is "frank@nova.ddns.net".
|
||||
expect(isFederationGlobeApplicable({ username: 'frank@nova.ddns.net' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for genuinely remote users', () => {
|
||||
expect(isFederationGlobeApplicable({ username: 'jannis@orbit.ddns.net' })).toBe(true);
|
||||
expect(isFederationGlobeApplicable({ username: 'heidi@orbit.ddns.net' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { User } from '@backspace/shared';
|
||||
|
||||
/**
|
||||
* Splits a potentially federated username into base name and domain.
|
||||
* "youruser@nova.ddns.net" → { baseName: "youruser", domain: "nova.ddns.net" }
|
||||
* "youruser" → { baseName: "youruser", domain: null }
|
||||
* "erin@nova.ddns.net" → { baseName: "erin", domain: "nova.ddns.net" }
|
||||
* "erin" → { baseName: "erin", domain: null }
|
||||
*/
|
||||
export function parseFederatedUsername(username: string): { baseName: string; domain: string | null } {
|
||||
const atIndex = username.indexOf('@');
|
||||
@@ -42,7 +42,7 @@ export function isSelf(
|
||||
// Replicated user: homeInstance matches our origin
|
||||
if (!user.homeInstance) return false;
|
||||
if (user.homeInstance !== window.location.host) return false;
|
||||
// Username: "youruser" or "youruser@nova.ddns.net" → base must match
|
||||
// Username: "erin" or "erin@nova.ddns.net" → base must match
|
||||
const { baseName } = parseFederatedUsername(user.username);
|
||||
const { baseName: homeBase } = parseFederatedUsername(homeUser.username);
|
||||
return baseName === homeBase;
|
||||
@@ -150,7 +150,7 @@ export function isDeliveryFromHome(
|
||||
*
|
||||
* True iff the user is genuinely remote: their username carries an `@domain`
|
||||
* suffix AND that domain is NOT our own host. Catches the bug where a stub
|
||||
* delivered by a sibling instance (e.g. orbit-side `axel@nova.ddns.net`
|
||||
* delivered by a sibling instance (e.g. orbit-side `frank@nova.ddns.net`
|
||||
* viewed from a session logged in to nova) would otherwise show the globe.
|
||||
*
|
||||
* Compose with {@link useCanonicalUserView} at render sites: resolve the
|
||||
|
||||
@@ -66,9 +66,9 @@ beforeEach(() => {
|
||||
describe('getCanonicalUserView', () => {
|
||||
it('returns the input unchanged on cache miss', () => {
|
||||
const stub = makeUser({
|
||||
id: 'orbit-axel-stub',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
id: 'orbit-frank-stub',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
@@ -77,16 +77,16 @@ describe('getCanonicalUserView', () => {
|
||||
|
||||
it('returns the cached entry when one exists for the same canonical key', () => {
|
||||
const stub = makeUser({
|
||||
id: 'orbit-axel-stub',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
id: 'orbit-frank-stub',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'lavender',
|
||||
});
|
||||
const homeFromNova = makeUser({
|
||||
id: 'nova-local-id',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
avatarColor: 'teal',
|
||||
});
|
||||
@@ -106,9 +106,9 @@ describe('getCanonicalUserView', () => {
|
||||
useSpaceStore.getState().upsertUserView(someOther, '');
|
||||
|
||||
const stub = makeUser({
|
||||
id: 'orbit-axel-stub',
|
||||
username: 'axel@nova.ddns.net',
|
||||
homeUserId: 'nova-axel-id',
|
||||
id: 'orbit-frank-stub',
|
||||
username: 'frank@nova.ddns.net',
|
||||
homeUserId: 'nova-frank-id',
|
||||
homeInstance: 'nova.ddns.net',
|
||||
});
|
||||
expect(getCanonicalUserView(stub)).toBe(stub);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function getCanonicalUserView(user: User): User {
|
||||
/**
|
||||
* Reactive lookup into the userViews cache. Subscribes to the specific cache
|
||||
* entry so the calling component re-renders when an upsert lands a better
|
||||
* view (e.g. nova's home view of Axel arriving after orbit's stub
|
||||
* view (e.g. nova's home view of Frank arriving after orbit's stub
|
||||
* populated the cache first). Returns the input unchanged on cache miss; the
|
||||
* site falls back to the current best information until the cache fills.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user