From b419c838a5e9d8a56bdac013fb33cd8b4d7268a7 Mon Sep 17 00:00:00 2001 From: dawn Date: Mon, 27 Jul 2026 15:45:47 +0300 Subject: [PATCH] web,localinfra: use knotmirror Signed-off-by: dawn --- docker-compose.yml | 3 +- localinfra/Caddyfile | 15 ++++- localinfra/readme.md | 2 + web/src/app.d.ts | 1 + web/src/lib/api/knotmirror.test.ts | 67 +++++++++++++++++++ web/src/lib/api/knotmirror.ts | 103 +++++++++++++++++++++++++++++ web/src/lib/server/config.ts | 9 ++- 7 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/api/knotmirror.test.ts create mode 100644 web/src/lib/api/knotmirror.ts diff --git a/docker-compose.yml b/docker-compose.yml index 671485ba..79165fd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -262,7 +262,7 @@ services: MIRROR_PLC_URL: https://plc.tngl.boltless.dev MIRROR_GIT_BASEPATH: /data/repos MIRROR_KNOT_USE_SSL: "true" - MIRROR_KNOT_SSRF: "true" + MIRROR_KNOT_SSRF: "false" MIRROR_RESYNC_PARALLELISM: "4" MIRROR_APPVIEW_URL: http://appview:3000 MIRROR_SEARCH_ZOEKT_URL: https://zoekt.tngl.boltless.dev/indexserver @@ -451,6 +451,7 @@ services: restart: unless-stopped environment: BOBBIN_URL: https://bobbin.tngl.boltless.dev + KNOTMIRROR_URL: https://mirror.tngl.boltless.dev CAMO_URL: https://camo.tngl.boltless.dev CAMO_SHARED_SECRET: localinfra-camo-secret AVATAR_URL: https://avatar.tngl.boltless.dev diff --git a/localinfra/Caddyfile b/localinfra/Caddyfile index 7e14ce2a..207cd6e9 100644 --- a/localinfra/Caddyfile +++ b/localinfra/Caddyfile @@ -50,7 +50,20 @@ spindle.tngl.boltless.dev { # knotmirror mirror.tngl.boltless.dev { tls internal - reverse_proxy knotmirror:7000 + @cors_preflight method OPTIONS + handle @cors_preflight { + header Access-Control-Allow-Origin "*" + header Access-Control-Allow-Methods "GET, POST, OPTIONS" + header Access-Control-Allow-Headers "Content-Type, authorization, atproto-proxy" + header Access-Control-Max-Age "86400" + respond 204 + } + handle { + header Access-Control-Allow-Origin "*" + header Access-Control-Allow-Methods "GET, POST, OPTIONS" + header Access-Control-Allow-Headers "Content-Type, authorization, atproto-proxy" + reverse_proxy knotmirror:7000 + } } # zoekt (internal service) diff --git a/localinfra/readme.md b/localinfra/readme.md index 0fa4e19a..8d0429fc 100644 --- a/localinfra/readme.md +++ b/localinfra/readme.md @@ -70,6 +70,8 @@ local bobbin (e.g. in `web/.env`): ```bash BOBBIN_URL=http://127.0.0.1:8090 +# leave unset to read repositories directly from their knots +KNOTMIRROR_URL=https://mirror.tngl.boltless.dev VITE_HANDLE_RESOLVER_URL=https://pds.tngl.boltless.dev VITE_PLC_DIRECTORY_URL=https://plc.tngl.boltless.dev ``` diff --git a/web/src/app.d.ts b/web/src/app.d.ts index b11acf2a..8a5c4ae0 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -3,6 +3,7 @@ declare global { interface PageData { publicConfig?: { bobbinUrl: string; + knotMirrorUrl: string; apiUrl: string; knotResolverUrl: string; camoEnabled: boolean; diff --git a/web/src/lib/api/knotmirror.test.ts b/web/src/lib/api/knotmirror.test.ts new file mode 100644 index 00000000..bf8d94b3 --- /dev/null +++ b/web/src/lib/api/knotmirror.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import * as knotmirror from "./knotmirror"; + +const jsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" } + }); + +describe("knotmirror.tree", () => { + it("adapts the temporary tree response and fetches the readme blob", async () => { + const fetchMock = vi.fn().mockImplementation(async (input) => { + const url = new URL(String(input)); + if (url.pathname.endsWith("getTree")) { + return jsonResponse({ + ref: "main", + files: [ + { + name: "README.md", + mode: "100644", + size: 12, + last_commit: { + hash: "abc", + message: "docs", + when: "2026-07-01T00:00:00Z" + } + } + ] + }); + } + return new Response("# hello", { + status: 200, + headers: { "content-type": "text/plain" } + }); + }); + + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); + const tree = await knotmirror.tree(ctx, { + repo: "did:plc:repo", + ref: "main", + path: "docs" + }); + + expect(tree.readme).toEqual({ filename: "README.md", contents: "# hello" }); + expect(tree.files[0]).toMatchObject({ name: "README.md", mode: "100644" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + const blobUrl = new URL(String(fetchMock.mock.calls[1][0])); + expect(blobUrl.pathname).toBe("/xrpc/sh.tangled.git.temp.getBlob"); + expect(blobUrl.searchParams.get("path")).toBe("docs/README.md"); + }); +}); + +describe("knotmirror.languages", () => { + it("uses the temporary languages endpoint", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + jsonResponse({ ref: "main", languages: [{ name: "TypeScript", size: 10 }] }) + ); + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); + + await knotmirror.languages(ctx, { repo: "did:plc:repo", ref: "main" }); + + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.listLanguages"); + }); +}); diff --git a/web/src/lib/api/knotmirror.ts b/web/src/lib/api/knotmirror.ts new file mode 100644 index 00000000..c15fbd34 --- /dev/null +++ b/web/src/lib/api/knotmirror.ts @@ -0,0 +1,103 @@ +import type { BobbinContext, XrpcRequestInit } from "./client"; +import { createBobbinClient } from "./client"; +import { jsonGet, rawGet } from "./_request"; +import type * as LegacyTree from "./lexicons/types/sh/tangled/git/temp/getTree"; +import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; +import { treeEntryKind, type BranchesResponse, type LogResponse, type TagsResponse } from "./repo"; + +const TREE_NSID = "sh.tangled.git.temp.getTree"; +const BLOB_NSID = "sh.tangled.git.temp.getBlob"; +const LOG_NSID = "sh.tangled.git.temp.listCommits"; +const BRANCHES_NSID = "sh.tangled.git.temp.listBranches"; +const TAGS_NSID = "sh.tangled.git.temp.listTags"; +const LANGUAGES_NSID = "sh.tangled.git.temp.listLanguages"; + +export interface KnotMirrorLanguagesResponse { + languages?: { name: string; size: number }[]; + total?: number; + ref?: string; +} + +export const createKnotMirrorClient = (serviceUrl: string, fetch: typeof globalThis.fetch) => + createBobbinClient({ serviceUrl, fetch }); + +const normalizeSignature = ( + signature: LegacyTree.Signature | undefined +): Tree.Signature | undefined => + signature ? { name: signature.name, email: signature.email, when: signature.when } : undefined; + +const normalizeCommit = (commit: LegacyTree.LastCommit | undefined): Tree.LastCommit | undefined => + commit + ? { + hash: commit.hash, + message: commit.message, + when: commit.when, + author: normalizeSignature(commit.author) + } + : undefined; + +const normalizeTree = (tree: LegacyTree.$output): Tree.$output => ({ + ref: tree.ref, + parent: tree.parent, + dotdot: tree.dotdot, + lastCommit: normalizeCommit(tree.lastCommit), + files: tree.files.map((entry) => ({ + name: entry.name, + mode: entry.mode, + size: entry.size, + last_commit: normalizeCommit(entry.last_commit) + })) +}); + +const isReadmeFile = (name: string, mode: string): boolean => + /^readme(?:\.[^.]+)?$/i.test(name) && treeEntryKind(mode) === "file"; + +const pathInTree = (path: string | undefined, name: string): string => + path ? `${path}/${name}` : name; + +export const tree = async ( + ctx: BobbinContext, + params: { repo: string; ref: string; path?: string }, + init?: XrpcRequestInit +): Promise => { + const response = await jsonGet(ctx, TREE_NSID, params, init); + const result = normalizeTree(response); + const readme = response.files.find((entry) => isReadmeFile(entry.name, entry.mode)); + if (!readme) return result; + + try { + const blob = await rawGet( + ctx, + BLOB_NSID, + { repo: params.repo, ref: params.ref, path: pathInTree(params.path, readme.name) }, + init + ); + return { ...result, readme: { filename: readme.name, contents: await blob.text() } }; + } catch { + return result; + } +}; + +export const log = ( + ctx: BobbinContext, + params: { repo: string; ref: string; limit: number }, + init?: XrpcRequestInit +) => jsonGet(ctx, LOG_NSID, params, init); + +export const branches = ( + ctx: BobbinContext, + params: { repo: string; limit: number }, + init?: XrpcRequestInit +) => jsonGet(ctx, BRANCHES_NSID, params, init); + +export const tags = ( + ctx: BobbinContext, + params: { repo: string; limit: number }, + init?: XrpcRequestInit +) => jsonGet(ctx, TAGS_NSID, params, init); + +export const languages = ( + ctx: BobbinContext, + params: { repo: string; ref: string }, + init?: XrpcRequestInit +) => jsonGet(ctx, LANGUAGES_NSID, params, init); diff --git a/web/src/lib/server/config.ts b/web/src/lib/server/config.ts index c798debe..a0b8e6b7 100644 --- a/web/src/lib/server/config.ts +++ b/web/src/lib/server/config.ts @@ -7,6 +7,7 @@ const cleanUrl = (value: string | undefined, fallback: string) => { export type WebConfig = { bobbinUrl: string; + knotMirrorUrl: string; apiUrl: string; knotResolverUrl: string; camoUrl: string; @@ -16,13 +17,17 @@ export type WebConfig = { avatarSecret: string; }; -export type PublicWebConfig = Pick & { +export type PublicWebConfig = Pick< + WebConfig, + "bobbinUrl" | "knotMirrorUrl" | "apiUrl" | "knotResolverUrl" +> & { /** camo has a secret, so markup can route images through it */ camoEnabled: boolean; }; type WebConfigEnv = { BOBBIN_URL?: string; + KNOTMIRROR_URL?: string; TANGLED_API_URL?: string; API_URL?: string; KNOT_RESOLVER_URL?: string; @@ -34,6 +39,7 @@ type WebConfigEnv = { export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), + knotMirrorUrl: cleanUrl(values.KNOTMIRROR_URL, ""), apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, "https://knot1.tangled.sh"), camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), @@ -50,6 +56,7 @@ export const getPublicConfig = (): PublicWebConfig => { const config = getConfig(); return { bobbinUrl: config.bobbinUrl, + knotMirrorUrl: config.knotMirrorUrl, apiUrl: config.apiUrl, knotResolverUrl: config.knotResolverUrl, camoEnabled: config.camoSecret !== "" -- 2.51.2