Draft: HOLD Files desk cut 1 (host contract): superseded if Matrix-only (A) is chosen on #142 #143
7 changed files with 695 additions and 4 deletions
180
src/app/features/files/filesHost.test.ts
Normal file
180
src/app/features/files/filesHost.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
187
src/app/features/files/filesHost.ts
Normal file
187
src/app/features/files/filesHost.ts
Normal 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
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
128
src/preview-demo/filesHost.test.ts
Normal file
128
src/preview-demo/filesHost.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
159
src/preview-demo/filesHost.ts
Normal file
159
src/preview-demo/filesHost.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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' },
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue