From c70b87bb0a8414d7fa0bbeb99052f7602d058247 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 14:36:12 +0000 Subject: [PATCH] Files desk cut 1: contract types, host client with Matrix OpenID, and a fake host for the preview features/files/filesHost.ts: the index and file response types, parseIndex (refuses a malformed index and any entry name that is a path), joinPath, an OpenID token source, and createFilesHost (bearer token, one retry on 401, Range, host error codes passed through uninterpreted). preview-demo/filesHost.ts: sample zones answered in-page under /_files, with the rules the real host must keep (bearer, ranges, no dotfiles, handoff-private never served), and the pretend homeserver's openid/request_token. No UI; config key filesDesk is declared, nothing reads it. Roots, ticket auth and error codes stay placeholders until Eureka answers Q-E1..7 on PR #142. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/files/filesHost.test.ts | 180 ++++++++++++++++++++++ src/app/features/files/filesHost.ts | 187 +++++++++++++++++++++++ src/app/hooks/useClientConfig.ts | 4 + src/preview-demo/filesHost.test.ts | 128 ++++++++++++++++ src/preview-demo/filesHost.ts | 159 +++++++++++++++++++ src/preview-demo/install.ts | 13 +- src/preview-demo/server.ts | 28 +++- 7 files changed, 695 insertions(+), 4 deletions(-) create mode 100644 src/app/features/files/filesHost.test.ts create mode 100644 src/app/features/files/filesHost.ts create mode 100644 src/preview-demo/filesHost.test.ts create mode 100644 src/preview-demo/filesHost.ts diff --git a/src/app/features/files/filesHost.test.ts b/src/app/features/files/filesHost.test.ts new file mode 100644 index 00000000..71fecf21 --- /dev/null +++ b/src/app/features/files/filesHost.test.ts @@ -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('', { 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'); + }); +}); diff --git a/src/app/features/files/filesHost.ts b/src/app/features/files/filesHost.ts new file mode 100644 index 00000000..5cdf68fd --- /dev/null +++ b/src/app/features/files/filesHost.ts @@ -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 `/` 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 => + 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; + +/** 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, + 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; + +export type FilesHost = { + list: (path: string, signal?: AbortSignal) => Promise; + /** 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; +}; + +const failure = async (res: Response): Promise => { + 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, 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 + ), + }; +}; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 7e4aa4aa..e0eeefe7 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -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; diff --git a/src/preview-demo/filesHost.test.ts b/src/preview-demo/filesHost.test.ts new file mode 100644 index 00000000..107b3052 --- /dev/null +++ b/src/preview-demo/filesHost.test.ts @@ -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(); + }); +}); diff --git a/src/preview-demo/filesHost.ts b/src/preview-demo/filesHost.ts new file mode 100644 index 00000000..e116aaac --- /dev/null +++ b/src/preview-demo/filesHost.ts @@ -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 = { + 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( + (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; +}; + +/** `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 => { + 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, + }; +}; diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index 54bddd71..8e11c45d 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -54,6 +54,14 @@ const parseBody = (body: unknown): unknown => { } }; +const lowerHeaders = (h: HeadersInit | undefined): Record => { + const out: Record = {}; + new Headers(h).forEach((v, k) => { + out[k] = v; + }); + return out; +}; + export type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; // 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' }, }); }; diff --git a/src/preview-demo/server.ts b/src/preview-demo/server.ts index 4a70c95b..2671cffd 100644 --- a/src/preview-demo/server.ts +++ b/src/preview-demo/server.ts @@ -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; +}; +export type DemoRequest = { + method: string; + url: string; + body?: unknown; + signal?: AbortSignal; + /** request headers, lower-case names */ + headers?: Record; +}; 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 => { + 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') { -- 2.47.3