Draft: HOLD Files desk cut 1 (host contract): superseded if Matrix-only (A) is chosen on #142 #143

Closed
void wants to merge 1 commit from void/files-desk-contract into dev
7 changed files with 695 additions and 4 deletions

View file

@ -0,0 +1,180 @@
import { describe, expect, it, vi } from 'vitest';
import {
FilesHostError,
createFilesHost,
isEntryName,
joinPath,
openIdTokenSource,
parentPath,
parseIndex,
} from './filesHost';
const file = { kind: 'file', name: 'a.wav', size: 10, mtime: 5, mime: 'audio/wav' };
const HASH = 'a'.repeat(64);
describe('parseIndex', () => {
it('reads files and directories, keeping an absent hash absent', () => {
expect(
parseIndex({
role: 'mind',
path: 'commons/x',
entries: [{ kind: 'dir', name: 'd', mtime: 1 }, file, { ...file, name: 'b', hash: HASH }],
})
).toEqual({
role: 'mind',
path: 'commons/x',
entries: [{ kind: 'dir', name: 'd', mtime: 1 }, file, { ...file, name: 'b', hash: HASH }],
});
});
it('reads a role the client does not know as the least privilege, never as a known one', () => {
expect(parseIndex({ role: 'root', path: '', entries: [] }).role).toBe('other');
expect(parseIndex({ path: '', entries: [] }).role).toBe('other');
});
it.each([
['not an object', 'x'],
['no path', { entries: [] }],
['no entries', { path: '' }],
['entry not an object', { path: '', entries: [3] }],
['unknown kind', { path: '', entries: [{ ...file, kind: 'link' }] }],
['file without size', { path: '', entries: [{ ...file, size: undefined }] }],
['negative size', { path: '', entries: [{ ...file, size: -1 }] }],
['file without mime', { path: '', entries: [{ ...file, mime: '' }] }],
['no mtime', { path: '', entries: [{ ...file, mtime: 'now' }] }],
['hash not sha256 hex', { path: '', entries: [{ ...file, hash: 'abc' }] }],
['upper-case hash', { path: '', entries: [{ ...file, hash: 'A'.repeat(64) }] }],
])('refuses a malformed index: %s', (_why, raw) => {
expect(() => parseIndex(raw)).toThrow(FilesHostError);
});
it.each(['', '.', '..', 'a/b', 'a\\b', 'a\0b', '../x'])(
'refuses an entry named %j, so a listed name can never be a path',
(name) => {
expect(() => parseIndex({ path: '', entries: [{ ...file, name }] })).toThrow(FilesHostError);
expect(isEntryName(name)).toBe(false);
}
);
});
describe('paths', () => {
it('joins a base and a listed name, and refuses a name that is a path', () => {
expect(joinPath('', 'commons')).toBe('commons');
expect(joinPath('commons/cast-art', 'poses')).toBe('commons/cast-art/poses');
expect(joinPath('commons/', 'x')).toBe('commons/x');
expect(() => joinPath('commons', '../handoff-private')).toThrow();
expect(() => joinPath('commons', 'a/b')).toThrow();
});
it('goes up one level', () => {
expect(parentPath('commons/a/b')).toBe('commons/a');
expect(parentPath('commons')).toBe('');
expect(parentPath('')).toBe('');
});
});
describe('openIdTokenSource', () => {
const mx = () => {
let n = 0;
return {
getOpenIdToken: vi.fn(async () => {
n += 1;
return {
access_token: `t${n}`,
token_type: 'Bearer',
matrix_server_name: 's',
expires_in: 3600,
};
}),
};
};
it('caches until a minute before expiry, then asks again', async () => {
const client = mx();
let t = 0;
const source = openIdTokenSource(client, () => t);
expect(await source()).toBe('t1');
t = 3539_000;
expect(await source()).toBe('t1');
t = 3541_000;
expect(await source()).toBe('t2');
expect(client.getOpenIdToken).toHaveBeenCalledTimes(2);
});
it('asks again on fresh, whatever is cached', async () => {
const source = openIdTokenSource(mx(), () => 0);
await source();
expect(await source({ fresh: true })).toBe('t2');
});
});
describe('createFilesHost', () => {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
// the first token is the cached one, the second what a fresh ask returns
const token = (cached: string, renewed = cached) =>
vi.fn(async ({ fresh = false } = {}) => (fresh ? renewed : cached));
it('asks for the index with the path encoded and the bearer token', async () => {
const fetchFn = vi.fn(async () => json({ role: 'member', path: 'commons/a b', entries: [] }));
const host = createFilesHost({ host: 'https://h.test/', token: token('t'), fetchFn });
expect((await host.list('commons/a b')).path).toBe('commons/a b');
const [url, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('https://h.test/index?path=commons%2Fa%20b');
expect(init.headers).toMatchObject({ Authorization: 'Bearer t', Accept: 'application/json' });
});
it('retries once with a fresh token on 401, and only once', async () => {
const answers = [json({ code: 'c' }, 401), json({ role: 'member', path: '', entries: [] })];
const fetchFn = vi.fn(async () => answers.shift() as Response);
const tk = token('old', 'new');
await createFilesHost({ host: 'https://h.test', token: tk, fetchFn }).list('');
expect(fetchFn).toHaveBeenCalledTimes(2);
expect((fetchFn.mock.calls[1] as unknown as [string, RequestInit])[1].headers).toMatchObject({
Authorization: 'Bearer new',
});
const always = vi.fn(async () => json({ code: 'c' }, 401));
await expect(
createFilesHost({ host: 'https://h.test', token: token('a', 'b'), fetchFn: always }).list('')
).rejects.toMatchObject({ status: 401, code: 'c' });
expect(always).toHaveBeenCalledTimes(2);
});
it('carries the host code and message untouched, and keeps the status text when the body is not JSON', async () => {
const host = (res: Response) =>
createFilesHost({ host: 'https://h.test', token: token('t'), fetchFn: async () => res });
await expect(
host(json({ code: 'whatever', error: 'No' }, 403)).list('x')
).rejects.toMatchObject({ status: 403, code: 'whatever', message: 'No' });
await expect(
host(new Response('<html>', { status: 502, statusText: 'Bad Gateway' })).list('x')
).rejects.toMatchObject({ status: 502, code: undefined, message: 'Bad Gateway' });
});
it('refuses a malformed index from a host that answered 200', async () => {
const host = createFilesHost({
host: 'https://h.test',
token: token('t'),
fetchFn: async () => json({ nope: 1 }),
});
await expect(host.list('')).rejects.toBeInstanceOf(FilesHostError);
});
it('asks for a file with a Range when given one, and passes the signal on', async () => {
const fetchFn = vi.fn(async () => new Response('x', { status: 206 }));
const ctl = new AbortController();
const host = createFilesHost({ host: 'https://h.test', token: token('t'), fetchFn });
expect(
(await host.file('aqua/shared/c.wav', { range: 'bytes=0-9', signal: ctl.signal })).status
).toBe(206);
const [url, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('https://h.test/file?path=aqua%2Fshared%2Fc.wav');
expect(init.headers).toMatchObject({ Range: 'bytes=0-9' });
expect(init.signal).toBe(ctl.signal);
await host.file('a');
expect(
(fetchFn.mock.calls[1] as unknown as [string, RequestInit])[1].headers
).not.toHaveProperty('Range');
});
});

View file

@ -0,0 +1,187 @@
// The Files desk's contract with the file host (docs/commons-files-desk.md, "Host contract"): response types, the
// parser that refuses a malformed index, and the client that calls the host with a Matrix OpenID token. The host
// is Eureka's; its answers to Q-E1..7 are the authority and win over anything written here. This module draws no
// UI, reads no filesystem and holds no zone roots: a path is `<zone>/<relative path>` built only from names the
// host listed.
//
// Left to the host's answers (placeholders, nothing here depends on them):
// - the Commons roots (Q-E1): the client lists zones from `GET /index?path=` and assumes none;
// - media-element auth (Q-E3): a path ticket, a service worker or a blob fetch. `file()` sends the bearer token
// and returns the Response, which serves a blob fetch; a ticket would be one more method;
// - error codes: `FilesHostError.code` carries the host's code string untouched and no code is interpreted.
import type { MatrixClient } from 'matrix-js-sdk';
/** `member`, `mind` and `steward` as the doc proposes (Q-E4); any other word the host sends reads as `other`,
* which the client treats as least privilege. The host gates every request; the role only hides controls. */
export type FilesRole = 'member' | 'mind' | 'steward' | 'other';
export type FileEntry = {
kind: 'file';
name: string;
size: number;
/** unix seconds */
mtime: number;
/** sniffed by the host from the bytes, not guessed from the extension */
mime: string;
/** lower-case hex sha256; absent while the host has not computed it */
hash?: string;
};
export type DirEntry = { kind: 'dir'; name: string; mtime: number };
export type FilesEntry = FileEntry | DirEntry;
export type FilesIndex = {
role: FilesRole;
/** `''` for the root listing, whose entries are the zones as directories */
path: string;
entries: FilesEntry[];
};
/** A refusal or a failure of the host. `code` is the host's own error code string, uninterpreted. */
export class FilesHostError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, message: string, code?: string) {
super(message);
this.name = 'FilesHostError';
this.status = status;
this.code = code;
}
}
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
/** An entry name is one path segment: the client builds a path from names, so a name that is a path is refused. */
export const isEntryName = (name: unknown): name is string =>
typeof name === 'string' && name !== '' && name !== '.' && name !== '..' && !/[/\\\0]/.test(name);
const bad = (what: string): never => {
throw new FilesHostError(502, `The file host sent a malformed index: ${what}`);
};
const ROLES = ['member', 'mind', 'steward'] as const;
const HASH = /^[0-9a-f]{64}$/;
const count = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v >= 0;
const parseEntry = (raw: unknown, i: number): FilesEntry => {
if (!isRecord(raw)) return bad(`entry ${i} is not an object`);
const { name, kind, size, mtime, mime, hash } = raw;
if (!isEntryName(name)) return bad(`entry ${i} has no usable name`);
if (!count(mtime)) return bad(`"${name}" has no mtime`);
if (kind === 'dir') return { kind, name, mtime };
if (kind !== 'file') return bad(`"${name}" has kind ${String(kind)}`);
if (!count(size)) return bad(`"${name}" has no size`);
if (typeof mime !== 'string' || mime === '') return bad(`"${name}" has no mime`);
if (hash !== undefined && (typeof hash !== 'string' || !HASH.test(hash)))
return bad(`"${name}" has a hash that is not sha256 hex`);
return { kind, name, size, mtime, mime, ...(hash === undefined ? {} : { hash }) };
};
/** The index response as the client uses it, or a FilesHostError: nothing half-valid is passed on. */
export const parseIndex = (raw: unknown): FilesIndex => {
if (!isRecord(raw)) return bad('not an object');
const { role, path, entries } = raw;
if (typeof path !== 'string') return bad('no path');
if (!Array.isArray(entries)) return bad('no entries');
return {
role: ROLES.find((r) => r === role) ?? 'other',
path,
entries: entries.map(parseEntry),
};
};
/** `base` and one listed entry name, joined; `base` is `''` or a path the host gave. */
export const joinPath = (base: string, name: string): string => {
if (!isEntryName(name)) throw new Error(`not an entry name: ${JSON.stringify(name)}`);
return base === '' ? name : `${base.replace(/\/+$/, '')}/${name}`;
};
/** The path one level up; `''` at a zone root's parent. */
export const parentPath = (path: string): string => path.split('/').slice(0, -1).join('/');
export type TokenSource = (opts?: { fresh?: boolean }) => Promise<string>;
/** A Matrix OpenID token for the host, cached until a minute before it expires. The host verifies it against the
* homeserver; the user's access token never leaves the client. */
export const openIdTokenSource = (
mx: Pick<MatrixClient, 'getOpenIdToken'>,
now: () => number = Date.now
): TokenSource => {
let cached: { token: string; until: number } | undefined;
return async ({ fresh = false } = {}) => {
if (!fresh && cached && now() < cached.until) return cached.token;
const t = await mx.getOpenIdToken();
cached = { token: t.access_token, until: now() + Math.max(0, t.expires_in - 60) * 1000 };
return t.access_token;
};
};
export type FetchFn = (input: string, init?: RequestInit) => Promise<Response>;
export type FilesHost = {
list: (path: string, signal?: AbortSignal) => Promise<FilesIndex>;
/** The bytes of a file, whole or a `Range` (206). The caller reads the body; a refusal throws. */
file: (path: string, opts?: { range?: string; signal?: AbortSignal }) => Promise<Response>;
};
const failure = async (res: Response): Promise<FilesHostError> => {
let code: string | undefined;
let message = res.statusText || `HTTP ${res.status}`;
try {
const body: unknown = await res.json();
if (isRecord(body)) {
if (typeof body.code === 'string') code = body.code;
if (typeof body.error === 'string') message = body.error;
}
} catch {
// a body that is not JSON keeps the status text
}
return new FilesHostError(res.status, message, code);
};
export const createFilesHost = (opts: {
/** the host's base URL, e.g. https://files.example/ (a placeholder until Eureka names it) */
host: string;
token: TokenSource;
fetchFn?: FetchFn;
}): FilesHost => {
const base = opts.host.replace(/\/+$/, '');
const fetchFn: FetchFn = opts.fetchFn ?? ((input, init) => fetch(input, init));
// One retry with a fresh token on 401: a cached token may have expired at the host before it did here.
const call = async (url: string, headers: Record<string, string>, signal?: AbortSignal) => {
const send = async (fresh: boolean) =>
fetchFn(url, {
headers: { ...headers, Authorization: `Bearer ${await opts.token({ fresh })}` },
signal,
});
let res = await send(false);
if (res.status === 401) res = await send(true);
if (!res.ok) throw await failure(res);
return res;
};
return {
list: async (path, signal) =>
parseIndex(
await (
await call(
`${base}/index?path=${encodeURIComponent(path)}`,
{ Accept: 'application/json' },
signal
)
).json()
),
file: (path, o = {}) =>
call(
`${base}/file?path=${encodeURIComponent(path)}`,
o.range ? { Range: o.range } : {},
o.signal
),
};
};

View file

@ -38,6 +38,10 @@ export type ClientConfig = {
// Sharing a set of files into a file-zone room as one album. Off unless `enabled` is true.
fileZoneShare?: { enabled?: boolean };
// The Files desk (docs/commons-files-desk.md): a served-directory file zone beside the Matrix one. Off unless
// `enabled` is true; `host` is the file host's base URL (a value Eureka names, never a path this client invents).
filesDesk?: { enabled?: boolean; host?: string };
// The sign-up entry on the sign-in page. Off unless `enabled` is true; `url` is the account
// service's own registration page (a value, never a route this client invents).
registration?: RegistrationConfig;

View file

@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest';
import { createClient } from 'matrix-js-sdk';
import { createDemoServer } from './server';
import { guardedFetch } from './install';
import {
createFilesHost,
openIdTokenSource,
FilesHostError,
} from '../app/features/files/filesHost';
import { DEMO_FILES_HOST } from './filesHost';
import { DEMO_ORIGIN, ME } from './fixtures';
const PAGE = 'https://page.test';
const never = (async () => new Response('real network')) as never;
// The client against the fake, through the same guard the page uses: what the Files desk will see in the preview.
const setup = () => {
const server = createDemoServer();
const fetchFn = guardedFetch(server, PAGE, never);
const mx = createClient({
baseUrl: DEMO_ORIGIN,
userId: ME,
accessToken: 'x',
fetchFn: fetchFn as never,
});
const host = createFilesHost({
host: DEMO_FILES_HOST,
token: openIdTokenSource(mx),
fetchFn: (u, i) => fetchFn(u, i),
});
return { server, host, fetchFn };
};
describe('the pretend file host', () => {
it('lists the zones at the root and a zone one level at a time, with sniffed mime and a sha256 on files', async () => {
const { host, server } = setup();
const root = await host.list('');
expect(root.entries.map((e) => e.name)).toEqual(['aqua', 'commons']);
expect(root.role).toBe('member');
const poses = await host.list('commons/cast-art/poses');
expect(poses.entries).toEqual([
expect.objectContaining({
kind: 'file',
name: 'aqua-calm.png',
mime: 'image/png',
hash: expect.stringMatching(/^[0-9a-f]{64}$/),
}),
expect.objectContaining({ name: 'nibu-bright.png' }),
]);
const commons = await host.list('commons');
expect(commons.entries.map((e) => `${e.kind}:${e.name}`)).toEqual([
'dir:backlog',
'dir:cast-art',
'dir:uploads',
'file:README.txt',
]);
server.close();
});
it('serves the bytes of a file, whole and by range, and the hash is the hash of those bytes', async () => {
const { host, server } = setup();
const entry = (await host.list('aqua/shared')).entries.find((e) => e.name === 'clip-01.wav');
if (entry?.kind !== 'file') throw new Error('no clip');
const whole = await host.file('aqua/shared/clip-01.wav');
const bytes = new Uint8Array(await whole.arrayBuffer());
expect(whole.status).toBe(200);
expect(whole.headers.get('accept-ranges')).toBe('bytes');
expect(bytes.length).toBe(entry.size);
const digest = await crypto.subtle.digest('SHA-256', bytes);
expect(
Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
).toBe(entry.hash);
const part = await host.file('aqua/shared/clip-01.wav', { range: 'bytes=4-11' });
expect(part.status).toBe(206);
expect(part.headers.get('content-range')).toBe(`bytes 4-11/${entry.size}`);
expect(new Uint8Array(await part.arrayBuffer())).toEqual(bytes.slice(4, 12));
const tail = await host.file('aqua/shared/clip-01.wav', { range: 'bytes=-4' });
expect(new Uint8Array(await tail.arrayBuffer())).toEqual(bytes.slice(-4));
await expect(
host.file('aqua/shared/clip-01.wav', { range: `bytes=${entry.size}-` })
).rejects.toMatchObject({ status: 416 });
server.close();
});
it.each([
'handoff-private',
'handoff-private/x',
'commons/../aqua',
'../etc/passwd',
'/commons',
'commons//cast-art',
'commons/.hidden',
'commons/nope',
'nozone',
'commons/README.txt/x',
])(
'answers 404 for %j: a path the host would not resolve says nothing about what exists',
async (path) => {
const { host, server } = setup();
await expect(host.list(path)).rejects.toMatchObject({ status: 404 });
await expect(host.file(path)).rejects.toMatchObject({ status: 404 });
server.close();
}
);
it('refuses a request without the OpenID bearer, a wrong one, and any write', async () => {
const { fetchFn, server } = setup();
const url = `${DEMO_FILES_HOST}/index?path=`;
expect((await fetchFn(url)).status).toBe(401);
expect(
(await fetchFn(url, { headers: { Authorization: 'Bearer demo-preview-token' } })).status
).toBe(401);
const put = await fetchFn(`${DEMO_FILES_HOST}/file?path=commons/uploads/x.txt`, {
method: 'PUT',
headers: { Authorization: 'Bearer demo-openid-token' },
body: 'x',
});
expect(put.status).toBe(405);
server.close();
});
it('refuses to list a file and to fetch a directory', async () => {
const { host, server } = setup();
await expect(host.list('commons/README.txt')).rejects.toBeInstanceOf(FilesHostError);
await expect(host.file('commons/cast-art')).rejects.toBeInstanceOf(FilesHostError);
server.close();
});
});

View file

@ -0,0 +1,159 @@
// The preview's pretend file host: the contract of docs/commons-files-desk.md answered from a few in-memory zones,
// so the Files desk can be built and looked at in a browser before the real host exists. It stands in for a host
// whose roots, ticket auth and error codes Eureka has not settled (Q-E1, Q-E3): the zones here are samples, the
// codes are `demo_*`, and nothing in the client may depend on either. It enforces what the real host must: a bearer
// token, one zone root per path, no `..`, no dotfiles, and `handoff-private` is simply not there.
import { DEMO_ORIGIN } from './fixtures';
import { DemoMedia, pngGradient, wavTone } from './media';
import type { DemoRequest, DemoResponse } from './server';
/** Where the fake answers: the demo origin, under this prefix. The client's `host` setting is this URL. */
export const FILES_PREFIX = '/_files';
export const DEMO_FILES_HOST = `${DEMO_ORIGIN}${FILES_PREFIX}`;
/** What the pretend homeserver's openid/request_token hands out, and the only bearer the fake accepts. */
export const DEMO_OPENID_TOKEN = 'demo-openid-token';
const text = (s: string): DemoMedia => ({ type: 'text/plain', data: new TextEncoder().encode(s) });
// name -> children (a directory) or media (a file), per zone
interface Tree {
[name: string]: Tree | DemoMedia;
}
const ZONES: Record<string, Tree> = {
commons: {
'README.txt': text('Commons: what members and Minds share.\n'),
'cast-art': {
poses: {
'aqua-calm.png': pngGradient([80, 160, 220]),
'nibu-bright.png': pngGradient([240, 190, 70]),
},
},
backlog: { 'backlog-void.md': text('# void\n\n- one thing\n- another\n') },
'.hidden': text('a dotfile the host never shows\n'),
uploads: {},
},
// present on disk, never served: the host's allowlist does not name it (bait for the tests)
'handoff-private': { 'secret.txt': text('never shown\n') },
aqua: {
shared: {
'clip-01.wav': wavTone(440),
'clip-02.wav': wavTone(660),
'notes.txt': text('listen on headphones\n'),
},
},
};
const isMedia = (n: Tree | DemoMedia): n is DemoMedia =>
'data' in n && n.data instanceof Uint8Array;
const hex = (buf: ArrayBuffer) =>
Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
const fail = (status: number, code: string, error: string): DemoResponse => ({
status,
body: { code, error },
});
// A path the real host would refuse to resolve at all answers 404 as if it did not exist.
const NEVER = 'handoff-private';
const shown = (name: string) => !name.startsWith('.') && name !== NEVER;
const segmentsOf = (path: string): string[] | undefined => {
if (path === '') return [];
const segs = path.split('/');
return segs.every((n) => shown(n) && !/[\\\0]/.test(n)) ? segs : undefined;
};
const walk = (segs: string[]): Tree | DemoMedia | undefined =>
segs.reduce<Tree | DemoMedia | undefined>(
(at, s) =>
at && !isMedia(at) && Object.prototype.hasOwnProperty.call(at, s) ? at[s] : undefined,
ZONES
);
const parseRange = (header: string, size: number): [number, number] | 'bad' | undefined => {
if (header === '') return undefined;
const m = header.match(/^bytes=(\d*)-(\d*)$/);
if (!m || (m[1] === '' && m[2] === '')) return 'bad';
const start = m[1] === '' ? Math.max(0, size - Number(m[2])) : Number(m[1]);
const end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
return start > end || start >= size ? 'bad' : [start, end];
};
export type DemoFiles = {
handles: (url: string) => boolean;
handle: (req: DemoRequest) => Promise<DemoResponse>;
};
/** `mtime` of every sample entry, in unix seconds; the clock is injected so tests do not drift. */
export const createDemoFiles = (now: () => number = Date.now): DemoFiles => {
const mtime = () => Math.floor(now() / 1000) - 3600;
const entry = async (name: string, node: Tree | DemoMedia) =>
isMedia(node)
? {
kind: 'file',
name,
size: node.data.length,
mtime: mtime(),
mime: node.type,
hash: hex(await crypto.subtle.digest('SHA-256', node.data)),
}
: { kind: 'dir', name, mtime: mtime() };
const handle = async (req: DemoRequest): Promise<DemoResponse> => {
const u = new URL(req.url, DEMO_ORIGIN);
const route = u.pathname.slice(FILES_PREFIX.length);
if (req.method.toUpperCase() !== 'GET') return fail(405, 'demo_method', 'Read only');
if (req.headers?.authorization !== `Bearer ${DEMO_OPENID_TOKEN}`)
return fail(401, 'demo_unauthenticated', 'Sign in again');
if (route !== '/index' && route !== '/file')
return fail(404, 'demo_not_found', 'No such route');
const path = u.searchParams.get('path') ?? '';
const segs = segmentsOf(path);
const node = segs && walk(segs);
if (!node) return fail(404, 'demo_not_found', 'No such path');
if (route === '/index') {
if (isMedia(node)) return fail(400, 'demo_not_a_directory', 'Not a directory');
return {
status: 200,
body: {
role: 'member',
path,
entries: await Promise.all(
Object.entries(node)
.filter(([name]) => shown(name))
.sort(
([a, x], [b, y]) => Number(isMedia(x)) - Number(isMedia(y)) || a.localeCompare(b)
)
.map(([name, child]) => entry(name, child))
),
},
};
}
if (!isMedia(node)) return fail(400, 'demo_not_a_file', 'Not a file');
const range = parseRange(req.headers?.range ?? '', node.data.length);
const base = { 'accept-ranges': 'bytes', 'x-content-type-options': 'nosniff' };
if (range === 'bad')
return {
status: 416,
body: { code: 'demo_range', error: 'Range not satisfiable' },
headers: { 'content-range': `bytes */${node.data.length}` },
};
if (!range) return { status: 200, body: null, raw: node, headers: base };
const [start, end] = range;
return {
status: 206,
body: null,
raw: { type: node.type, data: node.data.slice(start, end + 1) },
headers: { ...base, 'content-range': `bytes ${start}-${end}/${node.data.length}` },
};
};
return {
handles: (url) => new URL(url, DEMO_ORIGIN).pathname.startsWith(`${FILES_PREFIX}/`),
handle,
};
};

View file

@ -54,6 +54,14 @@ const parseBody = (body: unknown): unknown => {
}
};
const lowerHeaders = (h: HeadersInit | undefined): Record<string, string> => {
const out: Record<string, string> = {};
new Headers(h).forEach((v, k) => {
out[k] = v;
});
return out;
};
export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
// Own-origin requests go to the real fetch, the demo origin to the pretend server, the rest throw.
@ -72,16 +80,17 @@ export const guardedFetch =
url,
body: parseBody(raw),
signal: init?.signal ?? req?.signal ?? undefined,
headers: lowerHeaders(init?.headers ?? req?.headers),
});
if (out.raw) {
return new Response(out.raw.data, {
status: out.status,
headers: { 'Content-Type': out.raw.type },
headers: { ...out.headers, 'Content-Type': out.raw.type },
});
}
return new Response(JSON.stringify(out.body), {
status: out.status,
headers: { 'Content-Type': 'application/json' },
headers: { ...out.headers, 'Content-Type': 'application/json' },
});
};

