187 lines
8.2 KiB
TypeScript
187 lines
8.2 KiB
TypeScript
/**
|
|
* In-process stand-in for the two HTTP surfaces a plugin talks to:
|
|
* - WebDAV at /webdav/... (JSON directory listing, HEAD, Range GET, PUT)
|
|
* - meta API at /meta/... (GET record / property)
|
|
* - POST /api/claims (signed MMUR2 claims — every signature is verified)
|
|
* Records are stored FLAT (`subtitles/fre/<cid>`), the way meta-core keeps them.
|
|
*
|
|
* Claims are folded back into the old call shape so tests keep asserting on
|
|
* writes per record: a batch's text claims become one `PATCH /meta/<cid>`
|
|
* (body = key → value) and each retraction a `DELETE /meta/<cid>/<key>`.
|
|
*/
|
|
|
|
import http from 'http';
|
|
import type { AddressInfo } from 'net';
|
|
import { verifyClaim, type ClaimValue } from '../src/meta-core-client.js';
|
|
|
|
// Fixed test signing key (scalar 0x0101…01) — the plugin signs as this.
|
|
process.env.METAMESH_SIGNING_KEY ??= '01'.repeat(32);
|
|
|
|
export interface FakeCall {
|
|
method: string;
|
|
path: string;
|
|
body?: unknown;
|
|
}
|
|
|
|
export interface FakeCore {
|
|
url: string;
|
|
webdavUrl: string;
|
|
/** Keyed by path under /files, e.g. `/watch/show/a.mkv`. */
|
|
files: Map<string, Buffer>;
|
|
records: Map<string, Record<string, string>>;
|
|
calls: FakeCall[];
|
|
/** GET requests per file path (each ffmpeg pass opens the file at least once). */
|
|
fileGets: Map<string, number>;
|
|
failPatch: boolean;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
function readBody(req: http.IncomingMessage): Promise<Buffer> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
export async function startFakeCore(): Promise<FakeCore> {
|
|
const core = {
|
|
files: new Map<string, Buffer>(),
|
|
records: new Map<string, Record<string, string>>(),
|
|
calls: [] as FakeCall[],
|
|
fileGets: new Map<string, number>(),
|
|
failPatch: false,
|
|
};
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
const pathname = decodeURIComponent(url.pathname);
|
|
const send = (status: number, body?: unknown, headers: Record<string, string> = {}) => {
|
|
if (body === undefined) {
|
|
res.writeHead(status, headers);
|
|
res.end();
|
|
} else if (Buffer.isBuffer(body)) {
|
|
res.writeHead(status, headers);
|
|
res.end(body);
|
|
} else {
|
|
res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
};
|
|
|
|
if (pathname.startsWith('/webdav')) {
|
|
const p = pathname.slice('/webdav'.length) || '/';
|
|
if (req.method === 'PUT') {
|
|
core.files.set(p, await readBody(req));
|
|
return send(201);
|
|
}
|
|
if (p.endsWith('/')) {
|
|
const dir = p.replace(/\/$/, '');
|
|
const entries = new Map<string, { name: string; type: string; size: number }>();
|
|
for (const [fp, data] of core.files) {
|
|
if (!fp.startsWith(`${dir}/`)) continue;
|
|
const rest = fp.slice(dir.length + 1);
|
|
const slash = rest.indexOf('/');
|
|
if (slash === -1) entries.set(rest, { name: rest, type: 'file', size: data.length });
|
|
else entries.set(rest.slice(0, slash), { name: rest.slice(0, slash), type: 'directory', size: 512 });
|
|
}
|
|
return send(200, [...entries.values()]);
|
|
}
|
|
const data = core.files.get(p);
|
|
if (!data) return send(404);
|
|
if (req.method === 'HEAD') {
|
|
return send(200, undefined, { 'Content-Length': String(data.length), 'Accept-Ranges': 'bytes' });
|
|
}
|
|
core.fileGets.set(p, (core.fileGets.get(p) ?? 0) + 1);
|
|
const range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.range ?? '');
|
|
if (range) {
|
|
const start = Number(range[1]);
|
|
const end = range[2] ? Math.min(Number(range[2]), data.length - 1) : data.length - 1;
|
|
if (start >= data.length) return send(416, undefined, { 'Content-Range': `bytes */${data.length}` });
|
|
return send(206, data.subarray(start, end + 1), {
|
|
'Content-Range': `bytes ${start}-${end}/${data.length}`,
|
|
'Content-Length': String(end - start + 1),
|
|
'Accept-Ranges': 'bytes',
|
|
});
|
|
}
|
|
return send(200, data, { 'Content-Length': String(data.length), 'Accept-Ranges': 'bytes' });
|
|
}
|
|
|
|
if (pathname === '/api/claims' && req.method === 'POST') {
|
|
const claims = JSON.parse((await readBody(req)).toString('utf8')) as Array<{
|
|
cid: string; uid: string; key: string; value: string | { b64: string } | null; ts: number; sig: string;
|
|
}>;
|
|
if (core.failPatch) return send(500, { error: 'boom' });
|
|
const statuses: Array<{ status: string }> = [];
|
|
const sets = new Map<string, Record<string, string>>();
|
|
for (const c of claims) {
|
|
const value: ClaimValue = c.value === null ? null
|
|
: typeof c.value === 'string' ? c.value : Buffer.from(c.value.b64, 'base64');
|
|
if (!verifyClaim({ ...c, value, sig: Buffer.from(c.sig, 'base64') })) {
|
|
statuses.push({ status: 'bad_signature' });
|
|
continue;
|
|
}
|
|
statuses.push({ status: 'accepted' });
|
|
if (value === null) {
|
|
core.calls.push({ method: 'DELETE', path: `/meta/${c.cid}/${c.key}` });
|
|
const record = core.records.get(c.cid);
|
|
if (record) delete record[c.key];
|
|
} else {
|
|
sets.set(c.cid, { ...(sets.get(c.cid) ?? {}), [c.key]: String(value) });
|
|
}
|
|
}
|
|
for (const [cid, body] of sets) {
|
|
core.calls.push({ method: 'PATCH', path: `/meta/${cid}`, body });
|
|
core.records.set(cid, { ...(core.records.get(cid) ?? {}), ...body });
|
|
}
|
|
return send(200, statuses);
|
|
}
|
|
|
|
const m = /^\/meta\/([^/]+)(?:\/(.+))?$/.exec(pathname);
|
|
if (m) {
|
|
const [, hash, key] = m;
|
|
const raw = await readBody(req);
|
|
const body = raw.length ? JSON.parse(raw.toString('utf8')) : undefined;
|
|
if (req.method !== 'GET') core.calls.push({ method: req.method ?? '', path: pathname, body });
|
|
const record = core.records.get(hash);
|
|
|
|
if (req.method === 'GET' && !key) return record ? send(200, { metadata: record }) : send(404, { error: 'not found' });
|
|
if (req.method === 'GET' && key) {
|
|
return record && key in record ? send(200, { value: record[key] }) : send(404, { error: 'Property not found' });
|
|
}
|
|
if (req.method === 'PATCH' && !key) {
|
|
if (core.failPatch) return send(500, { error: 'boom' });
|
|
core.records.set(hash, { ...(record ?? {}), ...(body as Record<string, string>) });
|
|
return send(200, { status: 'ok' });
|
|
}
|
|
if (req.method === 'DELETE' && key) {
|
|
if (record) delete record[key];
|
|
return send(200, { status: 'ok' });
|
|
}
|
|
}
|
|
send(404, { error: 'no route' });
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
const url = `http://127.0.0.1:${port}`;
|
|
|
|
return {
|
|
...core,
|
|
get failPatch() { return core.failPatch; },
|
|
set failPatch(v: boolean) { core.failPatch = v; },
|
|
url,
|
|
webdavUrl: `${url}/webdav`,
|
|
close: () => new Promise<void>((resolve) => {
|
|
server.closeAllConnections?.();
|
|
server.close(() => resolve());
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Mutating calls (claims folded to PATCH / DELETE) against one record. */
|
|
export function writesTo(core: FakeCore, hash: string): FakeCall[] {
|
|
return core.calls.filter((c) => c.path === `/meta/${hash}` || c.path.startsWith(`/meta/${hash}/`));
|
|
}
|