This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web,localinfra: use knotmirror

Signed-off-by: dawn <dawn@tangled.org>

author
dawn
date (Jul 31, 2026, 10:54 PM +0300) commit b419c838 parent e1f2c70e change-id tmsvkupo
+197 -3
+2 -1
docker-compose.yml
··· 262 262 MIRROR_PLC_URL: https://plc.tngl.boltless.dev 263 263 MIRROR_GIT_BASEPATH: /data/repos 264 264 MIRROR_KNOT_USE_SSL: "true" 265 - MIRROR_KNOT_SSRF: "true" 265 + MIRROR_KNOT_SSRF: "false" 266 266 MIRROR_RESYNC_PARALLELISM: "4" 267 267 MIRROR_APPVIEW_URL: http://appview:3000 268 268 MIRROR_SEARCH_ZOEKT_URL: https://zoekt.tngl.boltless.dev/indexserver ··· 451 451 restart: unless-stopped 452 452 environment: 453 453 BOBBIN_URL: https://bobbin.tngl.boltless.dev 454 + KNOTMIRROR_URL: https://mirror.tngl.boltless.dev 454 455 CAMO_URL: https://camo.tngl.boltless.dev 455 456 CAMO_SHARED_SECRET: localinfra-camo-secret 456 457 AVATAR_URL: https://avatar.tngl.boltless.dev
+14 -1
localinfra/Caddyfile
··· 50 50 # knotmirror 51 51 mirror.tngl.boltless.dev { 52 52 tls internal 53 - reverse_proxy knotmirror:7000 53 + @cors_preflight method OPTIONS 54 + handle @cors_preflight { 55 + header Access-Control-Allow-Origin "*" 56 + header Access-Control-Allow-Methods "GET, POST, OPTIONS" 57 + header Access-Control-Allow-Headers "Content-Type, authorization, atproto-proxy" 58 + header Access-Control-Max-Age "86400" 59 + respond 204 60 + } 61 + handle { 62 + header Access-Control-Allow-Origin "*" 63 + header Access-Control-Allow-Methods "GET, POST, OPTIONS" 64 + header Access-Control-Allow-Headers "Content-Type, authorization, atproto-proxy" 65 + reverse_proxy knotmirror:7000 66 + } 54 67 } 55 68 56 69 # zoekt (internal service)
+2
localinfra/readme.md
··· 70 70 71 71 ```bash 72 72 BOBBIN_URL=http://127.0.0.1:8090 73 + # leave unset to read repositories directly from their knots 74 + KNOTMIRROR_URL=https://mirror.tngl.boltless.dev 73 75 VITE_HANDLE_RESOLVER_URL=https://pds.tngl.boltless.dev 74 76 VITE_PLC_DIRECTORY_URL=https://plc.tngl.boltless.dev 75 77 ```
+1
web/src/app.d.ts
··· 3 3 interface PageData { 4 4 publicConfig?: { 5 5 bobbinUrl: string; 6 + knotMirrorUrl: string; 6 7 apiUrl: string; 7 8 knotResolverUrl: string; 8 9 camoEnabled: boolean;
+67
web/src/lib/api/knotmirror.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import * as knotmirror from "./knotmirror"; 3 + 4 + const jsonResponse = (body: unknown): Response => 5 + new Response(JSON.stringify(body), { 6 + status: 200, 7 + headers: { "content-type": "application/json" } 8 + }); 9 + 10 + describe("knotmirror.tree", () => { 11 + it("adapts the temporary tree response and fetches the readme blob", async () => { 12 + const fetchMock = vi.fn<typeof globalThis.fetch>().mockImplementation(async (input) => { 13 + const url = new URL(String(input)); 14 + if (url.pathname.endsWith("getTree")) { 15 + return jsonResponse({ 16 + ref: "main", 17 + files: [ 18 + { 19 + name: "README.md", 20 + mode: "100644", 21 + size: 12, 22 + last_commit: { 23 + hash: "abc", 24 + message: "docs", 25 + when: "2026-07-01T00:00:00Z" 26 + } 27 + } 28 + ] 29 + }); 30 + } 31 + return new Response("# hello", { 32 + status: 200, 33 + headers: { "content-type": "text/plain" } 34 + }); 35 + }); 36 + 37 + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); 38 + const tree = await knotmirror.tree(ctx, { 39 + repo: "did:plc:repo", 40 + ref: "main", 41 + path: "docs" 42 + }); 43 + 44 + expect(tree.readme).toEqual({ filename: "README.md", contents: "# hello" }); 45 + expect(tree.files[0]).toMatchObject({ name: "README.md", mode: "100644" }); 46 + expect(fetchMock).toHaveBeenCalledTimes(2); 47 + const blobUrl = new URL(String(fetchMock.mock.calls[1][0])); 48 + expect(blobUrl.pathname).toBe("/xrpc/sh.tangled.git.temp.getBlob"); 49 + expect(blobUrl.searchParams.get("path")).toBe("docs/README.md"); 50 + }); 51 + }); 52 + 53 + describe("knotmirror.languages", () => { 54 + it("uses the temporary languages endpoint", async () => { 55 + const fetchMock = vi 56 + .fn<typeof globalThis.fetch>() 57 + .mockResolvedValue( 58 + jsonResponse({ ref: "main", languages: [{ name: "TypeScript", size: 10 }] }) 59 + ); 60 + const ctx = knotmirror.createKnotMirrorClient("https://mirror.test", fetchMock); 61 + 62 + await knotmirror.languages(ctx, { repo: "did:plc:repo", ref: "main" }); 63 + 64 + const url = new URL(String(fetchMock.mock.calls[0][0])); 65 + expect(url.pathname).toBe("/xrpc/sh.tangled.git.temp.listLanguages"); 66 + }); 67 + });
+103
web/src/lib/api/knotmirror.ts
··· 1 + import type { BobbinContext, XrpcRequestInit } from "./client"; 2 + import { createBobbinClient } from "./client"; 3 + import { jsonGet, rawGet } from "./_request"; 4 + import type * as LegacyTree from "./lexicons/types/sh/tangled/git/temp/getTree"; 5 + import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; 6 + import { treeEntryKind, type BranchesResponse, type LogResponse, type TagsResponse } from "./repo"; 7 + 8 + const TREE_NSID = "sh.tangled.git.temp.getTree"; 9 + const BLOB_NSID = "sh.tangled.git.temp.getBlob"; 10 + const LOG_NSID = "sh.tangled.git.temp.listCommits"; 11 + const BRANCHES_NSID = "sh.tangled.git.temp.listBranches"; 12 + const TAGS_NSID = "sh.tangled.git.temp.listTags"; 13 + const LANGUAGES_NSID = "sh.tangled.git.temp.listLanguages"; 14 + 15 + export interface KnotMirrorLanguagesResponse { 16 + languages?: { name: string; size: number }[]; 17 + total?: number; 18 + ref?: string; 19 + } 20 + 21 + export const createKnotMirrorClient = (serviceUrl: string, fetch: typeof globalThis.fetch) => 22 + createBobbinClient({ serviceUrl, fetch }); 23 + 24 + const normalizeSignature = ( 25 + signature: LegacyTree.Signature | undefined 26 + ): Tree.Signature | undefined => 27 + signature ? { name: signature.name, email: signature.email, when: signature.when } : undefined; 28 + 29 + const normalizeCommit = (commit: LegacyTree.LastCommit | undefined): Tree.LastCommit | undefined => 30 + commit 31 + ? { 32 + hash: commit.hash, 33 + message: commit.message, 34 + when: commit.when, 35 + author: normalizeSignature(commit.author) 36 + } 37 + : undefined; 38 + 39 + const normalizeTree = (tree: LegacyTree.$output): Tree.$output => ({ 40 + ref: tree.ref, 41 + parent: tree.parent, 42 + dotdot: tree.dotdot, 43 + lastCommit: normalizeCommit(tree.lastCommit), 44 + files: tree.files.map((entry) => ({ 45 + name: entry.name, 46 + mode: entry.mode, 47 + size: entry.size, 48 + last_commit: normalizeCommit(entry.last_commit) 49 + })) 50 + }); 51 + 52 + const isReadmeFile = (name: string, mode: string): boolean => 53 + /^readme(?:\.[^.]+)?$/i.test(name) && treeEntryKind(mode) === "file"; 54 + 55 + const pathInTree = (path: string | undefined, name: string): string => 56 + path ? `${path}/${name}` : name; 57 + 58 + export const tree = async ( 59 + ctx: BobbinContext, 60 + params: { repo: string; ref: string; path?: string }, 61 + init?: XrpcRequestInit 62 + ): Promise<Tree.$output> => { 63 + const response = await jsonGet<LegacyTree.$output>(ctx, TREE_NSID, params, init); 64 + const result = normalizeTree(response); 65 + const readme = response.files.find((entry) => isReadmeFile(entry.name, entry.mode)); 66 + if (!readme) return result; 67 + 68 + try { 69 + const blob = await rawGet( 70 + ctx, 71 + BLOB_NSID, 72 + { repo: params.repo, ref: params.ref, path: pathInTree(params.path, readme.name) }, 73 + init 74 + ); 75 + return { ...result, readme: { filename: readme.name, contents: await blob.text() } }; 76 + } catch { 77 + return result; 78 + } 79 + }; 80 + 81 + export const log = ( 82 + ctx: BobbinContext, 83 + params: { repo: string; ref: string; limit: number }, 84 + init?: XrpcRequestInit 85 + ) => jsonGet<LogResponse>(ctx, LOG_NSID, params, init); 86 + 87 + export const branches = ( 88 + ctx: BobbinContext, 89 + params: { repo: string; limit: number }, 90 + init?: XrpcRequestInit 91 + ) => jsonGet<BranchesResponse>(ctx, BRANCHES_NSID, params, init); 92 + 93 + export const tags = ( 94 + ctx: BobbinContext, 95 + params: { repo: string; limit: number }, 96 + init?: XrpcRequestInit 97 + ) => jsonGet<TagsResponse>(ctx, TAGS_NSID, params, init); 98 + 99 + export const languages = ( 100 + ctx: BobbinContext, 101 + params: { repo: string; ref: string }, 102 + init?: XrpcRequestInit 103 + ) => jsonGet<KnotMirrorLanguagesResponse>(ctx, LANGUAGES_NSID, params, init);
+8 -1
web/src/lib/server/config.ts
··· 7 7 8 8 export type WebConfig = { 9 9 bobbinUrl: string; 10 + knotMirrorUrl: string; 10 11 apiUrl: string; 11 12 knotResolverUrl: string; 12 13 camoUrl: string; ··· 16 17 avatarSecret: string; 17 18 }; 18 19 19 - export type PublicWebConfig = Pick<WebConfig, "bobbinUrl" | "apiUrl" | "knotResolverUrl"> & { 20 + export type PublicWebConfig = Pick< 21 + WebConfig, 22 + "bobbinUrl" | "knotMirrorUrl" | "apiUrl" | "knotResolverUrl" 23 + > & { 20 24 /** camo has a secret, so markup can route images through it */ 21 25 camoEnabled: boolean; 22 26 }; 23 27 24 28 type WebConfigEnv = { 25 29 BOBBIN_URL?: string; 30 + KNOTMIRROR_URL?: string; 26 31 TANGLED_API_URL?: string; 27 32 API_URL?: string; 28 33 KNOT_RESOLVER_URL?: string; ··· 34 39 35 40 export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ 36 41 bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), 42 + knotMirrorUrl: cleanUrl(values.KNOTMIRROR_URL, ""), 37 43 apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), 38 44 knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, "https://knot1.tangled.sh"), 39 45 camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh"), ··· 50 56 const config = getConfig(); 51 57 return { 52 58 bobbinUrl: config.bobbinUrl, 59 + knotMirrorUrl: config.knotMirrorUrl, 53 60 apiUrl: config.apiUrl, 54 61 knotResolverUrl: config.knotResolverUrl, 55 62 camoEnabled: config.camoSecret !== ""