View file

@ -19,10 +19,24 @@ import {
USERS,
} from './fixtures';
import { DemoMedia, pngGradient, wavTone } from './media';
import { createDemoFiles, DEMO_OPENID_TOKEN } from './filesHost';
/** `raw` answers a media download with bytes instead of JSON. */
export type DemoResponse = { status: number; body: unknown; raw?: DemoMedia };
export type DemoRequest = { method: string; url: string; body?: unknown; signal?: AbortSignal };
export type DemoResponse = {
status: number;
body: unknown;
raw?: DemoMedia;
/** extra response headers (lower-case names), e.g. a file host's Content-Range */
headers?: Record<string, string>;
};
export type DemoRequest = {
method: string;
url: string;
body?: unknown;
signal?: AbortSignal;
/** request headers, lower-case names */
headers?: Record<string, string>;
};
const POSE_KEY = 'org.gamecult.pose';
const NOTICE_TYPE = 'org.gamecult.notice';
@ -334,7 +348,10 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
return ROOMS.filter((r) => !r.direct).map(entry);
};
const files = createDemoFiles(now);
const handle = async (req: DemoRequest): Promise<DemoResponse> => {
if (files.handles(req.url)) return files.handle(req);
const u = new URL(req.url, DEMO_ORIGIN);
const p = decodeURIComponent(u.pathname);
const m = req.method.toUpperCase();
@ -350,6 +367,13 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
return found ? { status: 200, body: null, raw: found } : err(404, 'M_NOT_FOUND', 'No media');
}
const c = p.replace(/^\/_matrix\/client\/(v\d+|r0|unstable)/, '');
if (m === 'POST' && /^\/user\/[^/]+\/openid\/request_token$/.test(c))
return ok({
access_token: DEMO_OPENID_TOKEN,
token_type: 'Bearer',
matrix_server_name: DEMO_SERVER,
expires_in: 3600,
});
if (p.endsWith('/versions'))
return ok({ versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
if (c === '/login') {