Monorepo for Tangled tangled.org

appview/ogcard: migrate opengraph rendering to external cloudflare workers service #1182

merged opened by eti.tf targeting master from eti.tf/core: eti/opengraph-satori

the previous Go-based opengraph rendering made it difficult to handle dynamic content and was cumbersome to update when redesigning images. this moves rendering to a typescript/satori service on cloudflare workers, making design changes easier while offloading image processing from the main application.

Labels

None yet.

assignee

None yet.

Participants 5
AT URI
at://did:plc:xu5apv6kmu5jp7g5hwdnej42/sh.tangled.repo.pull/3mhga2rjlnd22
+1213 -6314
Interdiff #2 โ†’ #3
appview/config/config.go

This patch was likely rebased, as context lines do not match.

+2 -2
appview/issues/issues.go
··· 10 10 "time" 11 11 12 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/atclient" 13 + atpclient "github.com/bluesky-social/indigo/atproto/client" 14 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 16 "github.com/go-chi/chi/v5" ··· 1101 1101 // this is used to rollback changes made to the PDS 1102 1102 // 1103 1103 // it is a no-op if the provided ATURI is empty 1104 - func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 1104 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1105 1105 if aturi == "" { 1106 1106 return nil 1107 1107 }
appview/issues/opengraph.go

This file has not been changed.

+1 -2
appview/ogcard/.gitignore
··· 1 1 node_modules/ 2 - bun.lock 3 2 output/ 4 3 .wrangler/ 5 - .DS_Store 4 + .DS_Store
appview/ogcard/card.go

This file has not been changed.

+1 -1
appview/ogcard/client.go
··· 41 41 Stars int `json:"stars"` 42 42 Pulls int `json:"pulls"` 43 43 Issues int `json:"issues"` 44 - UpdatedAt string `json:"updatedAt"` 44 + CreatedAt string `json:"createdAt"` 45 45 AvatarUrl string `json:"avatarUrl"` 46 46 Languages []LanguageData `json:"languages"` 47 47 }
+9 -3
appview/ogcard/package.json
··· 3 3 "version": "1.0.0", 4 4 "private": true, 5 5 "type": "module", 6 + "workspaces": [ 7 + "packages/runtime" 8 + ], 6 9 "scripts": { 7 10 "dev": "wrangler dev", 8 11 "deploy": "wrangler deploy", 9 12 "typecheck": "tsc --noEmit", 10 13 "test": "bun test", 11 - "test:fonts": "tsx scripts/test-fonts.ts" 14 + "knip": "knip" 12 15 }, 13 16 "dependencies": { 14 - "@resvg/resvg-js": "^2.6.2", 17 + "@fontsource/inter": "^5.2.8", 15 18 "@resvg/resvg-wasm": "^2.6.2", 19 + "@tangled/ogcard-runtime": "*", 16 20 "lucide-static": "^0.577.0", 17 21 "preact": "^10.29.0", 18 - "satori": "^0.25.0", 22 + "satori": "0.25.0", 19 23 "zod": "^4.3.6" 20 24 }, 21 25 "devDependencies": { 22 26 "@cloudflare/workers-types": "^4.20260317.1", 27 + "@types/bun": "^1.3.11", 23 28 "@types/node": "^25.5.0", 29 + "knip": "^6.0.1", 24 30 "tsx": "^4.21.0", 25 31 "typescript": "^5.9.3", 26 32 "wrangler": "^4.75.0"
appview/ogcard/src/__tests__/assets/avatar.jpg

Failed to calculate interdiff for this file.

+7 -7
appview/ogcard/src/__tests__/fixtures.ts
··· 14 14 stars: 746, 15 15 pulls: 82, 16 16 issues: 176, 17 - updatedAt: "updated 2 hours ago", 17 + createdAt: "2026-01-29T00:00:00Z", 18 18 avatarUrl, 19 19 languages: [ 20 - { color: "#00ADD8", percentage: 65 }, 21 - { color: "#e34c26", percentage: 20 }, 20 + { color: "#00ADD8", percentage: 50 }, 21 + { color: "#e34c26", percentage: 30 }, 22 22 { color: "#7e7eff", percentage: 10 }, 23 - { color: "#663399", percentage: 3 }, 24 - { color: "#f1e05a", percentage: 2 }, 23 + { color: "#663399", percentage: 5 }, 24 + { color: "#f1e05a", percentage: 5 }, 25 25 ], 26 26 }); 27 27 ··· 43 43 ], 44 44 commentCount: 12, 45 45 reactionCount: 5, 46 - createdAt: "29 jan 2026", 46 + createdAt: "2026-01-29T00:00:00Z", 47 47 ...overrides, 48 48 }); 49 49 ··· 64 64 rounds: 3, 65 65 commentCount: 12, 66 66 reactionCount: 31, 67 - createdAt: "29 jan 2026", 67 + createdAt: "2026-01-29T00:00:00Z", 68 68 ...overrides, 69 69 }); 70 70
+5 -8
appview/ogcard/src/__tests__/render.test.ts
··· 1 1 import { test, describe, beforeAll } from "bun:test"; 2 2 import { writeFileSync, mkdirSync, readFileSync } from "fs"; 3 3 import { join } from "path"; 4 - import { h } from "preact"; 4 + import { h, type VNode } from "preact"; 5 5 import { renderCard } from "../lib/render"; 6 6 import { RepositoryCard } from "../components/cards/repository"; 7 7 import { IssueCard } from "../components/cards/issue"; ··· 39 39 avatarDataUri = loadAvatar(); 40 40 }); 41 41 42 - const savePng = (filename: string, buffer: Buffer) => { 42 + const savePng = (filename: string, buffer: Uint8Array) => { 43 43 writeFileSync(join(outputDir, filename), buffer); 44 44 }; 45 45 46 - const renderAndSave = async <T>( 47 - component: ReturnType<typeof h<T>>, 48 - filename: string, 49 - ) => { 50 - const { png } = await renderCard(component as any); 46 + const renderAndSave = async <P>(component: VNode<P>, filename: string) => { 47 + const { png } = await renderCard(component as VNode); 51 48 savePng(filename, png); 52 49 }; 53 50 54 51 describe("repository card", () => { 55 - test("renders open repository card", async () => { 52 + test("renders repository card", async () => { 56 53 const data = createRepoData(avatarDataUri); 57 54 const validated = repositoryCardSchema.parse(data); 58 55 await renderAndSave(h(RepositoryCard, validated), "repository-card.png");
+5 -7
appview/ogcard/src/components/cards/issue.tsx
··· 4 4 import { CardHeader } from "../shared/card-header"; 5 5 import { LabelList } from "../shared/label-pill"; 6 6 import { FooterStats } from "../shared/footer-stats"; 7 + import { TYPOGRAPHY } from "../shared/constants"; 7 8 import type { IssueCardData } from "../../validation"; 8 9 9 10 export function IssueCard(data: IssueCardData) { ··· 22 23 23 24 <div 24 25 style={{ 25 - fontFamily: "Inter", 26 - fontSize: 64, 27 - fontWeight: 600, 26 + ...TYPOGRAPHY.title, 28 27 color: "#000000", 29 28 display: "block", 30 29 lineClamp: `2 "... #${data.issueNumber}"`, ··· 36 35 <LabelList labels={data.labels} /> 37 36 </Col> 38 37 39 - <div 38 + <Row 40 39 style={{ 41 - display: "flex", 42 40 alignItems: "flex-end", 43 41 justifyContent: "space-between", 44 42 }}> ··· 48 46 commentCount={data.commentCount} 49 47 /> 50 48 <TangledLogo /> 51 - </div> 49 + </Row> 52 50 </Card> 53 51 ); 54 - } 52 + }
+9 -34
appview/ogcard/src/components/cards/pull-request.tsx
··· 4 4 import { CardHeader } from "../shared/card-header"; 5 5 import { FooterStats } from "../shared/footer-stats"; 6 6 import { FileDiff, RefreshCw } from "../../icons/lucide"; 7 - import { COLORS } from "../shared/constants"; 7 + import { COLORS, TYPOGRAPHY } from "../shared/constants"; 8 8 import type { PullRequestCardData } from "../../validation"; 9 9 10 10 interface FilesChangedPillProps { ··· 32 32 padding: "16px 28px", 33 33 }}> 34 34 <FileDiff size={34} color="#202020" /> 35 - <span 36 - style={{ 37 - fontFamily: "Inter", 38 - fontSize: 36, 39 - color: "#202020", 40 - fontWeight: 400, 41 - }}> 35 + <span style={{ ...TYPOGRAPHY.body, color: "#202020" }}> 42 36 {filesChanged} files 43 37 </span> 44 38 </Row> ··· 49 43 backgroundColor: COLORS.diff.additions.bg, 50 44 }}> 51 45 <span 52 - style={{ 53 - fontFamily: "Inter", 54 - fontSize: 36, 55 - color: COLORS.diff.additions.text, 56 - fontWeight: 500, 57 - }}> 46 + style={{ ...TYPOGRAPHY.body, color: COLORS.diff.additions.text }}> 58 47 +{additions} 59 48 </span> 60 49 </Row> ··· 64 53 backgroundColor: COLORS.diff.deletions.bg, 65 54 }}> 66 55 <span 67 - style={{ 68 - fontFamily: "Inter", 69 - fontSize: 36, 70 - color: COLORS.diff.deletions.text, 71 - fontWeight: 500, 72 - }}> 56 + style={{ ...TYPOGRAPHY.body, color: COLORS.diff.deletions.text }}> 73 57 -{deletions} 74 58 </span> 75 59 </Row> ··· 94 78 border: `4px solid ${COLORS.label.border}`, 95 79 }}> 96 80 <RefreshCw size={36} color="#202020" /> 97 - <span 98 - style={{ 99 - fontFamily: "Inter", 100 - fontSize: 36, 101 - color: "#202020", 102 - fontWeight: 400, 103 - }}> 81 + <span style={{ ...TYPOGRAPHY.body, color: "#202020" }}> 104 82 {value} {label} 105 83 </span> 106 84 </Row> ··· 123 101 124 102 <span 125 103 style={{ 126 - fontFamily: "Inter", 127 - fontSize: 64, 128 - fontWeight: 600, 104 + ...TYPOGRAPHY.title, 129 105 color: "#000000", 130 106 display: "block", 131 107 lineClamp: `2 "... #${data.pullRequestNumber}"`, ··· 144 120 </Row> 145 121 </Col> 146 122 147 - <div 123 + <Row 148 124 style={{ 149 - display: "flex", 150 125 alignItems: "flex-end", 151 126 justifyContent: "space-between", 152 127 }}> ··· 156 131 commentCount={data.commentCount} 157 132 /> 158 133 <TangledLogo /> 159 - </div> 134 + </Row> 160 135 </Card> 161 136 ); 162 - } 137 + }
+6 -23
appview/ogcard/src/components/cards/repository.tsx
··· 3 3 import { LanguageCircles } from "../shared/language-circles"; 4 4 import { Metrics } from "../shared/metrics"; 5 5 import { TangledLogo } from "../shared/logo"; 6 + import { FooterStats } from "../shared/footer-stats"; 7 + import { TYPOGRAPHY } from "../shared/constants"; 6 8 import type { RepositoryCardData } from "../../validation"; 7 9 8 10 export function RepositoryCard(data: RepositoryCardData) { ··· 12 14 13 15 <Col style={{ gap: 64 }}> 14 16 <Col style={{ gap: 24 }}> 15 - <span 16 - style={{ 17 - fontFamily: "Inter", 18 - fontSize: 144, 19 - fontWeight: 600, 20 - color: "#000000", 21 - }}> 17 + <span style={{ ...TYPOGRAPHY.repoName, color: "#000000" }}> 22 18 {data.repoName} 23 19 </span> 24 20 25 21 <Row style={{ gap: 16 }}> 26 22 <Avatar src={data.avatarUrl} size={64} /> 27 - <span 28 - style={{ 29 - fontFamily: "Inter", 30 - fontSize: 48, 31 - fontWeight: 500, 32 - color: "#000000", 33 - }}> 23 + <span style={{ ...TYPOGRAPHY.ownerHandle, color: "#000000" }}> 34 24 {data.ownerHandle} 35 25 </span> 36 26 </Row> ··· 45 35 justifyContent: "space-between", 46 36 flexGrow: 1, 47 37 }}> 48 - <div 49 - style={{ 50 - fontFamily: "Inter", 51 - fontSize: 32, 52 - color: "#7D7D7D", 53 - }}> 54 - {data.updatedAt} 55 - </div> 38 + <FooterStats createdAt={data.createdAt} /> 56 39 57 40 <TangledLogo /> 58 41 </Row> 59 42 </Card> 60 43 ); 61 - } 44 + }
+4 -3
appview/ogcard/src/components/shared/avatar.tsx
··· 4 4 } 5 5 6 6 export function Avatar({ src, size = 64 }: AvatarProps) { 7 - const avatarSrc = src.includes("avatar.tangled.sh") && !src.includes("format=") 8 - ? `${src}${src.includes("?") ? "&" : "?"}format=jpeg` 9 - : src; 7 + const avatarSrc = 8 + src.includes("avatar.tangled.sh") && !src.includes("format=") 9 + ? `${src}${src.includes("?") ? "&" : "?"}format=jpeg` 10 + : src; 10 11 11 12 return ( 12 13 <div
+8 -9
appview/ogcard/src/components/shared/card-header.tsx
··· 1 1 import { Row } from "./layout"; 2 2 import { Avatar } from "./avatar"; 3 + import { TYPOGRAPHY } from "./constants"; 3 4 4 5 interface CardHeaderProps { 5 6 avatarUrl: string; ··· 7 8 repoName: string; 8 9 } 9 10 10 - export function CardHeader({ avatarUrl, ownerHandle, repoName }: CardHeaderProps) { 11 + export function CardHeader({ 12 + avatarUrl, 13 + ownerHandle, 14 + repoName, 15 + }: CardHeaderProps) { 11 16 return ( 12 17 <Row style={{ gap: 16 }}> 13 18 <Avatar src={avatarUrl} size={64} /> 14 - <span 15 - style={{ 16 - fontFamily: "Inter", 17 - fontSize: 48, 18 - color: "#000000", 19 - fontWeight: 500, 20 - }}> 19 + <span style={{ ...TYPOGRAPHY.cardHeader, color: "#000000" }}> 21 20 {ownerHandle} / {repoName} 22 21 </span> 23 22 </Row> 24 23 ); 25 - } 24 + }
+10 -6
appview/ogcard/src/components/shared/constants.ts
··· 18 18 } as const; 19 19 20 20 export const TYPOGRAPHY = { 21 - title: { fontSize: 64, fontWeight: 600 }, 22 - repoName: { fontSize: 144, fontWeight: 600 }, 23 - ownerHandle: { fontSize: 48, fontWeight: 500 }, 24 - body: { fontSize: 36, fontWeight: 400 }, 25 - meta: { fontSize: 32, fontWeight: 400 }, 26 - } as const; 21 + title: { fontFamily: "Inter", fontSize: 64, fontWeight: 600 }, 22 + repoName: { fontFamily: "Inter", fontSize: 144, fontWeight: 600 }, 23 + ownerHandle: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 24 + cardHeader: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 25 + status: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 26 + metricValue: { fontFamily: "Inter", fontSize: 48, fontWeight: 500 }, 27 + body: { fontFamily: "Inter", fontSize: 36, fontWeight: 400 }, 28 + meta: { fontFamily: "Inter", fontSize: 32, fontWeight: 400 }, 29 + label: { fontFamily: "Inter", fontSize: 24, fontWeight: 400 }, 30 + } as const;
+16 -6
appview/ogcard/src/components/shared/footer-stats.tsx
··· 4 4 5 5 interface FooterStatsProps { 6 6 createdAt: string; 7 - reactionCount: number; 8 - commentCount: number; 7 + reactionCount?: number; 8 + commentCount?: number; 9 9 } 10 10 11 11 export function FooterStats({ ··· 13 13 reactionCount, 14 14 commentCount, 15 15 }: FooterStatsProps) { 16 + const formattedDate = new Intl.DateTimeFormat("en-GB", { 17 + day: "numeric", 18 + month: "short", 19 + year: "numeric", 20 + }).format(new Date(createdAt)); 21 + 16 22 return ( 17 23 <Row style={{ gap: 64 }}> 18 - <StatItem Icon={Calendar} value={createdAt} /> 19 - <StatItem Icon={SmilePlus} value={reactionCount} /> 20 - <StatItem Icon={MessageSquare} value={commentCount} /> 24 + <StatItem Icon={Calendar} value={formattedDate} /> 25 + {reactionCount ? ( 26 + <StatItem Icon={SmilePlus} value={reactionCount} /> 27 + ) : null} 28 + {commentCount ? ( 29 + <StatItem Icon={MessageSquare} value={commentCount} /> 30 + ) : null} 21 31 </Row> 22 32 ); 23 - } 33 + }
+4 -10
appview/ogcard/src/components/shared/label-pill.tsx
··· 1 1 import { Row } from "./layout"; 2 - import { COLORS } from "./constants"; 2 + import { COLORS, TYPOGRAPHY } from "./constants"; 3 3 4 4 interface LabelPillProps { 5 5 name: string; 6 6 color: string; 7 7 } 8 8 9 - export function LabelPill({ name, color }: LabelPillProps) { 9 + function LabelPill({ name, color }: LabelPillProps) { 10 10 return ( 11 11 <Row 12 12 style={{ ··· 24 24 backgroundColor: color, 25 25 }} 26 26 /> 27 - <span 28 - style={{ 29 - fontFamily: "Inter", 30 - fontSize: 36, 31 - color: COLORS.label.text, 32 - fontWeight: 400, 33 - }}> 27 + <span style={{ ...TYPOGRAPHY.body, color: COLORS.label.text }}> 34 28 {name} 35 29 </span> 36 30 </Row> ··· 52 46 ))} 53 47 </Row> 54 48 ); 55 - } 49 + }
+23 -18
appview/ogcard/src/components/shared/language-circles.tsx
··· 5 5 } 6 6 7 7 const MAX_RADIUS = 380; 8 - const MIN_RADIUS = 85; 9 - const RADIUS_STEP = (MAX_RADIUS - MIN_RADIUS) / 4; 10 8 11 - function languageToRadius(percentage: number, maxRadius: number = MAX_RADIUS): number { 12 - const minRadius = MIN_RADIUS; 13 - const scaledRadius = minRadius + (percentage / 100) * (maxRadius - minRadius); 14 - return Math.max(minRadius, Math.min(maxRadius, scaledRadius)); 9 + function percentageToThickness(percentage: number): number { 10 + return (percentage / 100) * MAX_RADIUS; 15 11 } 16 12 17 13 export function LanguageCircles({ languages }: LanguageCirclesProps) { 18 14 const sortedLanguages = [...languages] 19 15 .sort((a, b) => b.percentage - a.percentage) 20 - .slice(0, 5); 16 + .slice(0, 5) 17 + .reverse(); 18 + 19 + let cumulativeRadius = 0; 21 20 22 21 return ( 23 22 <div 24 23 style={{ 25 24 position: "absolute", 26 - right: 0, 27 - top: 0, 28 - width: 0, 29 - height: 0, 25 + right: -MAX_RADIUS, 26 + top: -MAX_RADIUS, 27 + width: MAX_RADIUS * 2, 28 + height: MAX_RADIUS * 2, 30 29 display: "flex", 31 30 }}> 32 31 {sortedLanguages.map((lang, i) => { 33 - const r = languageToRadius(lang.percentage); 32 + const thickness = percentageToThickness(lang.percentage); 33 + const contentSize = cumulativeRadius * 2; 34 + 35 + cumulativeRadius += thickness; 36 + 34 37 return ( 35 38 <div 36 39 key={i} 37 40 style={{ 38 41 position: "absolute", 39 - right: -r, 40 - top: -r, 41 - width: r * 2, 42 - height: r * 2, 42 + left: "50%", 43 + top: "50%", 44 + transform: "translate(-50%, -50%)", 45 + width: contentSize, 46 + height: contentSize, 43 47 borderRadius: "50%", 44 - background: lang.color, 48 + border: `${thickness}px solid ${lang.color}`, 49 + boxSizing: "content-box", 45 50 }} 46 51 /> 47 52 ); 48 53 })} 49 54 </div> 50 55 ); 51 - } 56 + }
appview/ogcard/src/components/shared/layout.tsx

This file has not been changed.

appview/ogcard/src/components/shared/logo.tsx

This file has not been changed.

+3 -2
appview/ogcard/src/components/shared/metrics.tsx
··· 1 1 import { Row, Col } from "./layout"; 2 + import { TYPOGRAPHY } from "./constants"; 2 3 import { 3 4 Star, 4 5 GitPullRequest, ··· 33 34 return ( 34 35 <Col style={{ gap: 12 }}> 35 36 <Row style={{ gap: 12, alignItems: "center" }}> 36 - <span style={{ fontSize: 48, fontWeight: 500 }}>{value}</span> 37 + <span style={TYPOGRAPHY.metricValue}>{value}</span> 37 38 <Icon size={48} /> 38 39 </Row> 39 - <span style={{ fontSize: 24, opacity: 0.75 }}>{label}</span> 40 + <span style={{ ...TYPOGRAPHY.label, opacity: 0.75 }}>{label}</span> 40 41 </Col> 41 42 ); 42 43 }
+3 -9
appview/ogcard/src/components/shared/stat-item.tsx
··· 1 1 import { Row } from "./layout"; 2 + import { TYPOGRAPHY } from "./constants"; 2 3 import type { LucideIcon } from "../../icons/lucide"; 3 4 4 5 interface StatItemProps { ··· 10 11 return ( 11 12 <Row style={{ gap: 16 }}> 12 13 <Icon size={36} color="#404040" /> 13 - <span 14 - style={{ 15 - fontFamily: "Inter", 16 - fontSize: 36, 17 - color: "#404040", 18 - }}> 19 - {value} 20 - </span> 14 + <span style={{ ...TYPOGRAPHY.body, color: "#404040" }}>{value}</span> 21 15 </Row> 22 16 ); 23 - } 17 + }
+4 -20
appview/ogcard/src/components/shared/status-badge.tsx
··· 6 6 GitPullRequestClosed, 7 7 GitMerge, 8 8 } from "../../icons/lucide"; 9 - import { COLORS } from "./constants"; 9 + import { COLORS, TYPOGRAPHY } from "./constants"; 10 10 11 11 const STATUS_CONFIG = { 12 12 open: { ··· 48 48 backgroundColor: config.bg, 49 49 }}> 50 50 <Icon size={48} color={config.text} /> 51 - <span 52 - style={{ 53 - fontFamily: "Inter", 54 - fontSize: 48, 55 - color: config.text, 56 - fontWeight: 500, 57 - }}> 58 - {status} 59 - </span> 51 + <span style={{ ...TYPOGRAPHY.status, color: config.text }}>{status}</span> 60 52 </Row> 61 53 ); 62 54 } ··· 75 67 backgroundColor: config.bg, 76 68 }}> 77 69 <Icon size={48} color={config.text} /> 78 - <span 79 - style={{ 80 - fontFamily: "Inter", 81 - fontSize: 48, 82 - color: config.text, 83 - fontWeight: 500, 84 - }}> 85 - {status} 86 - </span> 70 + <span style={{ ...TYPOGRAPHY.status, color: config.text }}>{status}</span> 87 71 </Row> 88 72 ); 89 - } 73 + }
+6 -4
appview/ogcard/src/icons/lucide.tsx
··· 13 13 const nodes = (iconNodes as unknown as Record<string, IconNodeEntry[]>)[name]; 14 14 if (!nodes) throw new Error(`Icon "${name}" not found`); 15 15 16 - return function Icon({ size = 24, color = "currentColor", strokeWidth = 2 }: IconProps = {}) { 16 + return function Icon({ 17 + size = 24, 18 + color = "currentColor", 19 + strokeWidth = 2, 20 + }: IconProps = {}) { 17 21 return h( 18 22 "svg", 19 23 { ··· 40 44 export const Calendar = createIcon("calendar"); 41 45 export const MessageSquare = createIcon("message-square"); 42 46 export const MessageSquareCode = createIcon("message-square-code"); 43 - export const ThumbsUp = createIcon("thumbs-up"); 44 - export const ThumbsDown = createIcon("thumbs-down"); 45 47 export const Ban = createIcon("ban"); 46 48 export const SmilePlus = createIcon("smile-plus"); 47 49 export const FileDiff = createIcon("file-diff"); 48 50 export const RefreshCw = createIcon("refresh-cw"); 49 51 50 - export type LucideIcon = typeof Star; 52 + export type LucideIcon = typeof Star;
+13 -9
appview/ogcard/src/index.tsx
··· 77 77 } 78 78 79 79 console.error("Error generating card:", error); 80 - const errorMessage = error instanceof Error ? error.message : String(error); 80 + const errorMessage = 81 + error instanceof Error ? error.message : String(error); 81 82 const errorStack = error instanceof Error ? error.stack : ""; 82 83 console.error("Error stack:", errorStack); 83 - return new Response(JSON.stringify({ 84 - error: errorMessage, 85 - stack: errorStack 86 - }), { 87 - status: 500, 88 - headers: { "Content-Type": "application/json" } 89 - }); 84 + return new Response( 85 + JSON.stringify({ 86 + error: errorMessage, 87 + stack: errorStack, 88 + }), 89 + { 90 + status: 500, 91 + headers: { "Content-Type": "application/json" }, 92 + }, 93 + ); 90 94 } 91 95 }, 92 - }; 96 + };
-35
appview/ogcard/src/lib/fonts.ts
··· 1 - export interface FontData { 2 - name: string; 3 - data: ArrayBuffer; 4 - weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; 5 - style: "normal" | "italic"; 6 - } 7 - 8 - const FONT_URLS = { 9 - regular: 10 - "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-400-normal.woff", 11 - medium: 12 - "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-500-normal.woff", 13 - semiBold: 14 - "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-600-normal.woff", 15 - }; 16 - 17 - export async function loadFonts(): Promise<FontData[]> { 18 - const [regular, medium, semiBold] = await Promise.all([ 19 - fetchFont(FONT_URLS.regular), 20 - fetchFont(FONT_URLS.medium), 21 - fetchFont(FONT_URLS.semiBold), 22 - ]); 23 - 24 - return [ 25 - { name: "Inter", data: regular, weight: 400, style: "normal" }, 26 - { name: "Inter", data: medium, weight: 500, style: "normal" }, 27 - { name: "Inter", data: semiBold, weight: 600, style: "normal" }, 28 - ]; 29 - } 30 - 31 - async function fetchFont(url: string): Promise<ArrayBuffer> { 32 - const response = await fetch(url); 33 - if (!response.ok) throw new Error(`Failed to load font: ${url}`); 34 - return response.arrayBuffer(); 35 - }
+21 -47
appview/ogcard/src/lib/render.ts
··· 1 - import satori, { init } from "satori/standalone"; 2 - import { loadFonts } from "./fonts"; 3 1 import type { VNode } from "preact"; 2 + import { initSatori, initResvg, loadFonts } from "@tangled/ogcard-runtime"; 3 + import type { ResvgClass } from "@tangled/ogcard-runtime/types"; 4 4 5 - const isCloudflareWorkers = typeof caches !== "undefined" && "default" in caches; 6 - 7 - let satoriInitialized = false; 8 - let resvgInitialized = false; 9 - 10 - let Resvg: any; 11 - let initWasm: any; 12 - let wasmModule: any; 13 - 14 - async function initSatori() { 15 - if (satoriInitialized) return; 16 - 17 - const yogaWasm = await fetch("https://unpkg.com/satori@0.25.0/yoga.wasm"); 18 - const wasmBuffer = await yogaWasm.arrayBuffer(); 19 - await init(wasmBuffer); 20 - 21 - satoriInitialized = true; 22 - } 23 - 24 - async function initResvg() { 25 - if (resvgInitialized) return; 26 - 27 - if (isCloudflareWorkers) { 28 - const resvgWasm = await import("@resvg/resvg-wasm"); 29 - Resvg = resvgWasm.Resvg; 30 - initWasm = resvgWasm.initWasm; 31 - 32 - // @ts-ignore 33 - wasmModule = (await import("@resvg/resvg-wasm/index_bg.wasm?module")).default; 34 - 35 - await initWasm(wasmModule); 36 - } else { 37 - const resvgJs = await import("@resvg/resvg-js"); 38 - Resvg = resvgJs.Resvg; 39 - } 40 - 41 - resvgInitialized = true; 42 - } 5 + let satoriFn: typeof import("satori").default | null = null; 6 + let Resvg: ResvgClass | null = null; 7 + let fontsLoaded = false; 8 + let cachedFonts: Awaited<ReturnType<typeof loadFonts>> | null = null; 43 9 44 10 export interface RenderResult { 45 11 svg: string; ··· 47 13 } 48 14 49 15 export async function renderCard(component: VNode): Promise<RenderResult> { 50 - await initSatori(); 51 - await initResvg(); 16 + if (!satoriFn) { 17 + satoriFn = await initSatori(); 18 + } 52 19 53 - const fonts = await loadFonts(); 20 + if (!Resvg) { 21 + Resvg = await initResvg(); 22 + } 23 + 24 + if (!fontsLoaded) { 25 + cachedFonts = await loadFonts(); 26 + fontsLoaded = true; 27 + } 54 28 55 - const svg = await satori(component as any, { 29 + const svg = await satoriFn(component as any, { 56 30 width: 1200, 57 31 height: 630, 58 - fonts, 32 + fonts: cachedFonts!, 59 33 embedFont: true, 60 34 }); 61 35 62 - const resvg = new Resvg(svg, { 36 + const resvg = new Resvg!(svg, { 63 37 fitTo: { mode: "width", value: 1200 }, 64 38 }); 65 39 ··· 69 43 svg, 70 44 png: pngData.asPng(), 71 45 }; 72 - } 46 + }
+9 -6
appview/ogcard/src/validation.ts
··· 14 14 stars: z.number().int().min(0).max(1000000), 15 15 pulls: z.number().int().min(0).max(100000), 16 16 issues: z.number().int().min(0).max(100000), 17 - updatedAt: z.string().max(100), 17 + createdAt: z.string().max(100), 18 18 avatarUrl: z.string().url(), 19 19 languages: z.array(languageSchema).max(5), 20 20 }); ··· 27 27 title: z.string().min(1).max(500), 28 28 issueNumber: z.number().int().positive(), 29 29 status: z.enum(["open", "closed"]), 30 - labels: z.array(z.object({ 31 - name: z.string().max(50), 32 - color: z.string().regex(hexColor), 33 - })).max(10), 30 + labels: z 31 + .array( 32 + z.object({ 33 + name: z.string().max(50), 34 + color: z.string().regex(hexColor), 35 + }), 36 + ) 37 + .max(10), 34 38 commentCount: z.number().int().min(0), 35 39 reactionCount: z.number().int().min(0), 36 40 createdAt: z.string(), ··· 64 68 export type RepositoryCardData = z.infer<typeof repositoryCardSchema>; 65 69 export type IssueCardData = z.infer<typeof issueCardSchema>; 66 70 export type PullRequestCardData = z.infer<typeof pullRequestCardSchema>; 67 - export type CardPayload = z.infer<typeof cardPayloadSchema>;
+1 -1
appview/ogcard/tsconfig.json
··· 4 4 "module": "ES2022", 5 5 "lib": ["ES2022"], 6 6 "moduleResolution": "bundler", 7 - "types": ["@cloudflare/workers-types", "node"], 7 + "types": ["@cloudflare/workers-types", "node", "bun"], 8 8 "jsx": "react-jsx", 9 9 "jsxImportSource": "preact", 10 10 "strict": true,
+11 -3
appview/ogcard/wrangler.jsonc
··· 1 1 { 2 + "$schema": "node_modules/wrangler/config-schema.json", 2 3 "name": "tangled-ogcard-worker", 3 4 "main": "src/index.tsx", 4 5 "compatibility_date": "2026-03-07", 5 - "compatibility_flags": ["nodejs_compat"], 6 - "account_id": "<YOUR_ACCOUNT_ID>", 7 - "workers_dev": true, 6 + "observability": { 7 + "enabled": true, 8 + }, 8 9 "routes": [ 9 10 { 10 11 "pattern": "og.tangled.org/*", ··· 14 15 "vars": { 15 16 "ENVIRONMENT": "production", 16 17 }, 18 + "rules": [ 19 + { 20 + "type": "Data", 21 + "globs": ["**/*.woff"], 22 + "fallthrough": true, 23 + }, 24 + ], 17 25 }
appview/pulls/opengraph.go

This file has not been changed.

+82 -16
appview/pulls/pulls.go
··· 414 414 return nil 415 415 } 416 416 417 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 418 - resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String()) 417 + scheme := "http" 418 + if !s.config.Core.Dev { 419 + scheme = "https" 420 + } 421 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 422 + xrpcc := &indigoxrpc.Client{ 423 + Host: host, 424 + } 425 + 426 + resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 419 427 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 420 428 return nil 421 429 } ··· 431 439 return pages.Unknown 432 440 } 433 441 434 - var sourceRepo syntax.ATURI 442 + var knot, ownerDid, repoName string 443 + 435 444 if pull.PullSource.RepoAt != nil { 436 445 // fork-based pulls 437 - sourceRepo = *pull.PullSource.RepoAt 446 + sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 447 + if err != nil { 448 + log.Println("failed to get source repo", err) 449 + return pages.Unknown 450 + } 451 + 452 + knot = sourceRepo.Knot 453 + ownerDid = sourceRepo.Did 454 + repoName = sourceRepo.Name 438 455 } else { 439 456 // pulls within the same repo 440 - sourceRepo = repo.RepoAt() 457 + knot = repo.Knot 458 + ownerDid = repo.Did 459 + repoName = repo.Name 441 460 } 442 461 443 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 444 - branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepo.String()) 462 + scheme := "http" 463 + if !s.config.Core.Dev { 464 + scheme = "https" 465 + } 466 + host := fmt.Sprintf("%s://%s", scheme, knot) 467 + xrpcc := &indigoxrpc.Client{ 468 + Host: host, 469 + } 470 + 471 + didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 472 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 445 473 if err != nil { 446 474 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 447 475 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 879 907 880 908 switch r.Method { 881 909 case http.MethodGet: 882 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 910 + scheme := "http" 911 + if !s.config.Core.Dev { 912 + scheme = "https" 913 + } 914 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 915 + xrpcc := &indigoxrpc.Client{ 916 + Host: host, 917 + } 883 918 884 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 919 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 920 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 885 921 if err != nil { 886 922 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 887 923 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 1502 1538 return 1503 1539 } 1504 1540 1505 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1541 + scheme := "http" 1542 + if !s.config.Core.Dev { 1543 + scheme = "https" 1544 + } 1545 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1546 + xrpcc := &indigoxrpc.Client{ 1547 + Host: host, 1548 + } 1506 1549 1507 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1550 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1551 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1508 1552 if err != nil { 1553 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1554 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1555 + s.pages.Error503(w) 1556 + return 1557 + } 1509 1558 log.Println("failed to fetch branches", err) 1510 - s.pages.Error503(w) 1511 1559 return 1512 1560 } 1513 1561 ··· 1562 1610 return 1563 1611 } 1564 1612 1565 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1566 - 1567 1613 forkVal := r.URL.Query().Get("fork") 1568 1614 repoString := strings.SplitN(forkVal, "/", 2) 1569 1615 forkOwnerDid := repoString[0] ··· 1579 1625 return 1580 1626 } 1581 1627 1582 - sourceXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, repo.RepoAt().String()) 1628 + sourceScheme := "http" 1629 + if !s.config.Core.Dev { 1630 + sourceScheme = "https" 1631 + } 1632 + sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1633 + sourceXrpcc := &indigoxrpc.Client{ 1634 + Host: sourceHost, 1635 + } 1636 + 1637 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1638 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1583 1639 if err != nil { 1584 1640 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1585 1641 log.Println("failed to call XRPC repo.branches for source", xrpcerr) ··· 1598 1654 return 1599 1655 } 1600 1656 1601 - targetXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1657 + targetScheme := "http" 1658 + if !s.config.Core.Dev { 1659 + targetScheme = "https" 1660 + } 1661 + targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1662 + targetXrpcc := &indigoxrpc.Client{ 1663 + Host: targetHost, 1664 + } 1665 + 1666 + targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1667 + targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1602 1668 if err != nil { 1603 1669 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1604 1670 log.Println("failed to call XRPC repo.branches for target", xrpcerr)
+1 -3
appview/repo/opengraph.go
··· 80 80 }) 81 81 } 82 82 83 - updatedAt := f.Created 84 - 85 83 payload := ogcard.RepositoryCardPayload{ 86 84 Type: "repository", 87 85 RepoName: f.Name, ··· 89 87 Stars: f.RepoStats.StarCount, 90 88 Pulls: f.RepoStats.PullCount.Open, 91 89 Issues: f.RepoStats.IssueCount.Open, 92 - UpdatedAt: updatedAt.Format(time.RFC3339), 90 + CreatedAt: f.Created.Format(time.RFC3339), 93 91 AvatarUrl: avatarUrl, 94 92 Languages: ogLanguages, 95 93 }
+2 -2
appview/repo/repo.go
··· 33 33 "tangled.org/core/xrpc/serviceauth" 34 34 35 35 comatproto "github.com/bluesky-social/indigo/api/atproto" 36 - "github.com/bluesky-social/indigo/atproto/atclient" 36 + atpclient "github.com/bluesky-social/indigo/atproto/client" 37 37 "github.com/bluesky-social/indigo/atproto/syntax" 38 38 lexutil "github.com/bluesky-social/indigo/lex/util" 39 39 securejoin "github.com/cyphar/filepath-securejoin" ··· 1207 1207 // this is used to rollback changes made to the PDS 1208 1208 // 1209 1209 // it is a no-op if the provided ATURI is empty 1210 - func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 1210 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1211 1211 if aturi == "" { 1212 1212 return nil 1213 1213 }
avatar/src/index.js

This file has not been changed.

-2
.gitignore
··· 22 22 genjwks.out 23 23 /nix/vm-data 24 24 blog/build/ 25 - build/ 26 - .wrangler/
+4 -4
.tangled/workflows/deploy-blog.yml
··· 16 16 mkdir -p appview/pages/static 17 17 touch appview/pages/static/x 18 18 19 - - name: generate css 20 - command: | 21 - tailwindcss -i input.css -o appview/pages/static/tw.css 22 - 23 19 - name: build blog cmd 24 20 command: | 25 21 go build -o blog.out ./cmd/blog 22 + 23 + - name: generate css 24 + command: | 25 + tailwindcss -i input.css -o appview/pages/static/tw.css 26 26 27 27 - name: build static site 28 28 command: |
-50
api/tangled/tempanalyzeMerge.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.analyzeMerge 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - GitTempAnalyzeMergeNSID = "sh.tangled.git.temp.analyzeMerge" 15 - ) 16 - 17 - // GitTempAnalyzeMerge_ConflictInfo is a "conflictInfo" in the sh.tangled.git.temp.analyzeMerge schema. 18 - type GitTempAnalyzeMerge_ConflictInfo struct { 19 - // filename: Name of the conflicted file 20 - Filename string `json:"filename" cborgen:"filename"` 21 - // reason: Reason for the conflict 22 - Reason string `json:"reason" cborgen:"reason"` 23 - } 24 - 25 - // GitTempAnalyzeMerge_Output is the output of a sh.tangled.git.temp.analyzeMerge call. 26 - type GitTempAnalyzeMerge_Output struct { 27 - // conflicts: List of files with merge conflicts 28 - Conflicts []*GitTempAnalyzeMerge_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 29 - // is_conflicted: Whether the merge has conflicts 30 - Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 31 - } 32 - 33 - // GitTempAnalyzeMerge calls the XRPC method "sh.tangled.git.temp.analyzeMerge". 34 - // 35 - // branch: Target branch to merge into 36 - // patch: Patch or pull request to check for merge conflicts 37 - // repo: AT-URI of the repository 38 - func GitTempAnalyzeMerge(ctx context.Context, c util.LexClient, branch string, patch string, repo string) (*GitTempAnalyzeMerge_Output, error) { 39 - var out GitTempAnalyzeMerge_Output 40 - 41 - params := map[string]interface{}{} 42 - params["branch"] = branch 43 - params["patch"] = patch 44 - params["repo"] = repo 45 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.analyzeMerge", params, nil, &out); err != nil { 46 - return nil, err 47 - } 48 - 49 - return &out, nil 50 - }
-71
api/tangled/tempdefs.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.defs 6 - 7 - import ( 8 - "github.com/bluesky-social/indigo/lex/util" 9 - ) 10 - 11 - const () 12 - 13 - // GitTempDefs_Blob is a "blob" in the sh.tangled.git.temp.defs schema. 14 - // 15 - // blob metadata. This object doesn't include the blob content 16 - type GitTempDefs_Blob struct { 17 - LastCommit *GitTempDefs_Commit `json:"lastCommit" cborgen:"lastCommit"` 18 - Mode string `json:"mode" cborgen:"mode"` 19 - // name: The file name 20 - Name string `json:"name" cborgen:"name"` 21 - // size: File size in bytes 22 - Size int64 `json:"size" cborgen:"size"` 23 - // submodule: Submodule information if path is a submodule 24 - Submodule *GitTempDefs_Submodule `json:"submodule,omitempty" cborgen:"submodule,omitempty"` 25 - } 26 - 27 - // GitTempDefs_Branch is a "branch" in the sh.tangled.git.temp.defs schema. 28 - type GitTempDefs_Branch struct { 29 - // commit: hydrated commit object 30 - Commit *GitTempDefs_Commit `json:"commit" cborgen:"commit"` 31 - // name: branch name 32 - Name string `json:"name" cborgen:"name"` 33 - } 34 - 35 - // GitTempDefs_Commit is a "commit" in the sh.tangled.git.temp.defs schema. 36 - type GitTempDefs_Commit struct { 37 - Author *GitTempDefs_Signature `json:"author" cborgen:"author"` 38 - Committer *GitTempDefs_Signature `json:"committer" cborgen:"committer"` 39 - Hash *string `json:"hash" cborgen:"hash"` 40 - Message string `json:"message" cborgen:"message"` 41 - Tree *string `json:"tree" cborgen:"tree"` 42 - } 43 - 44 - // GitTempDefs_Signature is a "signature" in the sh.tangled.git.temp.defs schema. 45 - type GitTempDefs_Signature struct { 46 - // email: Person email 47 - Email string `json:"email" cborgen:"email"` 48 - // name: Person name 49 - Name string `json:"name" cborgen:"name"` 50 - // when: Timestamp of the signature 51 - When string `json:"when" cborgen:"when"` 52 - } 53 - 54 - // GitTempDefs_Submodule is a "submodule" in the sh.tangled.git.temp.defs schema. 55 - type GitTempDefs_Submodule struct { 56 - // branch: Branch to track in the submodule 57 - Branch *string `json:"branch,omitempty" cborgen:"branch,omitempty"` 58 - // name: Submodule name 59 - Name string `json:"name" cborgen:"name"` 60 - // url: Submodule repository URL 61 - Url string `json:"url" cborgen:"url"` 62 - } 63 - 64 - // GitTempDefs_Tag is a "tag" in the sh.tangled.git.temp.defs schema. 65 - type GitTempDefs_Tag struct { 66 - Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 67 - // name: tag name 68 - Name string `json:"name" cborgen:"name"` 69 - Tagger *GitTempDefs_Signature `json:"tagger" cborgen:"tagger"` 70 - Target *util.LexiconTypeDecoder `json:"target" cborgen:"target"` 71 - }
-41
api/tangled/tempgetArchive.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getArchive 6 - 7 - import ( 8 - "bytes" 9 - "context" 10 - 11 - "github.com/bluesky-social/indigo/lex/util" 12 - ) 13 - 14 - const ( 15 - GitTempGetArchiveNSID = "sh.tangled.git.temp.getArchive" 16 - ) 17 - 18 - // GitTempGetArchive calls the XRPC method "sh.tangled.git.temp.getArchive". 19 - // 20 - // format: Archive format 21 - // prefix: Prefix for files in the archive 22 - // ref: Git reference (branch, tag, or commit SHA) 23 - // repo: AT-URI of the repository 24 - func GitTempGetArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) { 25 - buf := new(bytes.Buffer) 26 - 27 - params := map[string]interface{}{} 28 - if format != "" { 29 - params["format"] = format 30 - } 31 - if prefix != "" { 32 - params["prefix"] = prefix 33 - } 34 - params["ref"] = ref 35 - params["repo"] = repo 36 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getArchive", params, nil, buf); err != nil { 37 - return nil, err 38 - } 39 - 40 - return buf.Bytes(), nil 41 - }
-37
api/tangled/tempgetBlob.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getBlob 6 - 7 - import ( 8 - "bytes" 9 - "context" 10 - 11 - "github.com/bluesky-social/indigo/lex/util" 12 - ) 13 - 14 - const ( 15 - GitTempGetBlobNSID = "sh.tangled.git.temp.getBlob" 16 - ) 17 - 18 - // GitTempGetBlob calls the XRPC method "sh.tangled.git.temp.getBlob". 19 - // 20 - // path: Path within the repository tree 21 - // ref: Git reference (branch, tag, or commit SHA) 22 - // repo: AT-URI of the repository 23 - func GitTempGetBlob(ctx context.Context, c util.LexClient, path string, ref string, repo string) ([]byte, error) { 24 - buf := new(bytes.Buffer) 25 - 26 - params := map[string]interface{}{} 27 - params["path"] = path 28 - if ref != "" { 29 - params["ref"] = ref 30 - } 31 - params["repo"] = repo 32 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getBlob", params, nil, buf); err != nil { 33 - return nil, err 34 - } 35 - 36 - return buf.Bytes(), nil 37 - }
-45
api/tangled/tempgetBranch.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getBranch 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - GitTempGetBranchNSID = "sh.tangled.git.temp.getBranch" 15 - ) 16 - 17 - // GitTempGetBranch_Output is the output of a sh.tangled.git.temp.getBranch call. 18 - type GitTempGetBranch_Output struct { 19 - Author *GitTempDefs_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 - // hash: Latest commit hash on this branch 21 - Hash string `json:"hash" cborgen:"hash"` 22 - // message: Latest commit message 23 - Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 24 - // name: Branch name 25 - Name string `json:"name" cborgen:"name"` 26 - // when: Timestamp of latest commit 27 - When string `json:"when" cborgen:"when"` 28 - } 29 - 30 - // GitTempGetBranch calls the XRPC method "sh.tangled.git.temp.getBranch". 31 - // 32 - // name: Branch name to get information for 33 - // repo: AT-URI of the repository 34 - func GitTempGetBranch(ctx context.Context, c util.LexClient, name string, repo string) (*GitTempGetBranch_Output, error) { 35 - var out GitTempGetBranch_Output 36 - 37 - params := map[string]interface{}{} 38 - params["name"] = name 39 - params["repo"] = repo 40 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getBranch", params, nil, &out); err != nil { 41 - return nil, err 42 - } 43 - 44 - return &out, nil 45 - }
-32
api/tangled/tempgetCommit.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getCommit 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - GitTempGetCommitNSID = "sh.tangled.git.temp.getCommit" 15 - ) 16 - 17 - // GitTempGetCommit calls the XRPC method "sh.tangled.git.temp.getCommit". 18 - // 19 - // ref: reference name to resolve 20 - // repo: AT-URI of the repository 21 - func GitTempGetCommit(ctx context.Context, c util.LexClient, ref string, repo string) (*GitTempDefs_Commit, error) { 22 - var out GitTempDefs_Commit 23 - 24 - params := map[string]interface{}{} 25 - params["ref"] = ref 26 - params["repo"] = repo 27 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getCommit", params, nil, &out); err != nil { 28 - return nil, err 29 - } 30 - 31 - return &out, nil 32 - }
-35
api/tangled/tempgetDiff.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getDiff 6 - 7 - import ( 8 - "bytes" 9 - "context" 10 - 11 - "github.com/bluesky-social/indigo/lex/util" 12 - ) 13 - 14 - const ( 15 - GitTempGetDiffNSID = "sh.tangled.git.temp.getDiff" 16 - ) 17 - 18 - // GitTempGetDiff calls the XRPC method "sh.tangled.git.temp.getDiff". 19 - // 20 - // repo: AT-URI of the repository 21 - // rev1: First revision (commit, branch, or tag) 22 - // rev2: Second revision (commit, branch, or tag) 23 - func GitTempGetDiff(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) { 24 - buf := new(bytes.Buffer) 25 - 26 - params := map[string]interface{}{} 27 - params["repo"] = repo 28 - params["rev1"] = rev1 29 - params["rev2"] = rev2 30 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getDiff", params, nil, buf); err != nil { 31 - return nil, err 32 - } 33 - 34 - return buf.Bytes(), nil 35 - }
-36
api/tangled/tempgetEntity.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getEntity 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - GitTempGetEntityNSID = "sh.tangled.git.temp.getEntity" 15 - ) 16 - 17 - // GitTempGetEntity calls the XRPC method "sh.tangled.git.temp.getEntity". 18 - // 19 - // path: path of the entity 20 - // ref: Git reference (branch, tag, or commit SHA) 21 - // repo: AT-URI of the repository 22 - func GitTempGetEntity(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*GitTempDefs_Blob, error) { 23 - var out GitTempDefs_Blob 24 - 25 - params := map[string]interface{}{} 26 - params["path"] = path 27 - if ref != "" { 28 - params["ref"] = ref 29 - } 30 - params["repo"] = repo 31 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getEntity", params, nil, &out); err != nil { 32 - return nil, err 33 - } 34 - 35 - return &out, nil 36 - }
-30
api/tangled/tempgetHead.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getHead 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - GitTempGetHeadNSID = "sh.tangled.git.temp.getHead" 15 - ) 16 - 17 - // GitTempGetHead calls the XRPC method "sh.tangled.git.temp.getHead". 18 - // 19 - // repo: AT-URI of the repository 20 - func GitTempGetHead(ctx context.Context, c util.LexClient, repo string) (*GitTempDefs_Branch, error) { 21 - var out GitTempDefs_Branch 22 - 23 - params := map[string]interface{}{} 24 - params["repo"] = repo 25 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getHead", params, nil, &out); err != nil { 26 - return nil, err 27 - } 28 - 29 - return &out, nil 30 - }
-33
api/tangled/tempgetTag.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getTag 6 - 7 - import ( 8 - "bytes" 9 - "context" 10 - 11 - "github.com/bluesky-social/indigo/lex/util" 12 - ) 13 - 14 - const ( 15 - GitTempGetTagNSID = "sh.tangled.git.temp.getTag" 16 - ) 17 - 18 - // GitTempGetTag calls the XRPC method "sh.tangled.git.temp.getTag". 19 - // 20 - // repo: AT-URI of the repository 21 - // tag: Name of tag, such as v1.3.0 22 - func GitTempGetTag(ctx context.Context, c util.LexClient, repo string, tag string) ([]byte, error) { 23 - buf := new(bytes.Buffer) 24 - 25 - params := map[string]interface{}{} 26 - params["repo"] = repo 27 - params["tag"] = tag 28 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getTag", params, nil, buf); err != nil { 29 - return nil, err 30 - } 31 - 32 - return buf.Bytes(), nil 33 - }
-90
api/tangled/tempgetTree.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.getTree 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - GitTempGetTreeNSID = "sh.tangled.git.temp.getTree" 15 - ) 16 - 17 - // GitTempGetTree_LastCommit is a "lastCommit" in the sh.tangled.git.temp.getTree schema. 18 - type GitTempGetTree_LastCommit struct { 19 - Author *GitTempGetTree_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 - // hash: Commit hash 21 - Hash string `json:"hash" cborgen:"hash"` 22 - // message: Commit message 23 - Message string `json:"message" cborgen:"message"` 24 - // when: Commit timestamp 25 - When string `json:"when" cborgen:"when"` 26 - } 27 - 28 - // GitTempGetTree_Output is the output of a sh.tangled.git.temp.getTree call. 29 - type GitTempGetTree_Output struct { 30 - // dotdot: Parent directory path 31 - Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 32 - Files []*GitTempGetTree_TreeEntry `json:"files" cborgen:"files"` 33 - LastCommit *GitTempGetTree_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 34 - // parent: The parent path in the tree 35 - Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 36 - // readme: Readme for this file tree 37 - Readme *GitTempGetTree_Readme `json:"readme,omitempty" cborgen:"readme,omitempty"` 38 - // ref: The git reference used 39 - Ref string `json:"ref" cborgen:"ref"` 40 - } 41 - 42 - // GitTempGetTree_Readme is a "readme" in the sh.tangled.git.temp.getTree schema. 43 - type GitTempGetTree_Readme struct { 44 - // contents: Contents of the readme file 45 - Contents string `json:"contents" cborgen:"contents"` 46 - // filename: Name of the readme file 47 - Filename string `json:"filename" cborgen:"filename"` 48 - } 49 - 50 - // GitTempGetTree_Signature is a "signature" in the sh.tangled.git.temp.getTree schema. 51 - type GitTempGetTree_Signature struct { 52 - // email: Author email 53 - Email string `json:"email" cborgen:"email"` 54 - // name: Author name 55 - Name string `json:"name" cborgen:"name"` 56 - // when: Author timestamp 57 - When string `json:"when" cborgen:"when"` 58 - } 59 - 60 - // GitTempGetTree_TreeEntry is a "treeEntry" in the sh.tangled.git.temp.getTree schema. 61 - type GitTempGetTree_TreeEntry struct { 62 - Last_commit *GitTempGetTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 63 - // mode: File mode 64 - Mode string `json:"mode" cborgen:"mode"` 65 - // name: Relative file or directory name 66 - Name string `json:"name" cborgen:"name"` 67 - // size: File size in bytes 68 - Size int64 `json:"size" cborgen:"size"` 69 - } 70 - 71 - // GitTempGetTree calls the XRPC method "sh.tangled.git.temp.getTree". 72 - // 73 - // path: Path within the repository tree 74 - // ref: Git reference (branch, tag, or commit SHA) 75 - // repo: AT-URI of the repository 76 - func GitTempGetTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*GitTempGetTree_Output, error) { 77 - var out GitTempGetTree_Output 78 - 79 - params := map[string]interface{}{} 80 - if path != "" { 81 - params["path"] = path 82 - } 83 - params["ref"] = ref 84 - params["repo"] = repo 85 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.getTree", params, nil, &out); err != nil { 86 - return nil, err 87 - } 88 - 89 - return &out, nil 90 - }
-39
api/tangled/templistBranches.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.listBranches 6 - 7 - import ( 8 - "bytes" 9 - "context" 10 - 11 - "github.com/bluesky-social/indigo/lex/util" 12 - ) 13 - 14 - const ( 15 - GitTempListBranchesNSID = "sh.tangled.git.temp.listBranches" 16 - ) 17 - 18 - // GitTempListBranches calls the XRPC method "sh.tangled.git.temp.listBranches". 19 - // 20 - // cursor: Pagination cursor 21 - // limit: Maximum number of branches to return 22 - // repo: AT-URI of the repository 23 - func GitTempListBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 - buf := new(bytes.Buffer) 25 - 26 - params := map[string]interface{}{} 27 - if cursor != "" { 28 - params["cursor"] = cursor 29 - } 30 - if limit != 0 { 31 - params["limit"] = limit 32 - } 33 - params["repo"] = repo 34 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.listBranches", params, nil, buf); err != nil { 35 - return nil, err 36 - } 37 - 38 - return buf.Bytes(), nil 39 - }
-43
api/tangled/templistCommits.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.listCommits 6 - 7 - import ( 8 - "bytes" 9 - "context" 10 - 11 - "github.com/bluesky-social/indigo/lex/util" 12 - ) 13 - 14 - const ( 15 - GitTempListCommitsNSID = "sh.tangled.git.temp.listCommits" 16 - ) 17 - 18 - // GitTempListCommits calls the XRPC method "sh.tangled.git.temp.listCommits". 19 - // 20 - // cursor: Pagination cursor (commit SHA) 21 - // limit: Maximum number of commits to return 22 - // ref: Git reference (branch, tag, or commit SHA) 23 - // repo: AT-URI of the repository 24 - func GitTempListCommits(ctx context.Context, c util.LexClient, cursor string, limit int64, ref string, repo string) ([]byte, error) { 25 - buf := new(bytes.Buffer) 26 - 27 - params := map[string]interface{}{} 28 - if cursor != "" { 29 - params["cursor"] = cursor 30 - } 31 - if limit != 0 { 32 - params["limit"] = limit 33 - } 34 - if ref != "" { 35 - params["ref"] = ref 36 - } 37 - params["repo"] = repo 38 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.listCommits", params, nil, buf); err != nil { 39 - return nil, err 40 - } 41 - 42 - return buf.Bytes(), nil 43 - }
-61
api/tangled/templistLanguages.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.listLanguages 6 - 7 - import ( 8 - "context" 9 - 10 - "github.com/bluesky-social/indigo/lex/util" 11 - ) 12 - 13 - const ( 14 - GitTempListLanguagesNSID = "sh.tangled.git.temp.listLanguages" 15 - ) 16 - 17 - // GitTempListLanguages_Language is a "language" in the sh.tangled.git.temp.listLanguages schema. 18 - type GitTempListLanguages_Language struct { 19 - // color: Hex color code for this language 20 - Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 21 - // extensions: File extensions associated with this language 22 - Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"` 23 - // fileCount: Number of files in this language 24 - FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"` 25 - // name: Programming language name 26 - Name string `json:"name" cborgen:"name"` 27 - // percentage: Percentage of total codebase (0-100) 28 - Percentage int64 `json:"percentage" cborgen:"percentage"` 29 - // size: Total size of files in this language (bytes) 30 - Size int64 `json:"size" cborgen:"size"` 31 - } 32 - 33 - // GitTempListLanguages_Output is the output of a sh.tangled.git.temp.listLanguages call. 34 - type GitTempListLanguages_Output struct { 35 - Languages []*GitTempListLanguages_Language `json:"languages" cborgen:"languages"` 36 - // ref: The git reference used 37 - Ref string `json:"ref" cborgen:"ref"` 38 - // totalFiles: Total number of files analyzed 39 - TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"` 40 - // totalSize: Total size of all analyzed files in bytes 41 - TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"` 42 - } 43 - 44 - // GitTempListLanguages calls the XRPC method "sh.tangled.git.temp.listLanguages". 45 - // 46 - // ref: Git reference (branch, tag, or commit SHA) 47 - // repo: AT-URI of the repository 48 - func GitTempListLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*GitTempListLanguages_Output, error) { 49 - var out GitTempListLanguages_Output 50 - 51 - params := map[string]interface{}{} 52 - if ref != "" { 53 - params["ref"] = ref 54 - } 55 - params["repo"] = repo 56 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.listLanguages", params, nil, &out); err != nil { 57 - return nil, err 58 - } 59 - 60 - return &out, nil 61 - }
-39
api/tangled/templistTags.go
··· 1 - // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 - 3 - package tangled 4 - 5 - // schema: sh.tangled.git.temp.listTags 6 - 7 - import ( 8 - "bytes" 9 - "context" 10 - 11 - "github.com/bluesky-social/indigo/lex/util" 12 - ) 13 - 14 - const ( 15 - GitTempListTagsNSID = "sh.tangled.git.temp.listTags" 16 - ) 17 - 18 - // GitTempListTags calls the XRPC method "sh.tangled.git.temp.listTags". 19 - // 20 - // cursor: Pagination cursor 21 - // limit: Maximum number of tags to return 22 - // repo: AT-URI of the repository 23 - func GitTempListTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 - buf := new(bytes.Buffer) 25 - 26 - params := map[string]interface{}{} 27 - if cursor != "" { 28 - params["cursor"] = cursor 29 - } 30 - if limit != 0 { 31 - params["limit"] = limit 32 - } 33 - params["repo"] = repo 34 - if err := c.LexDo(ctx, util.Query, "", "sh.tangled.git.temp.listTags", params, nil, buf); err != nil { 35 - return nil, err 36 - } 37 - 38 - return buf.Bytes(), nil 39 - }
+2 -2
appview/labels/labels.go
··· 22 22 "tangled.org/core/tid" 23 23 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 - "github.com/bluesky-social/indigo/atproto/atclient" 25 + atpclient "github.com/bluesky-social/indigo/atproto/client" 26 26 "github.com/bluesky-social/indigo/atproto/syntax" 27 27 lexutil "github.com/bluesky-social/indigo/lex/util" 28 28 "github.com/go-chi/chi/v5" ··· 269 269 // this is used to rollback changes made to the PDS 270 270 // 271 271 // it is a no-op if the provided ATURI is empty 272 - func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 272 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 273 273 if aturi == "" { 274 274 return nil 275 275 }
+3 -3
appview/oauth/oauth.go
··· 11 11 "time" 12 12 13 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - "github.com/bluesky-social/indigo/atproto/atclient" 15 - "github.com/bluesky-social/indigo/atproto/atcrypto" 16 14 "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 16 + atcrypto "github.com/bluesky-social/indigo/atproto/crypto" 17 17 "github.com/bluesky-social/indigo/atproto/syntax" 18 18 xrpc "github.com/bluesky-social/indigo/xrpc" 19 19 "github.com/gorilla/sessions" ··· 262 262 return "" 263 263 } 264 264 265 - func (o *OAuth) AuthorizedClient(r *http.Request) (*atclient.APIClient, error) { 265 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 266 266 session, err := o.ResumeSession(r) 267 267 if err != nil { 268 268 return nil, fmt.Errorf("error getting session: %w", err)
+492
appview/ogcard/bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "@tangled/ogcard-worker", 7 + "dependencies": { 8 + "@fontsource/inter": "^5.2.8", 9 + "@resvg/resvg-wasm": "^2.6.2", 10 + "@tangled/ogcard-runtime": "*", 11 + "lucide-static": "^0.577.0", 12 + "preact": "^10.29.0", 13 + "satori": "0.25.0", 14 + "zod": "^4.3.6", 15 + }, 16 + "devDependencies": { 17 + "@cloudflare/workers-types": "^4.20260317.1", 18 + "@types/bun": "^1.3.11", 19 + "@types/node": "^25.5.0", 20 + "knip": "^6.0.1", 21 + "tsx": "^4.21.0", 22 + "typescript": "^5.9.3", 23 + "wrangler": "^4.75.0", 24 + }, 25 + }, 26 + "packages/runtime": { 27 + "name": "@tangled/ogcard-runtime", 28 + "version": "1.0.0", 29 + }, 30 + }, 31 + "packages": { 32 + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], 33 + 34 + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.15.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw=="], 35 + 36 + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260317.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g=="], 37 + 38 + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260317.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg=="], 39 + 40 + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260317.1", "", { "os": "linux", "cpu": "x64" }, "sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug=="], 41 + 42 + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260317.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw=="], 43 + 44 + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260317.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ=="], 45 + 46 + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260317.1", "", {}, "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ=="], 47 + 48 + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], 49 + 50 + "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], 51 + 52 + "@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="], 53 + 54 + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], 55 + 56 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], 57 + 58 + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], 59 + 60 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], 61 + 62 + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], 63 + 64 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], 65 + 66 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], 67 + 68 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], 69 + 70 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], 71 + 72 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], 73 + 74 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], 75 + 76 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], 77 + 78 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], 79 + 80 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], 81 + 82 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], 83 + 84 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], 85 + 86 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], 87 + 88 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], 89 + 90 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], 91 + 92 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], 93 + 94 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], 95 + 96 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], 97 + 98 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], 99 + 100 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], 101 + 102 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], 103 + 104 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], 105 + 106 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], 107 + 108 + "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], 109 + 110 + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], 111 + 112 + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], 113 + 114 + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], 115 + 116 + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], 117 + 118 + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], 119 + 120 + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], 121 + 122 + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], 123 + 124 + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], 125 + 126 + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], 127 + 128 + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], 129 + 130 + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], 131 + 132 + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], 133 + 134 + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], 135 + 136 + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], 137 + 138 + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], 139 + 140 + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], 141 + 142 + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], 143 + 144 + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], 145 + 146 + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], 147 + 148 + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], 149 + 150 + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], 151 + 152 + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], 153 + 154 + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], 155 + 156 + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], 157 + 158 + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], 159 + 160 + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 161 + 162 + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 163 + 164 + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], 165 + 166 + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], 167 + 168 + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 169 + 170 + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], 171 + 172 + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], 173 + 174 + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.120.0", "", { "os": "android", "cpu": "arm" }, "sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg=="], 175 + 176 + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.120.0", "", { "os": "android", "cpu": "arm64" }, "sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg=="], 177 + 178 + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A=="], 179 + 180 + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw=="], 181 + 182 + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.120.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A=="], 183 + 184 + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.120.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA=="], 185 + 186 + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.120.0", "", { "os": "linux", "cpu": "arm" }, "sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ=="], 187 + 188 + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw=="], 189 + 190 + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw=="], 191 + 192 + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.120.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw=="], 193 + 194 + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.120.0", "", { "os": "linux", "cpu": "none" }, "sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw=="], 195 + 196 + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.120.0", "", { "os": "linux", "cpu": "none" }, "sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw=="], 197 + 198 + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.120.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ=="], 199 + 200 + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ=="], 201 + 202 + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w=="], 203 + 204 + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.120.0", "", { "os": "none", "cpu": "arm64" }, "sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA=="], 205 + 206 + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.120.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA=="], 207 + 208 + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.120.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ=="], 209 + 210 + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.120.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw=="], 211 + 212 + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg=="], 213 + 214 + "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], 215 + 216 + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="], 217 + 218 + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.19.1", "", { "os": "android", "cpu": "arm64" }, "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA=="], 219 + 220 + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.19.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ=="], 221 + 222 + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.19.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ=="], 223 + 224 + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.19.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw=="], 225 + 226 + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A=="], 227 + 228 + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.19.1", "", { "os": "linux", "cpu": "arm" }, "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ=="], 229 + 230 + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig=="], 231 + 232 + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.19.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew=="], 233 + 234 + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.19.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ=="], 235 + 236 + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w=="], 237 + 238 + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.19.1", "", { "os": "linux", "cpu": "none" }, "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw=="], 239 + 240 + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.19.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA=="], 241 + 242 + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ=="], 243 + 244 + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.19.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw=="], 245 + 246 + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.19.1", "", { "os": "none", "cpu": "arm64" }, "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA=="], 247 + 248 + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.19.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg=="], 249 + 250 + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.19.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ=="], 251 + 252 + "@oxc-resolver/binding-win32-ia32-msvc": ["@oxc-resolver/binding-win32-ia32-msvc@11.19.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA=="], 253 + 254 + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], 255 + 256 + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], 257 + 258 + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], 259 + 260 + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], 261 + 262 + "@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="], 263 + 264 + "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], 265 + 266 + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], 267 + 268 + "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], 269 + 270 + "@tangled/ogcard-runtime": ["@tangled/ogcard-runtime@workspace:packages/runtime"], 271 + 272 + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], 273 + 274 + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], 275 + 276 + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], 277 + 278 + "base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], 279 + 280 + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], 281 + 282 + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 283 + 284 + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], 285 + 286 + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], 287 + 288 + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 289 + 290 + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 291 + 292 + "css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="], 293 + 294 + "css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="], 295 + 296 + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], 297 + 298 + "css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], 299 + 300 + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], 301 + 302 + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 303 + 304 + "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], 305 + 306 + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], 307 + 308 + "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], 309 + 310 + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 311 + 312 + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], 313 + 314 + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], 315 + 316 + "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], 317 + 318 + "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], 319 + 320 + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 321 + 322 + "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], 323 + 324 + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 325 + 326 + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], 327 + 328 + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 329 + 330 + "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], 331 + 332 + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 333 + 334 + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 335 + 336 + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 337 + 338 + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 339 + 340 + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 341 + 342 + "knip": ["knip@6.0.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "get-tsconfig": "4.13.6", "jiti": "^2.6.0", "minimist": "^1.2.8", "oxc-parser": "^0.120.0", "oxc-resolver": "^11.19.1", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "unbash": "^2.2.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-qk5m+w6IYEqfRG5546DXZJYl5AXsgFfDD6ULaDvkubqNtLye79sokBg3usURrWFjASMeQtvX19TfldU3jHkMNA=="], 343 + 344 + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], 345 + 346 + "lucide-static": ["lucide-static@0.577.0", "", {}, "sha512-hx39J5Tq4JWF2ALY+5YRg+SxQLpeAmLJDXNcqiBJH/UuVwp43it9fyki/onZO7AVFgG5ZbB+fWwZR9mwGHE2XQ=="], 347 + 348 + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], 349 + 350 + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], 351 + 352 + "miniflare": ["miniflare@4.20260317.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-xuwk5Kjv+shi5iUBAdCrRl9IaWSGnTU8WuTQzsUS2GlSDIMCJuu8DiF/d9ExjMXYiQG5ml+k9SVKnMj8cRkq0w=="], 353 + 354 + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], 355 + 356 + "oxc-parser": ["oxc-parser@0.120.0", "", { "dependencies": { "@oxc-project/types": "^0.120.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.120.0", "@oxc-parser/binding-android-arm64": "0.120.0", "@oxc-parser/binding-darwin-arm64": "0.120.0", "@oxc-parser/binding-darwin-x64": "0.120.0", "@oxc-parser/binding-freebsd-x64": "0.120.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.120.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.120.0", "@oxc-parser/binding-linux-arm64-gnu": "0.120.0", "@oxc-parser/binding-linux-arm64-musl": "0.120.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.120.0", "@oxc-parser/binding-linux-riscv64-musl": "0.120.0", "@oxc-parser/binding-linux-s390x-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-gnu": "0.120.0", "@oxc-parser/binding-linux-x64-musl": "0.120.0", "@oxc-parser/binding-openharmony-arm64": "0.120.0", "@oxc-parser/binding-wasm32-wasi": "0.120.0", "@oxc-parser/binding-win32-arm64-msvc": "0.120.0", "@oxc-parser/binding-win32-ia32-msvc": "0.120.0", "@oxc-parser/binding-win32-x64-msvc": "0.120.0" } }, "sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w=="], 357 + 358 + "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], 359 + 360 + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], 361 + 362 + "parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="], 363 + 364 + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], 365 + 366 + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 367 + 368 + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 369 + 370 + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], 371 + 372 + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 373 + 374 + "preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="], 375 + 376 + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], 377 + 378 + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 379 + 380 + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], 381 + 382 + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], 383 + 384 + "satori": ["satori@0.25.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-utINfLxrYrmSnLvxFT4ZwgwWa8KOjrz7ans32V5wItgHVmzESl/9i33nE38uG0miycab8hUqQtDlOpqrIpB/iw=="], 385 + 386 + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], 387 + 388 + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], 389 + 390 + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], 391 + 392 + "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], 393 + 394 + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], 395 + 396 + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], 397 + 398 + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], 399 + 400 + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 401 + 402 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 403 + 404 + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], 405 + 406 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 407 + 408 + "unbash": ["unbash@2.2.0", "", {}, "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w=="], 409 + 410 + "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], 411 + 412 + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], 413 + 414 + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], 415 + 416 + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], 417 + 418 + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], 419 + 420 + "workerd": ["workerd@1.20260317.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260317.1", "@cloudflare/workerd-darwin-arm64": "1.20260317.1", "@cloudflare/workerd-linux-64": "1.20260317.1", "@cloudflare/workerd-linux-arm64": "1.20260317.1", "@cloudflare/workerd-windows-64": "1.20260317.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g=="], 421 + 422 + "wrangler": ["wrangler@4.75.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.15.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260317.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260317.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260317.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Efk1tcnm4eduBYpH1sSjMYydXMnIFPns/qABI3+fsbDrUk5GksNYX8nYGVP4sFygvGPO7kJc36YJKB5ooA7JAg=="], 423 + 424 + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], 425 + 426 + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], 427 + 428 + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], 429 + 430 + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], 431 + 432 + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], 433 + 434 + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], 435 + 436 + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 437 + 438 + "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], 439 + 440 + "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], 441 + 442 + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], 443 + 444 + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], 445 + 446 + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], 447 + 448 + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], 449 + 450 + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], 451 + 452 + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], 453 + 454 + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], 455 + 456 + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], 457 + 458 + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], 459 + 460 + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], 461 + 462 + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], 463 + 464 + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], 465 + 466 + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], 467 + 468 + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], 469 + 470 + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], 471 + 472 + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], 473 + 474 + "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], 475 + 476 + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], 477 + 478 + "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], 479 + 480 + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], 481 + 482 + "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], 483 + 484 + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], 485 + 486 + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], 487 + 488 + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], 489 + 490 + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], 491 + } 492 + }
+4
appview/ogcard/knip.json
··· 1 + { 2 + "$schema": "https://unpkg.com/knip@5/schema.json", 3 + "tags": ["-lintignore"] 4 + }
+88
appview/ogcard/packages/runtime/index.ts
··· 1 + /** 2 + * Bun/Node.js runtime implementation 3 + * Uses filesystem APIs to load WASM and fonts 4 + */ 5 + import { readFile } from "node:fs/promises"; 6 + import { createRequire } from "node:module"; 7 + import type { FontData, SatoriFn, ResvgClass } from "./types"; 8 + 9 + const require = createRequire(import.meta.url); 10 + 11 + let satoriFn: SatoriFn | null = null; 12 + let resvgInitialized = false; 13 + let Resvg: ResvgClass | null = null; 14 + 15 + export async function initSatori(): Promise<SatoriFn> { 16 + if (satoriFn) return satoriFn; 17 + 18 + const { default: satori } = await import("satori"); 19 + satoriFn = satori; 20 + 21 + return satoriFn; 22 + } 23 + 24 + export async function initResvg(): Promise<ResvgClass> { 25 + if (resvgInitialized) return Resvg!; 26 + 27 + const { Resvg: ResvgClass, initWasm } = await import("@resvg/resvg-wasm"); 28 + const wasmPath = require.resolve("@resvg/resvg-wasm/index_bg.wasm"); 29 + const wasmBuffer = await readFile(wasmPath); 30 + await initWasm(wasmBuffer); 31 + 32 + Resvg = ResvgClass; 33 + resvgInitialized = true; 34 + return Resvg; 35 + } 36 + 37 + export async function loadFonts(): Promise<FontData[]> { 38 + // In Bun, .woff imports return a Module object with `default` being the file path 39 + const inter400Module = await import( 40 + "@fontsource/inter/files/inter-latin-400-normal.woff" 41 + ); 42 + const inter500Module = await import( 43 + "@fontsource/inter/files/inter-latin-500-normal.woff" 44 + ); 45 + const inter600Module = await import( 46 + "@fontsource/inter/files/inter-latin-600-normal.woff" 47 + ); 48 + 49 + const inter400Path = (inter400Module as { default: string }).default; 50 + const inter500Path = (inter500Module as { default: string }).default; 51 + const inter600Path = (inter600Module as { default: string }).default; 52 + 53 + const [buf400, buf500, buf600] = await Promise.all([ 54 + readFile(inter400Path), 55 + readFile(inter500Path), 56 + readFile(inter600Path), 57 + ]); 58 + 59 + return [ 60 + { 61 + name: "Inter", 62 + data: buf400.buffer.slice( 63 + buf400.byteOffset, 64 + buf400.byteOffset + buf400.byteLength, 65 + ), 66 + weight: 400, 67 + style: "normal", 68 + }, 69 + { 70 + name: "Inter", 71 + data: buf500.buffer.slice( 72 + buf500.byteOffset, 73 + buf500.byteOffset + buf500.byteLength, 74 + ), 75 + weight: 500, 76 + style: "normal", 77 + }, 78 + { 79 + name: "Inter", 80 + data: buf600.buffer.slice( 81 + buf600.byteOffset, 82 + buf600.byteOffset + buf600.byteLength, 83 + ), 84 + weight: 600, 85 + style: "normal", 86 + }, 87 + ]; 88 + }
+12
appview/ogcard/packages/runtime/package.json
··· 1 + { 2 + "name": "@tangled/ogcard-runtime", 3 + "version": "1.0.0", 4 + "private": true, 5 + "type": "module", 6 + "exports": { 7 + "workerd": "./workerd.ts", 8 + "bun": "./index.ts", 9 + "default": "./index.ts" 10 + }, 11 + "types": "./types.ts" 12 + }
+10
appview/ogcard/packages/runtime/types.ts
··· 1 + export interface FontData { 2 + name: string; 3 + data: ArrayBuffer; 4 + weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; 5 + style: "normal" | "italic"; 6 + } 7 + 8 + export type SatoriFn = typeof import("satori").default; 9 + 10 + export type ResvgClass = typeof import("@resvg/resvg-wasm").Resvg;
+60
appview/ogcard/packages/runtime/workerd.ts
··· 1 + /** 2 + * Cloudflare Workers runtime implementation 3 + * Uses ?module suffix for WASM imports as required by Wrangler 4 + */ 5 + import type { FontData, SatoriFn, ResvgClass } from "./types"; 6 + 7 + import inter400 from "@fontsource/inter/files/inter-latin-400-normal.woff"; 8 + import inter500 from "@fontsource/inter/files/inter-latin-500-normal.woff"; 9 + import inter600 from "@fontsource/inter/files/inter-latin-600-normal.woff"; 10 + 11 + let satoriFn: SatoriFn | null = null; 12 + let resvgInitialized = false; 13 + let Resvg: ResvgClass | null = null; 14 + 15 + export async function initSatori(): Promise<SatoriFn> { 16 + if (satoriFn) return satoriFn; 17 + 18 + const { default: satori, init } = await import("satori/standalone"); 19 + const wasmModule = (await import("satori/yoga.wasm?module")).default; 20 + await init(wasmModule); 21 + satoriFn = satori; 22 + 23 + return satoriFn; 24 + } 25 + 26 + export async function initResvg(): Promise<ResvgClass> { 27 + if (resvgInitialized) return Resvg!; 28 + 29 + const { Resvg: ResvgClass, initWasm } = await import("@resvg/resvg-wasm"); 30 + const wasmModule = (await import("@resvg/resvg-wasm/index_bg.wasm?module")) 31 + .default; 32 + await initWasm(wasmModule); 33 + 34 + Resvg = ResvgClass; 35 + resvgInitialized = true; 36 + return Resvg; 37 + } 38 + 39 + export async function loadFonts(): Promise<FontData[]> { 40 + return [ 41 + { 42 + name: "Inter", 43 + data: inter400 as ArrayBuffer, 44 + weight: 400, 45 + style: "normal", 46 + }, 47 + { 48 + name: "Inter", 49 + data: inter500 as ArrayBuffer, 50 + weight: 500, 51 + style: "normal", 52 + }, 53 + { 54 + name: "Inter", 55 + data: inter600 as ArrayBuffer, 56 + weight: 600, 57 + style: "normal", 58 + }, 59 + ]; 60 + }
+9
appview/ogcard/src/types.d.ts
··· 1 + declare module "*.wasm?module" { 2 + const value: WebAssembly.Module; 3 + export default value; 4 + } 5 + 6 + declare module "*.woff" { 7 + const value: ArrayBuffer; 8 + export default value; 9 + }
+1 -7
appview/pages/funcmap.go
··· 195 195 {D: math.MaxInt64, Format: "a long while %s", DivBy: 1}, 196 196 }) 197 197 }, 198 - "shortTimeFmt": func(t time.Time) string { 199 - return t.Format("Jan 2, 2006") 200 - }, 201 198 "longTimeFmt": func(t time.Time) string { 202 199 return t.Format("Jan 2, 2006, 3:04 PM MST") 203 200 }, ··· 212 209 return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds) 213 210 }, 214 211 "durationFmt": func(duration time.Duration) string { 215 - return durationFmt(duration, [4]string{"d", "h", "m", "s"}) 212 + return durationFmt(duration, [4]string{"d", "hr", "min", "s"}) 216 213 }, 217 214 "longDurationFmt": func(duration time.Duration) string { 218 215 return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"}) ··· 524 521 } 525 522 526 523 secret := p.avatar.SharedSecret 527 - if secret == "" { 528 - return "" 529 - } 530 524 h := hmac.New(sha256.New, []byte(secret)) 531 525 h.Write([]byte(did)) 532 526 signature := hex.EncodeToString(h.Sum(nil))
+1 -3
appview/pages/templates/fragments/line-quote-button.html
··· 227 227 ? firstAnchor 228 228 : `${firstAnchor}~${lastAnchor}`; 229 229 230 - const linkBase = document.getElementById('round-link-base')?.value 231 - || (window.location.pathname + window.location.search); 232 - const md = `[\`${label}\`](${linkBase}#${fragment})`; 230 + const md = `[\`${label}\`](${window.location.pathname}${window.location.search}#${fragment})`; 233 231 234 232 const { selectionStart: s, selectionEnd: end, value } = ta; 235 233 const before = value.slice(0, s);
-1
appview/pages/templates/layouts/fragments/footerMinimal.html
··· 10 10 <a href="https://blog.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">blog</a> 11 11 <a href="https://docs.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">docs</a> 12 12 <a href="https://tangled.org/tangled.org/core" hx-boost="true" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">source</a> 13 - <a href="https://tangled.org/brand" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">brand</a> 14 13 <a href="https://chat.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">discord</a> 15 14 <a href="https://bsky.app/profile/tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline" target="_blank" rel="noopener noreferrer">bluesky</a> 16 15 <a href="/terms" hx-boost="true" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">terms</a>
+3 -7
appview/pages/templates/repo/pipelines/workflow.html
··· 48 48 {{ $lastStatus := $all.Latest }} 49 49 {{ $kind := $lastStatus.Status.String }} 50 50 51 - <div id="left" class="flex items-center gap-2 flex-1 min-w-0"> 52 - <div class="flex-shrink-0"> 53 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 54 - </div> 55 - <span class="truncate" title="{{ $name }}"> 56 - {{ $name }} 57 - </span> 51 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 52 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 53 + {{ $name }} 58 54 </div> 59 55 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 60 56 <span class="font-bold">{{ $kind }}</span>
-1
appview/pages/templates/repo/pulls/pull.html
··· 99 99 {{ end }} 100 100 101 101 {{ define "contentAfter" }} 102 - <input type="hidden" id="round-link-base" value="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .ActiveRound }}" /> 103 102 {{ template "repo/fragments/diff" (list .Diff .DiffOpts $) }} 104 103 {{ end }} 105 104
+19 -9
appview/repo/archive.go
··· 8 8 "strings" 9 9 10 10 "github.com/go-chi/chi/v5" 11 - "tangled.org/core/api/tangled" 12 11 ) 13 12 14 13 func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { ··· 21 20 l.Error("failed to get repo and knot", "err", err) 22 21 return 23 22 } 23 + scheme := "http" 24 + if !rp.config.Core.Dev { 25 + scheme = "https" 26 + } 27 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 28 + didSlashRepo := f.DidSlashRepo() 24 29 25 30 // build the xrpc url 31 + u, err := url.Parse(host) 32 + if err != nil { 33 + l.Error("failed to parse host URL", "err", err) 34 + rp.pages.Error503(w) 35 + return 36 + } 37 + 38 + u.Path = "/xrpc/sh.tangled.repo.archive" 26 39 query := url.Values{} 27 - query.Set("repo", f.RepoAt().String()) 28 - query.Set("ref", ref) 29 40 query.Set("format", "tar.gz") 30 41 query.Set("prefix", r.URL.Query().Get("prefix")) 31 - xrpcURL := fmt.Sprintf( 32 - "%s/xrpc/%s?%s", 33 - rp.config.KnotMirror.Url, 34 - tangled.GitTempGetArchiveNSID, 35 - query.Encode(), 36 - ) 42 + query.Set("ref", ref) 43 + query.Set("repo", didSlashRepo) 44 + u.RawQuery = query.Encode() 45 + 46 + xrpcURL := u.String() 37 47 38 48 // make the get request 39 49 resp, err := http.Get(xrpcURL)
+10 -2
appview/repo/artifact.go
··· 313 313 return nil, err 314 314 } 315 315 316 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 316 + scheme := "http" 317 + if !rp.config.Core.Dev { 318 + scheme = "https" 319 + } 320 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 321 + xrpcc := &indigoxrpc.Client{ 322 + Host: host, 323 + } 317 324 318 - xrpcBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 0, f.RepoAt().String()) 325 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 326 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 319 327 if err != nil { 320 328 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 321 329 l.Error("failed to call XRPC repo.tags", "err", xrpcerr)
+12 -5
appview/repo/branches.go
··· 21 21 l.Error("failed to get repo and knot", "err", err) 22 22 return 23 23 } 24 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 25 - 26 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 27 - if err != nil { 28 - l.Error("failed to call XRPC repo.branches", "err", err) 24 + scheme := "http" 25 + if !rp.config.Core.Dev { 26 + scheme = "https" 27 + } 28 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 29 + xrpcc := &indigoxrpc.Client{ 30 + Host: host, 31 + } 32 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 33 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 34 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 35 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 29 36 rp.pages.Error503(w) 30 37 return 31 38 }
+11 -3
appview/repo/compare.go
··· 27 27 return 28 28 } 29 29 30 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 30 + scheme := "http" 31 + if !rp.config.Core.Dev { 32 + scheme = "https" 33 + } 34 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 35 + xrpcc := &indigoxrpc.Client{ 36 + Host: host, 37 + } 31 38 32 - branchBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 39 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 40 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 33 41 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 34 42 l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 35 43 rp.pages.Error503(w) ··· 66 74 head = queryHead 67 75 } 68 76 69 - tagBytes, err := tangled.GitTempListTags(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 77 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 70 78 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 71 79 l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 72 80 rp.pages.Error503(w)
+56 -34
appview/repo/index.go
··· 22 22 "tangled.org/core/appview/db" 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/pages" 25 + "tangled.org/core/appview/xrpcclient" 25 26 "tangled.org/core/orm" 26 27 "tangled.org/core/types" 27 28 ··· 41 42 return 42 43 } 43 44 45 + scheme := "http" 46 + if !rp.config.Core.Dev { 47 + scheme = "https" 48 + } 49 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 50 + xrpcc := &indigoxrpc.Client{ 51 + Host: host, 52 + } 53 + 44 54 user := rp.oauth.GetMultiAccountUser(r) 45 55 46 56 // Build index response from multiple XRPC calls 47 - result, err := rp.buildIndexResponse(r.Context(), f, ref) 48 - if err != nil { 49 - l.Error("failed to build index response", "err", err) 50 - rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 51 - LoggedInUser: user, 52 - KnotUnreachable: true, 53 - RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 54 - }) 55 - return 57 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 58 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 59 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 60 + l.Error("failed to call XRPC repo.index", "err", err) 61 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 62 + LoggedInUser: user, 63 + NeedsKnotUpgrade: true, 64 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 65 + }) 66 + return 67 + } else { 68 + l.Error("failed to build index response", "err", err) 69 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 70 + LoggedInUser: user, 71 + KnotUnreachable: true, 72 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 73 + }) 74 + return 75 + } 56 76 } 57 77 58 78 tagMap := make(map[string][]string) ··· 112 132 l.Error("failed to GetVerifiedObjectCommits", "err", err) 113 133 } 114 134 115 - var languageInfo []types.RepoLanguageDetails 116 - if !result.IsEmpty { 117 - // TODO: a bit dirty 118 - languageInfo, err = rp.getLanguageInfo(r.Context(), l, f, result.Ref, ref == "") 119 - if err != nil { 120 - l.Warn("failed to compute language percentages", "err", err) 121 - // non-fatal 122 - } 135 + // TODO: a bit dirty 136 + languageInfo, err := rp.getLanguageInfo(r.Context(), l, f, xrpcc, result.Ref, ref == "") 137 + if err != nil { 138 + l.Warn("failed to compute language percentages", "err", err) 139 + // non-fatal 123 140 } 124 141 125 142 var shas []string ··· 152 169 ctx context.Context, 153 170 l *slog.Logger, 154 171 repo *models.Repo, 172 + xrpcc *indigoxrpc.Client, 155 173 currentRef string, 156 174 isDefaultRef bool, 157 175 ) ([]types.RepoLanguageDetails, error) { ··· 164 182 165 183 if err != nil || langs == nil { 166 184 // non-fatal, fetch langs from ks via XRPC 167 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 168 - ls, err := tangled.GitTempListLanguages(ctx, xrpcc, currentRef, repo.RepoAt().String()) 185 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 186 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, didSlashRepo) 169 187 if err != nil { 170 - return nil, fmt.Errorf("calling knotmirror git.listLanguages: %w", err) 188 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 189 + l.Error("failed to call XRPC repo.languages", "err", xrpcerr) 190 + return nil, xrpcerr 191 + } 192 + return nil, err 171 193 } 172 194 173 195 if ls == nil || ls.Languages == nil { ··· 236 258 } 237 259 238 260 // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 239 - func (rp *Repo) buildIndexResponse(ctx context.Context, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 240 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 261 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, repo *models.Repo, ref string) (*types.RepoIndexResponse, error) { 262 + didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 241 263 242 264 // first get branches to determine the ref if not specified 243 - branchesBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String()) 265 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, didSlashRepo) 244 266 if err != nil { 245 - return nil, fmt.Errorf("calling knotmirror git.listBranches: %w", err) 267 + return nil, fmt.Errorf("failed to call repoBranches: %w", err) 246 268 } 247 269 248 270 var branchesResp types.RepoBranchesResponse ··· 274 296 275 297 var ( 276 298 tagsResp types.RepoTagsResponse 277 - treeResp *tangled.GitTempGetTree_Output 299 + treeResp *tangled.RepoTree_Output 278 300 logResp types.RepoLogResponse 279 301 readmeContent string 280 302 readmeFileName string ··· 282 304 283 305 // tags 284 306 wg.Go(func() { 285 - tagsBytes, err := tangled.GitTempListTags(ctx, xrpcc, "", 0, repo.RepoAt().String()) 307 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, didSlashRepo) 286 308 if err != nil { 287 - errs = errors.Join(errs, fmt.Errorf("failed to call git.ListTags: %w", err)) 309 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 288 310 return 289 311 } 290 312 291 313 if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 292 - errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListTags: %w", err)) 314 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err)) 293 315 } 294 316 }) 295 317 296 318 // tree/files 297 319 wg.Go(func() { 298 - resp, err := tangled.GitTempGetTree(ctx, xrpcc, "", ref, repo.RepoAt().String()) 320 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, didSlashRepo) 299 321 if err != nil { 300 - errs = errors.Join(errs, fmt.Errorf("failed to call git.GetTree: %w", err)) 322 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 301 323 return 302 324 } 303 325 treeResp = resp ··· 305 327 306 328 // commits 307 329 wg.Go(func() { 308 - logBytes, err := tangled.GitTempListCommits(ctx, xrpcc, "", 50, ref, repo.RepoAt().String()) 330 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, didSlashRepo) 309 331 if err != nil { 310 - errs = errors.Join(errs, fmt.Errorf("failed to call git.ListCommits: %w", err)) 332 + errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 311 333 return 312 334 } 313 335 314 336 if err := json.Unmarshal(logBytes, &logResp); err != nil { 315 - errs = errors.Join(errs, fmt.Errorf("failed to unmarshal git.ListCommits: %w", err)) 337 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err)) 316 338 } 317 339 }) 318 340 ··· 354 376 Readme: readmeContent, 355 377 ReadmeFileName: readmeFileName, 356 378 Commits: logResp.Commits, 357 - Description: "", 379 + Description: logResp.Description, 358 380 Files: files, 359 381 Branches: branchesResp.Branches, 360 382 Tags: tagsResp.Tags,
+18 -10
appview/repo/log.go
··· 40 40 ref := chi.URLParam(r, "ref") 41 41 ref, _ = url.PathUnescape(ref) 42 42 43 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 50 + } 44 51 45 52 limit := int64(60) 46 53 cursor := "" ··· 50 57 cursor = strconv.Itoa(offset) 51 58 } 52 59 53 - xrpcBytes, err := tangled.GitTempListCommits(r.Context(), xrpcc, cursor, limit, ref, f.RepoAt().String()) 54 - if err != nil { 55 - l.Error("failed to call XRPC repo.log", "err", err) 60 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 61 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 62 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 63 + l.Error("failed to call XRPC repo.log", "err", xrpcerr) 56 64 rp.pages.Error503(w) 57 65 return 58 66 } ··· 64 72 return 65 73 } 66 74 67 - tagBytes, err := tangled.GitTempListTags(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 68 - if err != nil { 69 - l.Error("failed to call XRPC repo.tags", "err", err) 75 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 76 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 77 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 70 78 rp.pages.Error503(w) 71 79 return 72 80 } ··· 85 93 } 86 94 } 87 95 88 - branchBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 89 - if err != nil { 90 - l.Error("failed to call XRPC repo.branches", "err", err) 96 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 97 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 98 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 91 99 rp.pages.Error503(w) 92 100 return 93 101 }
+10 -2
appview/repo/settings.go
··· 386 386 f, err := rp.repoResolver.Resolve(r) 387 387 user := rp.oauth.GetMultiAccountUser(r) 388 388 389 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 389 + scheme := "http" 390 + if !rp.config.Core.Dev { 391 + scheme = "https" 392 + } 393 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 394 + xrpcc := &indigoxrpc.Client{ 395 + Host: host, 396 + } 390 397 391 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 398 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 399 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 392 400 var result types.RepoBranchesResponse 393 401 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 394 402 l.Error("failed to call XRPC repo.branches", "err", xrpcerr)
+23 -8
appview/repo/tags.go
··· 27 27 l.Error("failed to get repo and knot", "err", err) 28 28 return 29 29 } 30 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 31 - xrpcBytes, err := tangled.GitTempListTags(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 32 - if err != nil { 33 - l.Error("failed to call XRPC repo.tags", "err", err) 30 + scheme := "http" 31 + if !rp.config.Core.Dev { 32 + scheme = "https" 33 + } 34 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 35 + xrpcc := &indigoxrpc.Client{ 36 + Host: host, 37 + } 38 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 39 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 40 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 41 + l.Error("failed to call XRPC repo.tags", "err", xrpcerr) 34 42 rp.pages.Error503(w) 35 43 return 36 44 } ··· 82 90 l.Error("failed to get repo and knot", "err", err) 83 91 return 84 92 } 93 + scheme := "http" 94 + if !rp.config.Core.Dev { 95 + scheme = "https" 96 + } 97 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 98 + xrpcc := &indigoxrpc.Client{ 99 + Host: host, 100 + } 101 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 85 102 tag := chi.URLParam(r, "tag") 86 103 87 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 88 - 89 - xrpcBytes, err := tangled.GitTempGetTag(r.Context(), xrpcc, f.RepoAt().String(), tag) 104 + xrpcBytes, err := tangled.RepoTag(r.Context(), xrpcc, repo, tag) 90 105 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 91 106 // if we don't match an existing tag, and the tag we're trying 92 107 // to match is "latest", resolve to the most recent tag 93 108 if tag == "latest" { 94 - tagsBytes, err := tangled.GitTempListTags(r.Context(), xrpcc, "", 1, f.RepoAt().String()) 109 + tagsBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 1, repo) 95 110 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 96 111 l.Error("failed to call XRPC repo.tags for latest", "err", xrpcerr) 97 112 rp.pages.Error503(w)
+10 -3
appview/repo/tree.go
··· 33 33 treePath := chi.URLParam(r, "*") 34 34 treePath, _ = url.PathUnescape(treePath) 35 35 treePath = strings.TrimSuffix(treePath, "/") 36 - 37 - xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 38 - xrpcResp, err := tangled.GitTempGetTree(r.Context(), xrpcc, treePath, ref, f.RepoAt().String()) 36 + scheme := "http" 37 + if !rp.config.Core.Dev { 38 + scheme = "https" 39 + } 40 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 41 + xrpcc := &indigoxrpc.Client{ 42 + Host: host, 43 + } 44 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 45 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 39 46 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 40 47 l.Error("failed to call XRPC repo.tree", "err", xrpcerr) 41 48 rp.pages.Error503(w)
+2 -2
appview/settings/settings.go
··· 28 28 "tangled.org/core/tid" 29 29 30 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 31 - "github.com/bluesky-social/indigo/atproto/atclient" 31 + atpclient "github.com/bluesky-social/indigo/atproto/client" 32 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 33 lexutil "github.com/bluesky-social/indigo/lex/util" 34 34 "github.com/gliderlabs/ssh" ··· 816 816 817 817 log.Printf("failed to update handle: %s", err) 818 818 msg := err.Error() 819 - var apiErr *atclient.APIError 819 + var apiErr *atpclient.APIError 820 820 if errors.As(err, &apiErr) && apiErr.Message != "" { 821 821 msg = apiErr.Message 822 822 }
+2 -2
appview/state/state.go
··· 38 38 "tangled.org/core/tid" 39 39 40 40 comatproto "github.com/bluesky-social/indigo/api/atproto" 41 - "github.com/bluesky-social/indigo/atproto/atclient" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 42 "github.com/bluesky-social/indigo/atproto/syntax" 43 43 lexutil "github.com/bluesky-social/indigo/lex/util" 44 44 "github.com/bluesky-social/indigo/xrpc" ··· 588 588 // this is used to rollback changes made to the PDS 589 589 // 590 590 // it is a no-op if the provided ATURI is empty 591 - func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 591 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 592 592 if aturi == "" { 593 593 return nil 594 594 }
+7 -5
blog/blog.go
··· 76 76 77 77 rctx := &markup.RenderContext{ 78 78 RendererType: markup.RendererTypeDefault, 79 + Sanitizer: markup.NewSanitizer(), 79 80 } 80 81 var posts []Post 81 82 for _, entry := range entries { ··· 99 100 } 100 101 101 102 htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes)) 103 + sanitized := rctx.SanitizeDefault(htmlStr) 102 104 103 105 posts = append(posts, Post{ 104 106 Meta: meta, 105 - Body: template.HTML(htmlStr), 107 + Body: template.HTML(sanitized), 106 108 }) 107 109 } 108 110 ··· 124 126 for _, p := range posts { 125 127 postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug 126 128 127 - var authorName strings.Builder 129 + var authorName string 128 130 for i, a := range p.Meta.Authors { 129 131 if i > 0 { 130 - authorName.WriteString(" & ") 132 + authorName += " & " 131 133 } 132 - authorName.WriteString(a.Name) 134 + authorName += a.Name 133 135 } 134 136 135 137 feed.Items = append(feed.Items, &feeds.Item{ 136 138 Title: p.Meta.Title, 137 139 Link: &feeds.Link{Href: postURL}, 138 140 Description: p.Meta.Subtitle, 139 - Author: &feeds.Author{Name: authorName.String()}, 141 + Author: &feeds.Author{Name: authorName}, 140 142 Created: p.ParsedDate(), 141 143 }) 142 144 }
+1 -1
blog/templates/fragments/footer.html
··· 1 1 {{ define "blog/fragments/footer" }} 2 - <footer class="mt-12 w-full px-6 py-4"> 2 + <footer class="mt-12 w-full px-6 py-4 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700"> 3 3 <div class="max-w-[90ch] mx-auto flex flex-wrap justify-center items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400"> 4 4 <div class="flex items-center justify-center gap-x-2 order-last sm:order-first w-full sm:w-auto"> 5 5 <a href="https://tangled.org" class="no-underline hover:no-underline flex items-center">
+13 -40
blog/templates/index.html
··· 10 10 11 11 {{ define "topbarLayout" }} 12 12 <header class="max-w-screen-xl mx-auto w-full" style="z-index: 20;"> 13 - <nav class="mx-auto space-x-4 px-6 py-2"> 14 - <div class="flex justify-between p-0 items-center"> 15 - <div id="left-items"> 16 - <a href="/" hx-boost="true" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 17 - {{ template "fragments/logotypeSmall" }} 18 - </a> 19 - </div> 20 - 21 - <div id="right-items" class="flex items-center gap-4"> 22 - <a href="https://tangled.org/login">login</a> 23 - <span class="text-gray-500 dark:text-gray-400">or</span> 24 - <a href="https://tangled.org/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 25 - join now {{ i "arrow-right" "size-4" }} 26 - </a> 27 - </div> 28 - </div> 29 - </nav> 13 + {{ template "layouts/fragments/topbar" . }} 30 14 </header> 31 15 {{ end }} 32 16 ··· 42 26 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-14"> 43 27 {{ range .Featured }} 44 28 <a href="/{{ .Meta.Slug }}" class="no-underline hover:no-underline group flex flex-col bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 overflow-hidden hover:bg-gray-100/25 hover:dark:bg-gray-700/25 transition-colors"> 45 - <div class="overflow-hidden bg-gray-100 dark:bg-gray-700 md:h-48"> 29 + <div class="aspect-[16/9] overflow-hidden bg-gray-100 dark:bg-gray-700"> 46 30 <img src="{{ .Meta.Image }}" alt="{{ .Meta.Title }}" class="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-300" /> 47 31 </div> 48 32 <div class="flex flex-col flex-1 px-5 py-4"> 49 33 <div class="text-xs text-gray-400 dark:text-gray-500 mb-2"> 50 - {{ $date := .ParsedDate }}{{ $date | shortTimeFmt}} 34 + {{ $date := .ParsedDate }}{{ $date.Format "Jan 2, 2006" }} 51 35 {{ if .Meta.Draft }}<span class="text-red-500">[draft]</span>{{ end }} 52 36 </div> 53 - <h2 class="font-bold text-gray-900 dark:text-white text-base leading-snug mb-1">{{ .Meta.Title }}</h2> 37 + <h2 class="font-bold text-gray-900 dark:text-white text-base leading-snug mb-1 group-hover:underline">{{ .Meta.Title }}</h2> 54 38 <p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 flex-1">{{ .Meta.Subtitle }}</p> 55 - <div class="flex items-center mt-4 gap-2"> 56 - {{ $hasAvatar := false }}{{ range .Meta.Authors }}{{ if tinyAvatar .Handle }}{{ $hasAvatar = true }}{{ end }}{{ end }} 57 - {{ if $hasAvatar }} 39 + <div class="flex items-center mt-4"> 58 40 <div class="inline-flex items-center -space-x-2"> 59 41 {{ range .Meta.Authors }} 60 - {{ $av := tinyAvatar .Handle }}{{ if $av }}<img src="{{ $av }}" class="size-6 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" />{{ end }} 61 - {{ end }} 62 - </div> 63 - {{ end }} 64 - <div class="text-xs"> 65 - {{ $last := sub (len .Meta.Authors) 1 }} 66 - {{ range $i, $n := .Meta.Authors }} 67 - {{ $n.Handle }}{{ if ne $i $last }}, {{ end }} 42 + <img src="{{ tinyAvatar .Handle }}" class="size-6 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" /> 68 43 {{ end }} 69 44 </div> 70 45 </div> ··· 78 53 {{ range .Posts }} 79 54 <a href="/{{ .Meta.Slug }}" class="no-underline hover:no-underline group flex items-center justify-between gap-4 px-6 py-3 hover:bg-gray-100/25 hover:dark:bg-gray-700/25 transition-colors"> 80 55 <div class="flex items-center gap-3 min-w-0"> 81 - <span class="font-medium text-gray-900 dark:text-white truncate"> 82 - {{ .Meta.Title }} 83 - {{ if .Meta.Draft }}<span class="text-red-500 text-xs font-normal ml-1">[draft]</span>{{ end }} 84 - </span> 85 - </div> 86 - <div class="flex items-center gap-2"> 87 56 <div class="inline-flex items-center -space-x-2 shrink-0"> 88 57 {{ range .Meta.Authors }} 89 58 <img src="{{ tinyAvatar .Handle }}" class="size-5 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" /> 90 59 {{ end }} 91 60 </div> 92 - <div class="text-sm text-gray-400 dark:text-gray-500 shrink-0"> 93 - {{ $date := .ParsedDate }}{{ $date | shortTimeFmt }} 94 - </div> 61 + <span class="font-medium text-gray-900 dark:text-white group-hover:underline truncate"> 62 + {{ .Meta.Title }} 63 + {{ if .Meta.Draft }}<span class="text-red-500 text-xs font-normal ml-1">[draft]</span>{{ end }} 64 + </span> 65 + </div> 66 + <div class="text-sm text-gray-400 dark:text-gray-500 shrink-0"> 67 + {{ $date := .ParsedDate }}{{ $date.Format "Jan 02, 2006" }} 95 68 </div> 96 69 </a> 97 70 {{ end }}
+2 -5
blog/templates/post.html
··· 35 35 {{ $authors := .Post.Meta.Authors }} 36 36 <p class="mb-1 text-sm text-gray-600 dark:text-gray-400"> 37 37 {{ $date := .Post.ParsedDate }} 38 - {{ $date | shortTimeFmt }} 38 + {{ $date.Format "02 Jan, 2006" }} 39 39 </p> 40 40 41 41 <h1 class="mb-0 text-2xl font-bold dark:text-white"> ··· 45 45 <p class="italic mt-1 mb-3 text-lg text-gray-600 dark:text-gray-400">{{ .Post.Meta.Subtitle }}</p> 46 46 47 47 <div class="flex items-center gap-3 not-prose"> 48 - {{ $hasAvatar := false }}{{ range $authors }}{{ if tinyAvatar .Handle }}{{ $hasAvatar = true }}{{ end }}{{ end }} 49 - {{ if $hasAvatar }} 50 48 <div class="inline-flex items-center -space-x-2"> 51 49 {{ range $authors }} 52 - {{ $av := tinyAvatar .Handle }}{{ if $av }}<img src="{{ $av }}" class="size-7 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Handle }}" title="{{ .Handle }}" />{{ end }} 50 + <img src="{{ tinyAvatar .Handle }}" class="size-7 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Handle }}" title="{{ .Handle }}" /> 53 51 {{ end }} 54 52 </div> 55 - {{ end }} 56 53 <div class="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300"> 57 54 {{ range $i, $a := $authors }} 58 55 {{ if gt $i 0 }}<span class="text-gray-400">&amp;</span>{{ end }}
+1 -1
blog/templates/text.html
··· 51 51 <p class="px-6 mb-0 text-sm text-gray-600 dark:text-gray-400"> 52 52 {{ $dateStr := index .Meta "date" }} 53 53 {{ $date := parsedate $dateStr }} 54 - {{ $date.Format | shortTimeFmt }} 54 + {{ $date.Format "02 Jan, 2006" }} 55 55 56 56 <span class="mx-2 select-none">&middot;</span> 57 57
+3 -30
cmd/blog/main.go
··· 4 4 "context" 5 5 "fmt" 6 6 "io" 7 - "io/fs" 8 7 "log/slog" 9 8 "net/http" 10 9 "os" ··· 85 84 return fmt.Errorf("rendering index: %w", err) 86 85 } 87 86 87 + // posts โ€” each at build/<slug>/index.html directly (no /blog/ prefix) 88 88 for _, post := range posts { 89 + post := post 89 90 postDir := filepath.Join(outDir, post.Meta.Slug) 90 91 if err := os.MkdirAll(postDir, 0755); err != nil { 91 92 return err ··· 97 98 } 98 99 } 99 100 100 - // atom feed 101 + // atom feed โ€” at build/feed.xml 101 102 baseURL := "https://blog.tangled.org" 102 103 atom, err := blog.AtomFeed(posts, baseURL) 103 104 if err != nil { ··· 107 108 return fmt.Errorf("writing feed: %w", err) 108 109 } 109 110 110 - // copy embedded static assets into build/static/ so Cloudflare Pages 111 - // can serve them from the same origin as the built HTML 112 - staticSrc, err := fs.Sub(pages.Files, "static") 113 - if err != nil { 114 - return fmt.Errorf("accessing embedded static dir: %w", err) 115 - } 116 - if err := copyFS(staticSrc, filepath.Join(outDir, "static")); err != nil { 117 - return fmt.Errorf("copying static assets: %w", err) 118 - } 119 - 120 111 logger.Info("build complete", "dir", outDir) 121 112 return nil 122 - } 123 - 124 - // copyFS copies all files from src into destDir, preserving directory structure. 125 - func copyFS(src fs.FS, destDir string) error { 126 - return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error { 127 - if err != nil { 128 - return err 129 - } 130 - dest := filepath.Join(destDir, path) 131 - if d.IsDir() { 132 - return os.MkdirAll(dest, 0755) 133 - } 134 - data, err := fs.ReadFile(src, path) 135 - if err != nil { 136 - return err 137 - } 138 - return os.WriteFile(dest, data, 0644) 139 - }) 140 113 } 141 114 142 115 func runServe(ctx context.Context, logger *slog.Logger, addr string) error {
-58
cmd/knotmirror/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - "os" 7 - "os/signal" 8 - "syscall" 9 - 10 - "github.com/carlmjohnson/versioninfo" 11 - "github.com/urfave/cli/v3" 12 - "tangled.org/core/knotmirror" 13 - "tangled.org/core/knotmirror/config" 14 - "tangled.org/core/log" 15 - ) 16 - 17 - func main() { 18 - if err := run(os.Args); err != nil { 19 - slog.Error("error running knotmirror", "err", err) 20 - os.Exit(-1) 21 - } 22 - } 23 - 24 - func run(args []string) error { 25 - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 26 - defer cancel() 27 - 28 - logger := log.New("knotmirror") 29 - slog.SetDefault(logger) 30 - ctx = log.IntoContext(ctx, logger) 31 - 32 - app := cli.Command{ 33 - Name: "knotmirror", 34 - Usage: "knot mirroring service", 35 - Version: versioninfo.Short(), 36 - } 37 - app.Flags = []cli.Flag{} 38 - app.Commands = []*cli.Command{ 39 - { 40 - Name: "serve", 41 - Usage: "run the knotmirror daemon", 42 - Action: runKnotMirror, 43 - Flags: []cli.Flag{}, 44 - }, 45 - } 46 - return app.Run(ctx, args) 47 - } 48 - 49 - func runKnotMirror(ctx context.Context, cmd *cli.Command) error { 50 - logger := log.FromContext(ctx) 51 - cfg, err := config.Load(ctx) 52 - if err != nil { 53 - return err 54 - } 55 - 56 - logger.Debug("config loaded:", "config", cfg) 57 - return knotmirror.Run(ctx, cfg) 58 - }
+1 -15
flake.nix
··· 106 106 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 107 107 knot = self.callPackage ./nix/pkgs/knot.nix {}; 108 108 dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 109 - tap = self.callPackage ./nix/pkgs/tap.nix {}; 110 - knotmirror = self.callPackage ./nix/pkgs/knot-mirror.nix {}; 111 109 }); 112 110 in { 113 111 overlays.default = final: prev: { 114 - inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly tap knotmirror; 112 + inherit (mkPackageSet final) lexgen goat sqlite-lib spindle knot-unwrapped knot appview docs dolly; 115 113 }; 116 114 117 115 packages = forAllSystems (system: let ··· 132 130 sqlite-lib 133 131 docs 134 132 dolly 135 - tap 136 133 ; 137 134 138 135 pkgsStatic-appview = staticPackages.appview; ··· 207 204 pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 208 205 packages'.lexgen 209 206 packages'.treefmt-wrapper 210 - packages'.tap 211 207 ]; 212 208 shellHook = '' 213 209 mkdir -p appview/pages/static ··· 352 348 imports = [./nix/modules/appview.nix]; 353 349 354 350 services.tangled.appview.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.appview; 355 - }; 356 - nixosModules.knotmirror = { 357 - lib, 358 - pkgs, 359 - ... 360 - }: { 361 - imports = [./nix/modules/knotmirror.nix]; 362 - 363 - services.tangled.knotmirror.tap-package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.tap; 364 - services.tangled.knotmirror.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knotmirror; 365 351 }; 366 352 nixosModules.knot = { 367 353 lib,
+10 -18
go.mod
··· 12 12 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 13 13 github.com/blevesearch/bleve/v2 v2.5.3 14 14 github.com/bluekeyes/go-gitdiff v0.8.1 15 - github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab 16 - github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 15 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 16 + github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 17 17 github.com/bmatcuk/doublestar/v4 v4.9.1 18 18 github.com/carlmjohnson/versioninfo v0.22.5 19 19 github.com/casbin/casbin/v2 v2.103.0 ··· 35 35 github.com/hiddeco/sshsig v0.2.0 36 36 github.com/hpcloud/tail v1.0.0 37 37 github.com/ipfs/go-cid v0.5.0 38 - github.com/jackc/pgx/v5 v5.8.0 39 38 github.com/mattn/go-sqlite3 v1.14.24 40 39 github.com/microcosm-cc/bluemonday v1.0.27 41 40 github.com/openbao/openbao/api/v2 v2.3.0 42 41 github.com/posthog/posthog-go v1.5.5 43 - github.com/prometheus/client_golang v1.23.2 44 42 github.com/redis/go-redis/v9 v9.7.3 45 43 github.com/resend/resend-go/v2 v2.15.0 46 44 github.com/sethvargo/go-envconfig v1.1.0 47 45 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 48 46 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 49 - github.com/stretchr/testify v1.11.1 50 - github.com/urfave/cli/v3 v3.4.1 47 + github.com/stretchr/testify v1.10.0 48 + github.com/urfave/cli/v3 v3.3.3 51 49 github.com/whyrusleeping/cbor-gen v0.3.1 52 50 github.com/yuin/goldmark v1.7.13 53 51 github.com/yuin/goldmark-emoji v1.0.6 54 52 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 55 53 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 56 54 go.abhg.dev/goldmark/mermaid v0.6.0 57 - golang.org/x/crypto v0.41.0 55 + golang.org/x/crypto v0.40.0 58 56 golang.org/x/image v0.31.0 59 - golang.org/x/net v0.43.0 57 + golang.org/x/net v0.42.0 60 58 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 61 59 gopkg.in/yaml.v3 v3.0.1 62 60 ) ··· 118 116 github.com/dlclark/regexp2 v1.11.5 // indirect 119 117 github.com/docker/go-connections v0.5.0 // indirect 120 118 github.com/docker/go-units v0.5.0 // indirect 121 - github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 122 119 github.com/emirpasic/gods v1.18.1 // indirect 123 120 github.com/felixge/httpsnoop v1.0.4 // indirect 124 121 github.com/fsnotify/fsnotify v1.6.0 // indirect ··· 163 160 github.com/ipfs/go-log v1.0.5 // indirect 164 161 github.com/ipfs/go-log/v2 v2.6.0 // indirect 165 162 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 166 - github.com/jackc/pgpassfile v1.0.0 // indirect 167 - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 168 - github.com/jackc/puddle/v2 v2.2.2 // indirect 169 163 github.com/json-iterator/go v1.1.12 // indirect 170 164 github.com/kevinburke/ssh_config v1.2.0 // indirect 171 165 github.com/klauspost/compress v1.18.0 // indirect ··· 198 192 github.com/pkg/errors v0.9.1 // indirect 199 193 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 200 194 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 195 + github.com/prometheus/client_golang v1.22.0 // indirect 201 196 github.com/prometheus/client_model v0.6.2 // indirect 202 - github.com/prometheus/common v0.66.1 // indirect 197 + github.com/prometheus/common v0.64.0 // indirect 203 198 github.com/prometheus/procfs v0.16.1 // indirect 204 199 github.com/rivo/uniseg v0.4.7 // indirect 205 200 github.com/ryanuber/go-glob v1.0.0 // indirect ··· 226 221 go.uber.org/atomic v1.11.0 // indirect 227 222 go.uber.org/multierr v1.11.0 // indirect 228 223 go.uber.org/zap v1.27.0 // indirect 229 - go.yaml.in/yaml/v2 v2.4.2 // indirect 230 224 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 231 225 golang.org/x/sync v0.17.0 // indirect 232 - golang.org/x/sys v0.35.0 // indirect 226 + golang.org/x/sys v0.34.0 // indirect 233 227 golang.org/x/text v0.29.0 // indirect 234 228 golang.org/x/time v0.12.0 // indirect 235 229 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 236 230 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 237 231 google.golang.org/grpc v1.73.0 // indirect 238 - google.golang.org/protobuf v1.36.8 // indirect 232 + google.golang.org/protobuf v1.36.6 // indirect 239 233 gopkg.in/fsnotify.v1 v1.4.7 // indirect 240 234 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 241 235 gopkg.in/warnings.v0 v0.1.2 // indirect ··· 251 245 replace github.com/bluekeyes/go-gitdiff => tangled.sh/oppi.li/go-gitdiff v0.8.2 252 246 253 247 replace github.com/alecthomas/chroma/v2 => github.com/oppiliappan/chroma/v2 v2.24.2 254 - 255 - replace github.com/bluesky-social/indigo => github.com/boltlessengineer/indigo v0.0.0-20260315101958-fb1dfa36fed2 256 248 257 249 // from bluesky-social/indigo 258 250 replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4
+22 -36
go.sum
··· 94 94 github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= 95 95 github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= 96 96 github.com/blevesearch/zapx/v16 v16.2.4/go.mod h1:Rti/REtuuMmzwsI8/C/qIzRaEoSK/wiFYw5e5ctUKKs= 97 - github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 h1:OK76FcHhZp8ohjRB0OMWgti0oYAWFlt3KDQcIkH1pfI= 98 - github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654/go.mod h1:vt8kVRKtvrBspt9G38wDD8+BotjIMO8u8IYoVnyE4zY= 97 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 98 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 99 + github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 100 + github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 99 101 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 100 102 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 101 103 github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 102 104 github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 103 - github.com/boltlessengineer/indigo v0.0.0-20260315101958-fb1dfa36fed2 h1:63+EsT7kltod8g1eA0eNuvq1q9ANJWRdxlLeJjJDVYY= 104 - github.com/boltlessengineer/indigo v0.0.0-20260315101958-fb1dfa36fed2/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 105 105 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 106 106 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 107 107 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 178 178 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 179 179 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 180 180 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 181 - github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 182 - github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 183 181 github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 184 182 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 185 183 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= ··· 350 348 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 351 349 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 352 350 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 353 - github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 354 - github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 355 - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 356 - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 357 - github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= 358 - github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= 359 - github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 360 - github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 361 351 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 362 352 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 363 353 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 379 369 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 380 370 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 381 371 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 382 - github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 383 - github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 384 372 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 385 373 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 386 374 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 482 470 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 483 471 github.com/posthog/posthog-go v1.5.5 h1:2o3j7IrHbTIfxRtj4MPaXKeimuTYg49onNzNBZbwksM= 484 472 github.com/posthog/posthog-go v1.5.5/go.mod h1:3RqUmSnPuwmeVj/GYrS75wNGqcAKdpODiwc83xZWgdE= 485 - github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 486 - github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 473 + github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 474 + github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 487 475 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 488 476 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 489 - github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 490 - github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 477 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 478 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 491 479 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 492 480 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 493 481 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= ··· 533 521 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 534 522 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 535 523 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 536 - github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 537 - github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 524 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 525 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 538 526 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 539 527 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 540 528 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= ··· 547 535 github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 548 536 github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 549 537 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 550 - github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= 551 - github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 538 + github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 539 + github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 552 540 github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= 553 541 github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= 554 542 github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= ··· 618 606 go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 619 607 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 620 608 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 621 - go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 622 - go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 623 609 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 624 610 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 625 611 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= ··· 627 613 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 628 614 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 629 615 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 630 - golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 631 - golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 616 + golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 617 + golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 632 618 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 633 619 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 634 620 golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= ··· 663 649 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 664 650 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 665 651 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 666 - golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 667 - golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 652 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 653 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 668 654 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 669 655 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 670 656 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 704 690 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 705 691 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 706 692 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 707 - golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 708 - golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 693 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 694 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 709 695 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 710 696 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 711 697 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= ··· 715 701 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 716 702 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 717 703 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 718 - golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 719 - golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 704 + golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 705 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 720 706 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 721 707 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 722 708 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 769 755 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 770 756 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 771 757 google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 772 - google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 773 - google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 758 + google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 759 + google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 774 760 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 775 761 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 776 762 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+3 -3
idresolver/resolver.go
··· 60 60 base := BaseDirectory(plcUrl) 61 61 cached := identity.NewCacheDirectory(base, 250_000, time.Hour*24, time.Minute*2, time.Minute*5) 62 62 return &Resolver{ 63 - directory: cached, 63 + directory: &cached, 64 64 } 65 65 } 66 66 ··· 80 80 return nil, err 81 81 } 82 82 83 - return r.directory.Lookup(ctx, id) 83 + return r.directory.Lookup(ctx, *id) 84 84 } 85 85 86 86 func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { ··· 117 117 return err 118 118 } 119 119 120 - return r.directory.Purge(ctx, id) 120 + return r.directory.Purge(ctx, *id) 121 121 } 122 122 123 123 func (r *Resolver) Directory() identity.Directory {
-182
knotmirror/adminpage.go
··· 1 - package knotmirror 2 - 3 - import ( 4 - "database/sql" 5 - "embed" 6 - "fmt" 7 - "html" 8 - "html/template" 9 - "log/slog" 10 - "net/http" 11 - "strconv" 12 - "time" 13 - 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/go-chi/chi/v5" 16 - "tangled.org/core/appview/pagination" 17 - "tangled.org/core/knotmirror/db" 18 - "tangled.org/core/knotmirror/models" 19 - ) 20 - 21 - //go:embed templates/*.html 22 - var templateFS embed.FS 23 - 24 - const repoPageSize = 20 25 - 26 - type AdminServer struct { 27 - db *sql.DB 28 - resyncer *Resyncer 29 - logger *slog.Logger 30 - } 31 - 32 - func NewAdminServer(l *slog.Logger, database *sql.DB, resyncer *Resyncer) *AdminServer { 33 - return &AdminServer{ 34 - db: database, 35 - resyncer: resyncer, 36 - logger: l, 37 - } 38 - } 39 - 40 - func (s *AdminServer) Router() http.Handler { 41 - r := chi.NewRouter() 42 - r.Get("/repos", s.handleRepos()) 43 - r.Get("/hosts", s.handleHosts()) 44 - 45 - r.Post("/api/triggerRepoResync", s.handleRepoResyncTrigger()) 46 - r.Post("/api/cancelRepoResync", s.handleRepoResyncCancel()) 47 - return r 48 - } 49 - 50 - func funcmap() template.FuncMap { 51 - return template.FuncMap{ 52 - "add": func(a, b int) int { return a + b }, 53 - "sub": func(a, b int) int { return a - b }, 54 - "readt": func(ts int64) string { 55 - if ts <= 0 { 56 - return "n/a" 57 - } 58 - return time.Unix(ts, 0).Format("2006-01-02 15:04") 59 - }, 60 - "const": func() map[string]any { 61 - return map[string]any{ 62 - "AllRepoStates": models.AllRepoStates, 63 - "AllHostStatuses": models.AllHostStatuses, 64 - } 65 - }, 66 - } 67 - } 68 - 69 - func (s *AdminServer) handleRepos() http.HandlerFunc { 70 - tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/repos.html")) 71 - return func(w http.ResponseWriter, r *http.Request) { 72 - pageNum, _ := strconv.Atoi(r.URL.Query().Get("page")) 73 - if pageNum < 1 { 74 - pageNum = 1 75 - } 76 - page := pagination.Page{ 77 - Offset: (pageNum - 1) * repoPageSize, 78 - Limit: repoPageSize, 79 - } 80 - 81 - var ( 82 - did = r.URL.Query().Get("did") 83 - knot = r.URL.Query().Get("knot") 84 - state = r.URL.Query().Get("state") 85 - ) 86 - 87 - repos, err := db.ListRepos(r.Context(), s.db, page, did, knot, state) 88 - if err != nil { 89 - http.Error(w, err.Error(), http.StatusInternalServerError) 90 - } 91 - counts, err := db.GetRepoCountsByState(r.Context(), s.db) 92 - if err != nil { 93 - http.Error(w, err.Error(), http.StatusInternalServerError) 94 - } 95 - err = tpl.ExecuteTemplate(w, "base", map[string]any{ 96 - "Repos": repos, 97 - "RepoCounts": counts, 98 - "Page": pageNum, 99 - "FilterByDid": did, 100 - "FilterByKnot": knot, 101 - "FilterByState": models.RepoState(state), 102 - }) 103 - if err != nil { 104 - slog.Error("failed to render", "err", err) 105 - } 106 - } 107 - } 108 - 109 - func (s *AdminServer) handleHosts() http.HandlerFunc { 110 - tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/hosts.html")) 111 - return func(w http.ResponseWriter, r *http.Request) { 112 - var status = models.HostStatus(r.URL.Query().Get("status")) 113 - if status == "" { 114 - status = models.HostStatusActive 115 - } 116 - 117 - hosts, err := db.ListHosts(r.Context(), s.db, status) 118 - if err != nil { 119 - http.Error(w, err.Error(), http.StatusInternalServerError) 120 - } 121 - err = tpl.ExecuteTemplate(w, "base", map[string]any{ 122 - "Hosts": hosts, 123 - "FilterByStatus": models.HostStatus(status), 124 - }) 125 - if err != nil { 126 - slog.Error("failed to render", "err", err) 127 - } 128 - } 129 - } 130 - 131 - func (s *AdminServer) handleRepoResyncTrigger() http.HandlerFunc { 132 - return func(w http.ResponseWriter, r *http.Request) { 133 - var repoQuery = r.FormValue("repo") 134 - 135 - repo, err := syntax.ParseATURI(repoQuery) 136 - if err != nil || repo.RecordKey() == "" { 137 - writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 138 - return 139 - } 140 - 141 - if err := s.resyncer.TriggerResyncJob(r.Context(), repo); err != nil { 142 - s.logger.Error("failed to trigger resync job", "err", err) 143 - writeNotif(w, http.StatusInternalServerError, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 144 - return 145 - } 146 - writeNotif(w, http.StatusOK, "success") 147 - } 148 - } 149 - 150 - func (s *AdminServer) handleRepoResyncCancel() http.HandlerFunc { 151 - return func(w http.ResponseWriter, r *http.Request) { 152 - var repoQuery = r.FormValue("repo") 153 - 154 - repo, err := syntax.ParseATURI(repoQuery) 155 - if err != nil || repo.RecordKey() == "" { 156 - writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 157 - return 158 - } 159 - 160 - s.resyncer.CancelResyncJob(repo) 161 - writeNotif(w, http.StatusOK, "success") 162 - } 163 - } 164 - 165 - func writeNotif(w http.ResponseWriter, status int, msg string) { 166 - w.Header().Set("Content-Type", "text/html") 167 - w.WriteHeader(status) 168 - 169 - class := "info" 170 - switch { 171 - case status >= 500: 172 - class = "error" 173 - case status >= 400: 174 - class = "warn" 175 - } 176 - 177 - fmt.Fprintf(w, 178 - `<div hx-swap-oob="beforeend:#notifications"><div class="notif %s">%s</div></div>`, 179 - class, 180 - html.EscapeString(msg), 181 - ) 182 - }
-45
knotmirror/config/config.go
··· 1 - package config 2 - 3 - import ( 4 - "context" 5 - "time" 6 - 7 - "github.com/sethvargo/go-envconfig" 8 - ) 9 - 10 - type Config struct { 11 - PlcUrl string `env:"MIRROR_PLC_URL, default=https://plc.directory"` 12 - TapUrl string `env:"MIRROR_TAP_URL, default=http://localhost:2480"` 13 - DbUrl string `env:"MIRROR_DB_URL, required"` 14 - KnotUseSSL bool `env:"MIRROR_KNOT_USE_SSL, default=false"` // use SSL for Knot when not scheme is not specified 15 - KnotSSRF bool `env:"MIRROR_KNOT_SSRF, default=false"` 16 - GitRepoBasePath string `env:"MIRROR_GIT_BASEPATH, default=repos"` 17 - GitRepoFetchTimeout time.Duration `env:"MIRROR_GIT_FETCH_TIMEOUT, default=600s"` 18 - ResyncParallelism int `env:"MIRROR_RESYNC_PARALLELISM, default=5"` 19 - Slurper SlurperConfig `env:",prefix=MIRROR_SLURPER_"` 20 - UseSSL bool `env:"MIRROR_USE_SSL, default=false"` 21 - Hostname string `env:"MIRROR_HOSTNAME, required"` 22 - Listen string `env:"MIRROR_LISTEN, default=:7000"` 23 - MetricsListen string `env:"MIRROR_METRICS_LISTEN, default=127.0.0.1:7100"` 24 - AdminListen string `env:"MIRROR_ADMIN_LISTEN, default=127.0.0.1:7200"` 25 - } 26 - 27 - func (c *Config) BaseUrl() string { 28 - if c.UseSSL { 29 - return "https://" + c.Hostname 30 - } 31 - return "http://" + c.Hostname 32 - } 33 - 34 - type SlurperConfig struct { 35 - PersistCursorPeriod time.Duration `env:"PERSIST_CURSOR_PERIOD, default=4s"` 36 - ConcurrencyPerHost int `env:"CONCURRENCY, default=4"` 37 - } 38 - 39 - func Load(ctx context.Context) (*Config, error) { 40 - var cfg Config 41 - if err := envconfig.Process(ctx, &cfg); err != nil { 42 - return nil, err 43 - } 44 - return &cfg, nil 45 - }
-25
knotmirror/crawler.go
··· 1 - package knotmirror 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "log/slog" 7 - 8 - "tangled.org/core/log" 9 - ) 10 - 11 - type Crawler struct { 12 - logger *slog.Logger 13 - db *sql.DB 14 - } 15 - 16 - func NewCrawler(l *slog.Logger, db *sql.DB) *Crawler { 17 - return &Crawler{ 18 - logger: log.SubLogger(l, "crawler"), 19 - db: db, 20 - } 21 - } 22 - 23 - func (c *Crawler) Start(ctx context.Context) { 24 - // TODO: repository crawler 25 - }
-100
knotmirror/db/db.go
··· 1 - package db 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - "time" 8 - 9 - _ "github.com/jackc/pgx/v5/stdlib" 10 - ) 11 - 12 - func Make(ctx context.Context, dbUrl string, maxConns int) (*sql.DB, error) { 13 - db, err := sql.Open("pgx", dbUrl) 14 - if err != nil { 15 - return nil, fmt.Errorf("opening db: %w", err) 16 - } 17 - 18 - db.SetMaxOpenConns(maxConns) 19 - db.SetMaxIdleConns(maxConns) 20 - db.SetConnMaxIdleTime(time.Hour) 21 - 22 - pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) 23 - defer cancel() 24 - if err := db.PingContext(pingCtx); err != nil { 25 - db.Close() 26 - return nil, fmt.Errorf("ping db: %w", err) 27 - } 28 - 29 - conn, err := db.Conn(ctx) 30 - if err != nil { 31 - return nil, err 32 - } 33 - defer conn.Close() 34 - 35 - _, err = conn.ExecContext(ctx, ` 36 - create table if not exists repos ( 37 - did text not null, 38 - rkey text not null, 39 - at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo' || '/' || rkey) stored, 40 - cid text, 41 - 42 - -- record content 43 - name text not null, 44 - knot_domain text not null, 45 - 46 - -- sync info 47 - git_rev text not null, 48 - repo_sha text not null, 49 - state text not null default 'pending', 50 - error_msg text, 51 - retry_count integer not null default 0, 52 - retry_after integer not null default 0, 53 - db_created_at timestamptz not null default now(), 54 - db_updated_at timestamptz not null default now(), 55 - 56 - constraint repos_pkey primary key (did, rkey) 57 - ); 58 - 59 - -- knot hosts 60 - create table if not exists hosts ( 61 - hostname text not null, 62 - no_ssl boolean not null default false, 63 - status text not null default 'active', 64 - last_seq bigint not null default -1, 65 - db_created_at timestamptz not null default now(), 66 - db_updated_at timestamptz not null default now(), 67 - 68 - constraint hosts_pkey primary key (hostname) 69 - ); 70 - 71 - create index if not exists idx_repos_aturi on repos (at_uri); 72 - create index if not exists idx_repos_db_updated_at on repos (db_updated_at desc); 73 - create index if not exists idx_hosts_db_updated_at on hosts (db_updated_at desc); 74 - 75 - create or replace function set_updated_at() 76 - returns trigger as $$ 77 - begin 78 - new.db_updated_at = now(); 79 - return new; 80 - end; 81 - $$ language plpgsql; 82 - 83 - drop trigger if exists repos_set_updated_at on repos; 84 - create trigger repos_set_updated_at 85 - before update on repos 86 - for each row 87 - execute function set_updated_at(); 88 - 89 - drop trigger if exists hosts_set_updated_at on hosts; 90 - create trigger hosts_set_updated_at 91 - before update on hosts 92 - for each row 93 - execute function set_updated_at(); 94 - `) 95 - if err != nil { 96 - return nil, fmt.Errorf("initializing db schema: %w", err) 97 - } 98 - 99 - return db, nil 100 - }
-102
knotmirror/db/hosts.go
··· 1 - package db 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "errors" 7 - "fmt" 8 - "log" 9 - 10 - "tangled.org/core/knotmirror/models" 11 - ) 12 - 13 - func UpsertHost(ctx context.Context, e *sql.DB, host *models.Host) error { 14 - if _, err := e.ExecContext(ctx, 15 - `insert into hosts (hostname, no_ssl, status, last_seq) 16 - values ($1, $2, $3, $4) 17 - on conflict(hostname) do update set 18 - no_ssl = excluded.no_ssl, 19 - status = excluded.status, 20 - last_seq = excluded.last_seq 21 - `, 22 - host.Hostname, 23 - host.NoSSL, 24 - host.Status, 25 - host.LastSeq, 26 - ); err != nil { 27 - return fmt.Errorf("upserting host: %w", err) 28 - } 29 - return nil 30 - } 31 - 32 - func GetHost(ctx context.Context, e *sql.DB, hostname string) (*models.Host, error) { 33 - var host models.Host 34 - if err := e.QueryRowContext(ctx, 35 - `select hostname, no_ssl, status, last_seq 36 - from hosts where hostname = $1`, 37 - hostname, 38 - ).Scan( 39 - &host.Hostname, 40 - &host.NoSSL, 41 - &host.Status, 42 - &host.LastSeq, 43 - ); err != nil { 44 - if errors.Is(err, sql.ErrNoRows) { 45 - return nil, nil 46 - } 47 - return nil, err 48 - } 49 - return &host, nil 50 - } 51 - 52 - func StoreCursors(ctx context.Context, e *sql.DB, cursors []models.HostCursor) error { 53 - tx, err := e.BeginTx(ctx, nil) 54 - if err != nil { 55 - return fmt.Errorf("starting transaction: %w", err) 56 - } 57 - defer tx.Rollback() 58 - for _, cur := range cursors { 59 - if cur.LastSeq <= 0 { 60 - continue 61 - } 62 - if _, err := tx.ExecContext(ctx, 63 - `update hosts set last_seq = $1 where hostname = $2`, 64 - cur.LastSeq, 65 - cur.Hostname, 66 - ); err != nil { 67 - log.Println("failed to persist host cursor", "host", cur.Hostname, "lastSeq", cur.LastSeq, "err", err) 68 - } 69 - } 70 - return tx.Commit() 71 - } 72 - 73 - func ListHosts(ctx context.Context, e *sql.DB, status models.HostStatus) ([]models.Host, error) { 74 - rows, err := e.QueryContext(ctx, 75 - `select hostname, no_ssl, status, last_seq 76 - from hosts 77 - where status = $1`, 78 - status, 79 - ) 80 - if err != nil { 81 - return nil, fmt.Errorf("querying hosts: %w", err) 82 - } 83 - defer rows.Close() 84 - 85 - var hosts []models.Host 86 - for rows.Next() { 87 - var host models.Host 88 - if err := rows.Scan( 89 - &host.Hostname, 90 - &host.NoSSL, 91 - &host.Status, 92 - &host.LastSeq, 93 - ); err != nil { 94 - return nil, fmt.Errorf("scanning row: %w", err) 95 - } 96 - hosts = append(hosts, host) 97 - } 98 - if err := rows.Err(); err != nil { 99 - return nil, fmt.Errorf("scanning rows: %w ", err) 100 - } 101 - return hosts, nil 102 - }
-275
knotmirror/db/repos.go
··· 1 - package db 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "errors" 7 - "fmt" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "tangled.org/core/appview/pagination" 11 - "tangled.org/core/knotmirror/models" 12 - ) 13 - 14 - func AddRepo(ctx context.Context, e *sql.DB, did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, name, knot string) error { 15 - if _, err := e.ExecContext(ctx, 16 - `insert into repos (did, rkey, cid, name, knot_domain) 17 - values ($1, $2, $3, $4, $5)`, 18 - did, rkey, cid, name, knot, 19 - ); err != nil { 20 - return fmt.Errorf("inserting repo: %w", err) 21 - } 22 - return nil 23 - } 24 - 25 - func UpsertRepo(ctx context.Context, e *sql.DB, repo *models.Repo) error { 26 - if _, err := e.ExecContext(ctx, 27 - `insert into repos (did, rkey, cid, name, knot_domain, git_rev, repo_sha, state, error_msg, retry_count, retry_after) 28 - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 29 - on conflict(did, rkey) do update set 30 - cid = excluded.cid, 31 - name = excluded.name, 32 - knot_domain = excluded.knot_domain, 33 - git_rev = excluded.git_rev, 34 - repo_sha = excluded.repo_sha, 35 - state = excluded.state, 36 - error_msg = excluded.error_msg, 37 - retry_count = excluded.retry_count, 38 - retry_after = excluded.retry_after`, 39 - // where repos.cid != excluded.cid`, 40 - repo.Did, 41 - repo.Rkey, 42 - repo.Cid, 43 - repo.Name, 44 - repo.KnotDomain, 45 - repo.GitRev, 46 - repo.RepoSha, 47 - repo.State, 48 - repo.ErrorMsg, 49 - repo.RetryCount, 50 - repo.RetryAfter, 51 - ); err != nil { 52 - return fmt.Errorf("upserting repo: %w", err) 53 - } 54 - return nil 55 - } 56 - 57 - func UpdateRepoState(ctx context.Context, e *sql.DB, did syntax.DID, rkey syntax.RecordKey, state models.RepoState) error { 58 - if _, err := e.ExecContext(ctx, 59 - `update repos 60 - set state = $1 61 - where did = $2 and rkey = $3`, 62 - state, 63 - did, rkey, 64 - ); err != nil { 65 - return fmt.Errorf("updating repo: %w", err) 66 - } 67 - return nil 68 - } 69 - 70 - func DeleteRepo(ctx context.Context, e *sql.DB, did syntax.DID, rkey syntax.RecordKey) error { 71 - if _, err := e.ExecContext(ctx, 72 - `delete from repos where did = $1 and rkey = $2`, 73 - did, 74 - rkey, 75 - ); err != nil { 76 - return fmt.Errorf("deleting repo: %w", err) 77 - } 78 - return nil 79 - } 80 - 81 - func GetRepoByName(ctx context.Context, e *sql.DB, did syntax.DID, name string) (*models.Repo, error) { 82 - var repo models.Repo 83 - if err := e.QueryRowContext(ctx, 84 - `select 85 - did, 86 - rkey, 87 - cid, 88 - name, 89 - knot_domain, 90 - git_rev, 91 - repo_sha, 92 - state, 93 - error_msg, 94 - retry_count, 95 - retry_after 96 - from repos 97 - where did = $1 and name = $2`, 98 - did, 99 - name, 100 - ).Scan( 101 - &repo.Did, 102 - &repo.Rkey, 103 - &repo.Cid, 104 - &repo.Name, 105 - &repo.KnotDomain, 106 - &repo.GitRev, 107 - &repo.RepoSha, 108 - &repo.State, 109 - &repo.ErrorMsg, 110 - &repo.RetryCount, 111 - &repo.RetryAfter, 112 - ); err != nil { 113 - if errors.Is(err, sql.ErrNoRows) { 114 - return nil, nil 115 - } 116 - return nil, fmt.Errorf("querying repo: %w", err) 117 - } 118 - return &repo, nil 119 - } 120 - 121 - func GetRepoByAtUri(ctx context.Context, e *sql.DB, aturi syntax.ATURI) (*models.Repo, error) { 122 - var repo models.Repo 123 - if err := e.QueryRowContext(ctx, 124 - `select 125 - did, 126 - rkey, 127 - cid, 128 - name, 129 - knot_domain, 130 - git_rev, 131 - repo_sha, 132 - state, 133 - error_msg, 134 - retry_count, 135 - retry_after 136 - from repos 137 - where at_uri = $1`, 138 - aturi, 139 - ).Scan( 140 - &repo.Did, 141 - &repo.Rkey, 142 - &repo.Cid, 143 - &repo.Name, 144 - &repo.KnotDomain, 145 - &repo.GitRev, 146 - &repo.RepoSha, 147 - &repo.State, 148 - &repo.ErrorMsg, 149 - &repo.RetryCount, 150 - &repo.RetryAfter, 151 - ); err != nil { 152 - if errors.Is(err, sql.ErrNoRows) { 153 - return nil, nil 154 - } 155 - return nil, fmt.Errorf("querying repo: %w", err) 156 - } 157 - return &repo, nil 158 - } 159 - 160 - func ListRepos(ctx context.Context, e *sql.DB, page pagination.Page, did, knot, state string) ([]models.Repo, error) { 161 - var conditions []string 162 - var args []any 163 - 164 - pageClause := "" 165 - if page.Limit > 0 { 166 - pageClause = " limit $1 offset $2 " 167 - args = append(args, page.Limit, page.Offset) 168 - } 169 - 170 - whereClause := "" 171 - if did != "" { 172 - conditions = append(conditions, fmt.Sprintf("did = $%d", len(args)+1)) 173 - args = append(args, did) 174 - } 175 - if knot != "" { 176 - conditions = append(conditions, fmt.Sprintf("knot_domain = $%d", len(args)+1)) 177 - args = append(args, knot) 178 - } 179 - if state != "" { 180 - conditions = append(conditions, fmt.Sprintf("state = $%d", len(args)+1)) 181 - args = append(args, state) 182 - } 183 - if len(conditions) > 0 { 184 - whereClause = "WHERE " + conditions[0] 185 - for _, condition := range conditions[1:] { 186 - whereClause += " AND " + condition 187 - } 188 - } 189 - 190 - query := ` 191 - select 192 - did, 193 - rkey, 194 - cid, 195 - name, 196 - knot_domain, 197 - git_rev, 198 - repo_sha, 199 - state, 200 - error_msg, 201 - retry_count, 202 - retry_after 203 - from repos 204 - ` + whereClause + pageClause 205 - rows, err := e.QueryContext(ctx, query, args...) 206 - if err != nil { 207 - return nil, err 208 - } 209 - defer rows.Close() 210 - 211 - var repos []models.Repo 212 - for rows.Next() { 213 - var repo models.Repo 214 - if err := rows.Scan( 215 - &repo.Did, 216 - &repo.Rkey, 217 - &repo.Cid, 218 - &repo.Name, 219 - &repo.KnotDomain, 220 - &repo.GitRev, 221 - &repo.RepoSha, 222 - &repo.State, 223 - &repo.ErrorMsg, 224 - &repo.RetryCount, 225 - &repo.RetryAfter, 226 - ); err != nil { 227 - return nil, fmt.Errorf("scanning row: %w", err) 228 - } 229 - repos = append(repos, repo) 230 - } 231 - if err := rows.Err(); err != nil { 232 - return nil, fmt.Errorf("scanning rows: %w ", err) 233 - } 234 - 235 - return repos, nil 236 - } 237 - 238 - func GetRepoCountsByState(ctx context.Context, e *sql.DB) (map[models.RepoState]int64, error) { 239 - const q = ` 240 - SELECT state, COUNT(*) 241 - FROM repos 242 - GROUP BY state 243 - ` 244 - 245 - rows, err := e.QueryContext(ctx, q) 246 - if err != nil { 247 - return nil, err 248 - } 249 - defer rows.Close() 250 - 251 - counts := make(map[models.RepoState]int64) 252 - 253 - for rows.Next() { 254 - var state string 255 - var count int64 256 - 257 - if err := rows.Scan(&state, &count); err != nil { 258 - return nil, err 259 - } 260 - 261 - counts[models.RepoState(state)] = count 262 - } 263 - 264 - if err := rows.Err(); err != nil { 265 - return nil, err 266 - } 267 - 268 - for _, s := range models.AllRepoStates { 269 - if _, ok := counts[s]; !ok { 270 - counts[s] = 0 271 - } 272 - } 273 - 274 - return counts, nil 275 - }
-305
knotmirror/git.go
··· 1 - package knotmirror 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "net/url" 8 - "os" 9 - "os/exec" 10 - "path/filepath" 11 - "regexp" 12 - "strings" 13 - 14 - "github.com/go-git/go-git/v5" 15 - gitconfig "github.com/go-git/go-git/v5/config" 16 - "github.com/go-git/go-git/v5/plumbing/transport" 17 - "tangled.org/core/knotmirror/models" 18 - ) 19 - 20 - type GitMirrorManager interface { 21 - Exist(repo *models.Repo) (bool, error) 22 - // RemoteSetUrl updates git repository 'origin' remote 23 - RemoteSetUrl(ctx context.Context, repo *models.Repo) error 24 - // Clone clones the repository as a mirror 25 - Clone(ctx context.Context, repo *models.Repo) error 26 - // Fetch fetches the repository 27 - Fetch(ctx context.Context, repo *models.Repo) error 28 - // Sync mirrors the repository. It will clone the repository if repository doesn't exist. 29 - Sync(ctx context.Context, repo *models.Repo) error 30 - } 31 - 32 - type CliGitMirrorManager struct { 33 - repoBasePath string 34 - knotUseSSL bool 35 - } 36 - 37 - func NewCliGitMirrorManager(repoBasePath string, knotUseSSL bool) *CliGitMirrorManager { 38 - return &CliGitMirrorManager{ 39 - repoBasePath, 40 - knotUseSSL, 41 - } 42 - } 43 - 44 - var _ GitMirrorManager = new(CliGitMirrorManager) 45 - 46 - func (c *CliGitMirrorManager) makeRepoPath(repo *models.Repo) string { 47 - return filepath.Join(c.repoBasePath, repo.Did.String(), repo.Rkey.String()) 48 - } 49 - 50 - func (c *CliGitMirrorManager) Exist(repo *models.Repo) (bool, error) { 51 - return isDir(c.makeRepoPath(repo)) 52 - } 53 - 54 - func (c *CliGitMirrorManager) RemoteSetUrl(ctx context.Context, repo *models.Repo) error { 55 - path := c.makeRepoPath(repo) 56 - url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL) 57 - if err != nil { 58 - return fmt.Errorf("constructing repo remote url: %w", err) 59 - } 60 - cmd := exec.CommandContext(ctx, "git", "-C", path, "remote", "set-url", "origin", url) 61 - if out, err := cmd.CombinedOutput(); err != nil { 62 - if ctx.Err() != nil { 63 - return ctx.Err() 64 - } 65 - msg := string(out) 66 - return fmt.Errorf("running 'git remote set-url origin %s': %w\n%s", url, err, msg) 67 - } 68 - return nil 69 - } 70 - 71 - func (c *CliGitMirrorManager) Clone(ctx context.Context, repo *models.Repo) error { 72 - path := c.makeRepoPath(repo) 73 - url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL) 74 - if err != nil { 75 - return fmt.Errorf("constructing repo remote url: %w", err) 76 - } 77 - return c.clone(ctx, path, url) 78 - } 79 - 80 - func (c *CliGitMirrorManager) clone(ctx context.Context, path, url string) error { 81 - cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", url, path) 82 - if out, err := cmd.CombinedOutput(); err != nil { 83 - if ctx.Err() != nil { 84 - return ctx.Err() 85 - } 86 - msg := string(out) 87 - if classification := classifyCliError(msg); classification != nil { 88 - return classification 89 - } 90 - return fmt.Errorf("running 'git clone --mirror %s': %w\n%s", url, err, msg) 91 - } 92 - return nil 93 - } 94 - 95 - func (c *CliGitMirrorManager) Fetch(ctx context.Context, repo *models.Repo) error { 96 - path := c.makeRepoPath(repo) 97 - return c.fetch(ctx, path) 98 - } 99 - 100 - func (c *CliGitMirrorManager) fetch(ctx context.Context, path string) error { 101 - // TODO: use `repo.Knot` instead of depending on origin 102 - cmd := exec.CommandContext(ctx, "git", "-C", path, "fetch", "--prune", "origin") 103 - if out, err := cmd.CombinedOutput(); err != nil { 104 - if ctx.Err() != nil { 105 - return ctx.Err() 106 - } 107 - return fmt.Errorf("running 'git fetch': %w\n%s", err, string(out)) 108 - } 109 - return nil 110 - } 111 - 112 - func (c *CliGitMirrorManager) Sync(ctx context.Context, repo *models.Repo) error { 113 - path := c.makeRepoPath(repo) 114 - url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL) 115 - if err != nil { 116 - return fmt.Errorf("constructing repo remote url: %w", err) 117 - } 118 - 119 - exist, err := isDir(path) 120 - if err != nil { 121 - return fmt.Errorf("checking repo path: %w", err) 122 - } 123 - if !exist { 124 - if err := c.clone(ctx, path, url); err != nil { 125 - return fmt.Errorf("cloning repo: %w", err) 126 - } 127 - } else { 128 - if err := c.fetch(ctx, path); err != nil { 129 - return fmt.Errorf("fetching repo: %w", err) 130 - } 131 - } 132 - return nil 133 - } 134 - 135 - var ( 136 - ErrDNSFailure = errors.New("git: knot: dns failure (could not resolve host)") 137 - ErrCertExpired = errors.New("git: knot: certificate has expired") 138 - ErrCertMismatch = errors.New("git: knot: certificate hostname mismatch") 139 - ErrTLSHandshake = errors.New("git: knot: tls handshake failure") 140 - ErrHTTPStatus = errors.New("git: knot: request url returned error") 141 - ErrUnreachable = errors.New("git: knot: could not connect to server") 142 - ErrRepoNotFound = errors.New("git: repo: repository not found") 143 - ) 144 - 145 - var ( 146 - reDNSFailure = regexp.MustCompile(`Could not resolve host:`) 147 - reCertExpired = regexp.MustCompile(`SSL certificate OpenSSL verify result: certificate has expired`) 148 - reCertMismatch = regexp.MustCompile(`SSL: no alternative certificate subject name matches target hostname`) 149 - reTLSHandshake = regexp.MustCompile(`TLS connect error: (.*)`) 150 - reHTTPStatus = regexp.MustCompile(`The requested URL returned error: (\d\d\d)`) 151 - reUnreachable = regexp.MustCompile(`Could not connect to server`) 152 - reRepoNotFound = regexp.MustCompile(`repository '.*?' not found`) 153 - ) 154 - 155 - // classifyCliError classifies git cli error message. It will return nil for unknown error messages 156 - func classifyCliError(stderr string) error { 157 - msg := strings.TrimSpace(stderr) 158 - if m := reTLSHandshake.FindStringSubmatch(msg); len(m) > 1 { 159 - return fmt.Errorf("%w: %s", ErrTLSHandshake, m[1]) 160 - } 161 - if m := reHTTPStatus.FindStringSubmatch(msg); len(m) > 1 { 162 - return fmt.Errorf("%w: %s", ErrHTTPStatus, m[1]) 163 - } 164 - switch { 165 - case reDNSFailure.MatchString(msg): 166 - return ErrDNSFailure 167 - case reCertExpired.MatchString(msg): 168 - return ErrCertExpired 169 - case reCertMismatch.MatchString(msg): 170 - return ErrCertMismatch 171 - case reUnreachable.MatchString(msg): 172 - return ErrUnreachable 173 - case reRepoNotFound.MatchString(msg): 174 - return ErrRepoNotFound 175 - } 176 - return nil 177 - } 178 - 179 - type GoGitMirrorManager struct { 180 - repoBasePath string 181 - knotUseSSL bool 182 - } 183 - 184 - func NewGoGitMirrorClient(repoBasePath string, knotUseSSL bool) *GoGitMirrorManager { 185 - return &GoGitMirrorManager{ 186 - repoBasePath, 187 - knotUseSSL, 188 - } 189 - } 190 - 191 - var _ GitMirrorManager = new(GoGitMirrorManager) 192 - 193 - func (c *GoGitMirrorManager) makeRepoPath(repo *models.Repo) string { 194 - return filepath.Join(c.repoBasePath, repo.Did.String(), repo.Rkey.String()) 195 - } 196 - 197 - func (c *GoGitMirrorManager) Exist(repo *models.Repo) (bool, error) { 198 - return isDir(c.makeRepoPath(repo)) 199 - } 200 - 201 - func (c *GoGitMirrorManager) RemoteSetUrl(ctx context.Context, repo *models.Repo) error { 202 - panic("unimplemented") 203 - } 204 - 205 - func (c *GoGitMirrorManager) Clone(ctx context.Context, repo *models.Repo) error { 206 - path := c.makeRepoPath(repo) 207 - url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL) 208 - if err != nil { 209 - return fmt.Errorf("constructing repo remote url: %w", err) 210 - } 211 - return c.clone(ctx, path, url) 212 - } 213 - 214 - func (c *GoGitMirrorManager) clone(ctx context.Context, path, url string) error { 215 - _, err := git.PlainCloneContext(ctx, path, true, &git.CloneOptions{ 216 - URL: url, 217 - Mirror: true, 218 - }) 219 - if err != nil && !errors.Is(err, transport.ErrEmptyRemoteRepository) { 220 - return fmt.Errorf("cloning repo: %w", err) 221 - } 222 - return nil 223 - } 224 - 225 - func (c *GoGitMirrorManager) Fetch(ctx context.Context, repo *models.Repo) error { 226 - path := c.makeRepoPath(repo) 227 - url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL) 228 - if err != nil { 229 - return fmt.Errorf("constructing repo remote url: %w", err) 230 - } 231 - 232 - return c.fetch(ctx, path, url) 233 - } 234 - 235 - func (c *GoGitMirrorManager) fetch(ctx context.Context, path, url string) error { 236 - gr, err := git.PlainOpen(path) 237 - if err != nil { 238 - return fmt.Errorf("opening local repo: %w", err) 239 - } 240 - if err := gr.FetchContext(ctx, &git.FetchOptions{ 241 - RemoteURL: url, 242 - RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec("+refs/*:refs/*")}, 243 - Force: true, 244 - Prune: true, 245 - }); err != nil { 246 - return fmt.Errorf("fetching reppo: %w", err) 247 - } 248 - return nil 249 - } 250 - 251 - func (c *GoGitMirrorManager) Sync(ctx context.Context, repo *models.Repo) error { 252 - path := c.makeRepoPath(repo) 253 - url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), c.knotUseSSL) 254 - if err != nil { 255 - return fmt.Errorf("constructing repo remote url: %w", err) 256 - } 257 - 258 - exist, err := isDir(path) 259 - if err != nil { 260 - return fmt.Errorf("checking repo path: %w", err) 261 - } 262 - if !exist { 263 - if err := c.clone(ctx, path, url); err != nil { 264 - return fmt.Errorf("cloning repo: %w", err) 265 - } 266 - } else { 267 - if err := c.fetch(ctx, path, url); err != nil { 268 - return fmt.Errorf("fetching repo: %w", err) 269 - } 270 - } 271 - return nil 272 - } 273 - 274 - func makeRepoRemoteUrl(knot, didSlashRepo string, knotUseSSL bool) (string, error) { 275 - if !strings.Contains(knot, "://") { 276 - if knotUseSSL { 277 - knot = "https://" + knot 278 - } else { 279 - knot = "http://" + knot 280 - } 281 - } 282 - 283 - u, err := url.Parse(knot) 284 - if err != nil { 285 - return "", err 286 - } 287 - 288 - if u.Scheme != "http" && u.Scheme != "https" { 289 - return "", fmt.Errorf("unsupported scheme: %s", u.Scheme) 290 - } 291 - 292 - u = u.JoinPath(didSlashRepo) 293 - return u.String(), nil 294 - } 295 - 296 - func isDir(path string) (bool, error) { 297 - info, err := os.Stat(path) 298 - if err == nil && info.IsDir() { 299 - return true, nil 300 - } 301 - if os.IsNotExist(err) { 302 - return false, nil 303 - } 304 - return false, err 305 - }
-138
knotmirror/knotmirror.go
··· 1 - package knotmirror 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - _ "net/http/pprof" 8 - "time" 9 - 10 - "github.com/go-chi/chi/v5" 11 - "github.com/prometheus/client_golang/prometheus/promhttp" 12 - "tangled.org/core/idresolver" 13 - "tangled.org/core/knotmirror/config" 14 - "tangled.org/core/knotmirror/db" 15 - "tangled.org/core/knotmirror/knotstream" 16 - "tangled.org/core/knotmirror/models" 17 - "tangled.org/core/knotmirror/xrpc" 18 - "tangled.org/core/log" 19 - ) 20 - 21 - func Run(ctx context.Context, cfg *config.Config) error { 22 - // make sure every services are cleaned up on fast return 23 - ctx, cancel := context.WithCancel(ctx) 24 - defer cancel() 25 - 26 - logger := log.FromContext(ctx) 27 - 28 - db, err := db.Make(ctx, cfg.DbUrl, 32) 29 - if err != nil { 30 - return fmt.Errorf("initializing db: %w", err) 31 - } 32 - 33 - resolver := idresolver.DefaultResolver(cfg.PlcUrl) 34 - 35 - // NOTE: using plain git-cli for clone/fetch as go-git is too memory-intensive. 36 - gitm := NewCliGitMirrorManager(cfg.GitRepoBasePath, cfg.KnotUseSSL) 37 - 38 - res, err := db.ExecContext(ctx, 39 - `update repos set state = $1 where state = $2`, 40 - models.RepoStateDesynchronized, 41 - models.RepoStateResyncing, 42 - ) 43 - if err != nil { 44 - return fmt.Errorf("clearing resyning states: %w", err) 45 - } 46 - rows, err := res.RowsAffected() 47 - if err != nil { 48 - return fmt.Errorf("getting affected rows: %w", err) 49 - } 50 - logger.Info(fmt.Sprintf("clearing resyning states: %d records updated", rows)) 51 - 52 - xrpc := xrpc.New(logger, cfg, db, resolver) 53 - knotstream := knotstream.NewKnotStream(logger, db, cfg) 54 - crawler := NewCrawler(logger, db) 55 - resyncer := NewResyncer(logger, db, gitm, cfg) 56 - adminpage := NewAdminServer(logger, db, resyncer) 57 - 58 - // maintain repository list with tap 59 - // NOTE: this can be removed once we introduce did-for-repo because then we can just listen to KnotStream for #identity events. 60 - tap := NewTapClient(logger, cfg, db, gitm, knotstream) 61 - 62 - // start http server 63 - go func() { 64 - logger.Info("starting http server", "addr", cfg.Listen) 65 - 66 - mux := chi.NewRouter() 67 - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 68 - w.Write([]byte("Welcome to a knotmirror server.\n")) 69 - }) 70 - mux.Mount("/xrpc", xrpc.Router()) 71 - 72 - if err := http.ListenAndServe(cfg.Listen, mux); err != nil { 73 - logger.Error("xrpc server failed", "error", err) 74 - } 75 - }() 76 - 77 - // start metrics endpoint 78 - go func() { 79 - metricsAddr := cfg.MetricsListen 80 - logger.Info("starting metrics server", "addr", metricsAddr) 81 - http.Handle("/metrics", promhttp.Handler()) 82 - if err := http.ListenAndServe(metricsAddr, nil); err != nil { 83 - logger.Error("metrics server failed", "error", err) 84 - } 85 - }() 86 - 87 - // start admin page endpoint 88 - go func() { 89 - logger.Info("starting admin server", "addr", cfg.AdminListen) 90 - if err := http.ListenAndServe(cfg.AdminListen, adminpage.Router()); err != nil { 91 - logger.Error("admin server failed", "error", err) 92 - } 93 - }() 94 - 95 - tap.Start(ctx) 96 - 97 - resyncer.Start(ctx) 98 - 99 - // periodically crawl the entire network to mirror the repositories 100 - crawler.Start(ctx) 101 - 102 - // listen to knotstream (currently we don't have relay for knots, so subscribe every known knots) 103 - knotstream.Start(ctx) 104 - 105 - svcErr := make(chan error, 1) 106 - if err := knotstream.ResubscribeAllHosts(ctx); err != nil { 107 - svcErr <- fmt.Errorf("resubscribing known hosts: %w", err) 108 - } 109 - 110 - logger.Info("startup complete") 111 - select { 112 - case <-ctx.Done(): 113 - logger.Info("received shutdown signal", "reason", ctx.Err()) 114 - case err := <-svcErr: 115 - if err != nil { 116 - logger.Error("service error", "error", err) 117 - } 118 - cancel() 119 - } 120 - 121 - logger.Info("shutting down knotmirror") 122 - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) 123 - defer shutdownCancel() 124 - 125 - var errs []error 126 - if err := knotstream.Shutdown(shutdownCtx); err != nil { 127 - errs = append(errs, err) 128 - } 129 - if err := db.Close(); err != nil { 130 - errs = append(errs, err) 131 - } 132 - for _, err := range errs { 133 - logger.Error("error during shutdown", "err", err) 134 - } 135 - 136 - logger.Info("shutdown complete") 137 - return nil 138 - }
-88
knotmirror/knotstream/knotstream.go
··· 1 - package knotstream 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - "log/slog" 8 - "time" 9 - 10 - "tangled.org/core/knotmirror/config" 11 - "tangled.org/core/knotmirror/db" 12 - "tangled.org/core/knotmirror/models" 13 - "tangled.org/core/log" 14 - ) 15 - 16 - type KnotStream struct { 17 - logger *slog.Logger 18 - db *sql.DB 19 - slurper *KnotSlurper 20 - } 21 - 22 - func NewKnotStream(l *slog.Logger, db *sql.DB, cfg *config.Config) *KnotStream { 23 - l = log.SubLogger(l, "knotstream") 24 - return &KnotStream{ 25 - logger: l, 26 - db: db, 27 - slurper: NewKnotSlurper(l, db, cfg.Slurper), 28 - } 29 - } 30 - 31 - func (s *KnotStream) Start(ctx context.Context) { 32 - go s.slurper.Run(ctx) 33 - } 34 - 35 - func (s *KnotStream) Shutdown(ctx context.Context) error { 36 - return s.slurper.Shutdown(ctx) 37 - } 38 - 39 - func (s *KnotStream) CheckIfSubscribed(hostname string) bool { 40 - return s.slurper.CheckIfSubscribed(hostname) 41 - } 42 - 43 - func (s *KnotStream) SubscribeHost(ctx context.Context, hostname string, noSSL bool) error { 44 - l := s.logger.With("hostname", hostname, "nossl", noSSL) 45 - l.Debug("subscribe") 46 - host, err := db.GetHost(ctx, s.db, hostname) 47 - if err != nil { 48 - return fmt.Errorf("loading host from db: %w", err) 49 - } 50 - 51 - if host == nil { 52 - host = &models.Host{ 53 - Hostname: hostname, 54 - NoSSL: noSSL, 55 - Status: models.HostStatusActive, 56 - LastSeq: 0, 57 - } 58 - 59 - if err := db.UpsertHost(ctx, s.db, host); err != nil { 60 - return fmt.Errorf("adding host to db: %w", err) 61 - } 62 - 63 - l.Info("adding new host subscription") 64 - } 65 - 66 - if host.Status == models.HostStatusBanned { 67 - return fmt.Errorf("cannot subscribe to banned knot") 68 - } 69 - return s.slurper.Subscribe(ctx, *host) 70 - } 71 - 72 - func (s *KnotStream) ResubscribeAllHosts(ctx context.Context) error { 73 - hosts, err := db.ListHosts(ctx, s.db, models.HostStatusActive) 74 - if err != nil { 75 - return fmt.Errorf("listing hosts: %w", err) 76 - } 77 - 78 - for _, host := range hosts { 79 - l := s.logger.With("hostname", host.Hostname) 80 - l.Info("re-subscribing to active host") 81 - if err := s.slurper.Subscribe(ctx, host); err != nil { 82 - l.Warn("failed to re-subscribe to host", "err", err) 83 - } 84 - // sleep for a very short period, so we don't open tons of sockets at the same time 85 - time.Sleep(1 * time.Millisecond) 86 - } 87 - return nil 88 - }
-28
knotmirror/knotstream/metrics.go
··· 1 - package knotstream 2 - 3 - import ( 4 - "github.com/prometheus/client_golang/prometheus" 5 - "github.com/prometheus/client_golang/prometheus/promauto" 6 - ) 7 - 8 - // KnotStream metrics 9 - var ( 10 - knotstreamEventsReceived = promauto.NewCounter(prometheus.CounterOpts{ 11 - Name: "knotmirror_knotstream_events_received_total", 12 - Help: "Total number of events received from knotstream", 13 - }) 14 - knotstreamEventsProcessed = promauto.NewCounter(prometheus.CounterOpts{ 15 - Name: "knotmirror_knotstream_events_processed_total", 16 - Help: "Total number of events successfully processed", 17 - }) 18 - knotstreamEventsSkipped = promauto.NewCounter(prometheus.CounterOpts{ 19 - Name: "knotmirror_knotstream_events_skipped_total", 20 - Help: "Total number of events skipped (not tracked)", 21 - }) 22 - ) 23 - 24 - // slurper metrics 25 - var connectedInbound = promauto.NewGauge(prometheus.GaugeOpts{ 26 - Name: "knotmirror_connected_inbound", 27 - Help: "Number of inbound knotstream we are consuming", 28 - })
-102
knotmirror/knotstream/scheduler.go
··· 1 - package knotstream 2 - 3 - import ( 4 - "context" 5 - "log/slog" 6 - "sync" 7 - "sync/atomic" 8 - "time" 9 - 10 - "tangled.org/core/log" 11 - ) 12 - 13 - type ParallelScheduler struct { 14 - concurrency int 15 - 16 - do func(ctx context.Context, task *Task) error 17 - 18 - feeder chan *Task 19 - lk sync.Mutex 20 - scheduled map[string][]*Task 21 - lastSeq atomic.Int64 22 - 23 - logger *slog.Logger 24 - } 25 - 26 - type Task struct { 27 - key string 28 - message []byte 29 - } 30 - 31 - func NewParallelScheduler(maxC int, ident string, do func(context.Context, *Task) error) *ParallelScheduler { 32 - return &ParallelScheduler{ 33 - concurrency: maxC, 34 - do: do, 35 - feeder: make(chan *Task), 36 - scheduled: make(map[string][]*Task), 37 - logger: log.New("parallel-scheduler"), 38 - } 39 - } 40 - 41 - func (s *ParallelScheduler) Start(ctx context.Context) { 42 - for range s.concurrency { 43 - go s.ForEach(ctx, s.do) 44 - } 45 - } 46 - 47 - func (s *ParallelScheduler) AddTask(ctx context.Context, task *Task) { 48 - s.lk.Lock() 49 - if st, ok := s.scheduled[task.key]; ok { 50 - // schedule task 51 - s.scheduled[task.key] = append(st, task) 52 - s.lk.Unlock() 53 - return 54 - } 55 - s.scheduled[task.key] = []*Task{} 56 - s.lk.Unlock() 57 - 58 - select { 59 - case <-ctx.Done(): 60 - return 61 - case s.feeder <- task: 62 - return 63 - } 64 - } 65 - 66 - func (s *ParallelScheduler) ForEach(ctx context.Context, fn func(context.Context, *Task) error) { 67 - for task := range s.feeder { 68 - for task != nil { 69 - select { 70 - case <-ctx.Done(): 71 - return 72 - default: 73 - } 74 - if err := fn(ctx, task); err != nil { 75 - s.logger.Error("event handler failed", "err", err) 76 - } 77 - 78 - s.lk.Lock() 79 - func() { 80 - rem, ok := s.scheduled[task.key] 81 - if !ok { 82 - s.logger.Error("should always have an 'active' entry if a worker is processing a job") 83 - } 84 - if len(rem) == 0 { 85 - delete(s.scheduled, task.key) 86 - task = nil 87 - } else { 88 - task = rem[0] 89 - s.scheduled[task.key] = rem[1:] 90 - } 91 - 92 - // TODO: update seq from received message 93 - s.lastSeq.Store(time.Now().UnixNano()) 94 - }() 95 - s.lk.Unlock() 96 - } 97 - } 98 - } 99 - 100 - func (s *ParallelScheduler) LastSeq() int64 { 101 - return s.lastSeq.Load() 102 - }
-334
knotmirror/knotstream/slurper.go
··· 1 - package knotstream 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "encoding/json" 7 - "fmt" 8 - "log/slog" 9 - "math/rand" 10 - "net/http" 11 - "sync" 12 - "time" 13 - 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/bluesky-social/indigo/util/ssrf" 16 - "github.com/carlmjohnson/versioninfo" 17 - "github.com/gorilla/websocket" 18 - "tangled.org/core/api/tangled" 19 - "tangled.org/core/knotmirror/config" 20 - "tangled.org/core/knotmirror/db" 21 - "tangled.org/core/knotmirror/models" 22 - "tangled.org/core/log" 23 - ) 24 - 25 - type KnotSlurper struct { 26 - logger *slog.Logger 27 - db *sql.DB 28 - cfg config.SlurperConfig 29 - 30 - subsLk sync.Mutex 31 - subs map[string]*subscription 32 - } 33 - 34 - func NewKnotSlurper(l *slog.Logger, db *sql.DB, cfg config.SlurperConfig) *KnotSlurper { 35 - return &KnotSlurper{ 36 - logger: log.SubLogger(l, "slurper"), 37 - db: db, 38 - cfg: cfg, 39 - subs: make(map[string]*subscription), 40 - } 41 - } 42 - 43 - func (s *KnotSlurper) Run(ctx context.Context) { 44 - for { 45 - select { 46 - case <-ctx.Done(): 47 - return 48 - case <-time.After(s.cfg.PersistCursorPeriod): 49 - if err := s.persistCursors(ctx); err != nil { 50 - s.logger.Error("failed to flush cursors", "err", err) 51 - } 52 - } 53 - } 54 - } 55 - 56 - func (s *KnotSlurper) CheckIfSubscribed(hostname string) bool { 57 - s.subsLk.Lock() 58 - defer s.subsLk.Unlock() 59 - 60 - _, ok := s.subs[hostname] 61 - return ok 62 - } 63 - 64 - func (s *KnotSlurper) Shutdown(ctx context.Context) error { 65 - s.logger.Info("starting shutdown host cursor flush") 66 - err := s.persistCursors(ctx) 67 - if err != nil { 68 - s.logger.Error("shutdown error", "err", err) 69 - } 70 - s.logger.Info("slurper shutdown complete") 71 - return err 72 - } 73 - 74 - func (s *KnotSlurper) persistCursors(ctx context.Context) error { 75 - // // gather cursor list from subscriptions and store them to DB 76 - // start := time.Now() 77 - 78 - s.subsLk.Lock() 79 - cursors := make([]models.HostCursor, len(s.subs)) 80 - i := 0 81 - for _, sub := range s.subs { 82 - cursors[i] = sub.HostCursor() 83 - i++ 84 - } 85 - s.subsLk.Unlock() 86 - 87 - err := db.StoreCursors(ctx, s.db, cursors) 88 - // s.logger.Info("finished persisting cursors", "count", len(cursors), "duration", time.Since(start).String(), "err", err) 89 - return err 90 - } 91 - 92 - func (s *KnotSlurper) Subscribe(ctx context.Context, host models.Host) error { 93 - s.subsLk.Lock() 94 - defer s.subsLk.Unlock() 95 - 96 - _, ok := s.subs[host.Hostname] 97 - if ok { 98 - return fmt.Errorf("already subscribed: %s", host.Hostname) 99 - } 100 - 101 - // TODO: include `cancel` function to kill subscription by hostname 102 - sub := &subscription{ 103 - hostname: host.Hostname, 104 - scheduler: NewParallelScheduler( 105 - s.cfg.ConcurrencyPerHost, 106 - host.Hostname, 107 - s.ProcessEvent, 108 - ), 109 - } 110 - s.subs[host.Hostname] = sub 111 - 112 - sub.scheduler.Start(ctx) 113 - go s.subscribeWithRedialer(ctx, host, sub) 114 - return nil 115 - } 116 - 117 - func (s *KnotSlurper) subscribeWithRedialer(ctx context.Context, host models.Host, sub *subscription) { 118 - l := s.logger.With("host", host.Hostname) 119 - 120 - dialer := websocket.Dialer{ 121 - HandshakeTimeout: time.Second * 5, 122 - } 123 - 124 - // if this isn't a localhost / private connection, then we should enable SSRF protections 125 - if !host.NoSSL { 126 - netDialer := ssrf.PublicOnlyDialer() 127 - dialer.NetDialContext = netDialer.DialContext 128 - } 129 - 130 - cursor := host.LastSeq 131 - 132 - connectedInbound.Inc() 133 - defer connectedInbound.Dec() 134 - 135 - var backoff int 136 - for { 137 - select { 138 - case <-ctx.Done(): 139 - return 140 - default: 141 - } 142 - u := host.LegacyEventsURL(cursor) 143 - l.Debug("made url with cursor", "cursor", cursor, "url", u) 144 - 145 - // NOTE: manual backoff retry implementation to explicitly handle fails 146 - hdr := make(http.Header) 147 - hdr.Add("User-Agent", userAgent()) 148 - conn, resp, err := dialer.DialContext(ctx, u, hdr) 149 - if err != nil { 150 - l.Warn("dialing failed", "err", err, "backoff", backoff) 151 - time.Sleep(sleepForBackoff(backoff)) 152 - backoff++ 153 - if backoff > 30 { 154 - l.Warn("host does not appear to be online, disabling for now") 155 - host.Status = models.HostStatusOffline 156 - if err := db.UpsertHost(ctx, s.db, &host); err != nil { 157 - l.Error("failed to update host status", "err", err) 158 - } 159 - return 160 - } 161 - continue 162 - } 163 - 164 - l.Debug("knot event subscription response", "code", resp.StatusCode, "url", u) 165 - 166 - if err := s.handleConnection(ctx, conn, sub); err != nil { 167 - // TODO: measure the last N connection error times and if they're coming too fast reconnect slower or don't reconnect and wait for requestCrawl 168 - l.Warn("host connection failed", "err", err, "backoff", backoff) 169 - } 170 - 171 - updatedCursor := sub.LastSeq() 172 - didProgress := updatedCursor > cursor 173 - l.Debug("cursor compare", "cursor", cursor, "updatedCursor", updatedCursor, "didProgress", didProgress) 174 - if cursor == 0 || didProgress { 175 - cursor = updatedCursor 176 - backoff = 0 177 - 178 - batch := []models.HostCursor{sub.HostCursor()} 179 - if err := db.StoreCursors(ctx, s.db, batch); err != nil { 180 - l.Error("failed to store cursors", "err", err) 181 - } 182 - } 183 - } 184 - } 185 - 186 - // handleConnection handles websocket connection. 187 - // Schedules task from received event and return when connection is closed 188 - func (s *KnotSlurper) handleConnection(ctx context.Context, conn *websocket.Conn, sub *subscription) error { 189 - // ping on every 30s 190 - ctx, cancel := context.WithCancel(ctx) 191 - defer cancel() // close the background ping job on connection close 192 - go func() { 193 - t := time.NewTicker(30 * time.Second) 194 - defer t.Stop() 195 - failcount := 0 196 - 197 - for { 198 - select { 199 - case <-t.C: 200 - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second*10)); err != nil { 201 - s.logger.Warn("failed to ping", "err", err) 202 - failcount++ 203 - if failcount >= 4 { 204 - s.logger.Error("too many ping fails", "count", failcount) 205 - _ = conn.Close() 206 - return 207 - } 208 - } else { 209 - failcount = 0 // ok ping 210 - } 211 - case <-ctx.Done(): 212 - _ = conn.Close() 213 - return 214 - } 215 - } 216 - }() 217 - 218 - conn.SetPingHandler(func(message string) error { 219 - err := conn.WriteControl(websocket.PongMessage, []byte(message), time.Now().Add(time.Minute)) 220 - if err == websocket.ErrCloseSent { 221 - return nil 222 - } 223 - return err 224 - }) 225 - conn.SetPongHandler(func(_ string) error { 226 - if err := conn.SetReadDeadline(time.Now().Add(time.Minute)); err != nil { 227 - s.logger.Error("failed to set read deadline", "err", err) 228 - } 229 - return nil 230 - }) 231 - 232 - for { 233 - select { 234 - case <-ctx.Done(): 235 - return ctx.Err() 236 - default: 237 - } 238 - msgType, msg, err := conn.ReadMessage() 239 - if err != nil { 240 - return err 241 - } 242 - 243 - if msgType != websocket.TextMessage { 244 - continue 245 - } 246 - 247 - sub.scheduler.AddTask(ctx, &Task{ 248 - key: sub.hostname, // TODO: replace to repository AT-URI for better concurrency 249 - message: msg, 250 - }) 251 - } 252 - } 253 - 254 - type LegacyGitEvent struct { 255 - Rkey string 256 - Nsid string 257 - Event tangled.GitRefUpdate 258 - } 259 - 260 - func (s *KnotSlurper) ProcessEvent(ctx context.Context, task *Task) error { 261 - var legacyMessage LegacyGitEvent 262 - if err := json.Unmarshal(task.message, &legacyMessage); err != nil { 263 - return fmt.Errorf("unmarshaling message: %w", err) 264 - } 265 - 266 - if err := s.ProcessLegacyGitRefUpdate(ctx, &legacyMessage); err != nil { 267 - return fmt.Errorf("processing gitRefUpdate: %w", err) 268 - } 269 - return nil 270 - } 271 - 272 - func (s *KnotSlurper) ProcessLegacyGitRefUpdate(ctx context.Context, evt *LegacyGitEvent) error { 273 - knotstreamEventsReceived.Inc() 274 - 275 - curr, err := db.GetRepoByName(ctx, s.db, syntax.DID(evt.Event.RepoDid), evt.Event.RepoName) 276 - if err != nil { 277 - return fmt.Errorf("failed to get repo '%s': %w", evt.Event.RepoDid+"/"+evt.Event.RepoName, err) 278 - } 279 - if curr == nil { 280 - // if repo doesn't exist in DB, just ignore the event. That repo is unknown. 281 - // 282 - // Normally did+name is already enough to perform git-fetch as that's 283 - // what needed to fetch the repository. 284 - // But we want to store that in did/rkey in knot-mirror. 285 - // Therefore, we should ignore when the repository is unknown. 286 - // Hopefully crawler will sync it later. 287 - s.logger.Warn("skipping event from unknown repo", "did/repo", evt.Event.RepoDid+"/"+evt.Event.RepoName) 288 - knotstreamEventsSkipped.Inc() 289 - return nil 290 - } 291 - l := s.logger.With("repoAt", curr.AtUri()) 292 - 293 - // TODO: should plan resync to resyncBuffer on RepoStateResyncing 294 - if curr.State != models.RepoStateActive { 295 - l.Debug("skipping non-active repo") 296 - knotstreamEventsSkipped.Inc() 297 - return nil 298 - } 299 - 300 - if curr.GitRev != "" && evt.Rkey <= curr.GitRev.String() { 301 - l.Debug("skipping replayed event", "event.Rkey", evt.Rkey, "currentRev", curr.GitRev) 302 - knotstreamEventsSkipped.Inc() 303 - return nil 304 - } 305 - 306 - // if curr.State == models.RepoStateResyncing { 307 - // firehoseEventsSkipped.Inc() 308 - // return fp.events.addToResyncBuffer(ctx, commit) 309 - // } 310 - 311 - // can't skip anything, update repo state 312 - if err := db.UpdateRepoState(ctx, s.db, curr.Did, curr.Rkey, models.RepoStateDesynchronized); err != nil { 313 - return err 314 - } 315 - 316 - l.Info("event processed", "eventRev", evt.Rkey) 317 - 318 - knotstreamEventsProcessed.Inc() 319 - return nil 320 - } 321 - 322 - func userAgent() string { 323 - return fmt.Sprintf("knotmirror/%s", versioninfo.Short()) 324 - } 325 - 326 - func sleepForBackoff(b int) time.Duration { 327 - if b == 0 { 328 - return 0 329 - } 330 - if b < 10 { 331 - return time.Millisecond * time.Duration((50*b)+rand.Intn(500)) 332 - } 333 - return time.Second * 30 334 - }
-22
knotmirror/knotstream/subscription.go
··· 1 - package knotstream 2 - 3 - import "tangled.org/core/knotmirror/models" 4 - 5 - // subscription represents websocket connection with that host 6 - type subscription struct { 7 - hostname string 8 - 9 - // embedded parallel job scheduler 10 - scheduler *ParallelScheduler 11 - } 12 - 13 - func (s *subscription) LastSeq() int64 { 14 - return s.scheduler.LastSeq() 15 - } 16 - 17 - func (s *subscription) HostCursor() models.HostCursor { 18 - return models.HostCursor{ 19 - Hostname: s.hostname, 20 - LastSeq: s.LastSeq(), 21 - } 22 - }
-29
knotmirror/metrics.go
··· 1 - package knotmirror 2 - 3 - import ( 4 - "github.com/prometheus/client_golang/prometheus" 5 - "github.com/prometheus/client_golang/prometheus/promauto" 6 - ) 7 - 8 - // Resync metrics 9 - var ( 10 - // TODO: 11 - // - working / waiting resycner counts 12 - resyncsStarted = promauto.NewCounter(prometheus.CounterOpts{ 13 - Name: "knotmirror_resyncs_started_total", 14 - Help: "Total number of repo resyncs started", 15 - }) 16 - resyncsCompleted = promauto.NewCounter(prometheus.CounterOpts{ 17 - Name: "knotmirror_resyncs_completed_total", 18 - Help: "Total number of repo resyncs completed", 19 - }) 20 - resyncsFailed = promauto.NewCounter(prometheus.CounterOpts{ 21 - Name: "knotmirror_resyncs_failed_total", 22 - Help: "Total number of repo resyncs failed", 23 - }) 24 - resyncDuration = promauto.NewHistogram(prometheus.HistogramOpts{ 25 - Name: "knotmirror_resync_duration_seconds", 26 - Help: "Duration of repo resync operations", 27 - Buckets: prometheus.ExponentialBuckets(0.1, 2, 12), 28 - }) 29 - )
-110
knotmirror/models/models.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - 6 - "github.com/bluesky-social/indigo/atproto/syntax" 7 - "tangled.org/core/api/tangled" 8 - ) 9 - 10 - type Repo struct { 11 - Did syntax.DID 12 - Rkey syntax.RecordKey 13 - Cid *syntax.CID 14 - // content of tangled.Repo 15 - Name string 16 - KnotDomain string 17 - 18 - GitRev syntax.TID // last processed git.refUpdate revision 19 - RepoSha string // sha256 sum of git refs (to avoid no-op git fetch) 20 - State RepoState 21 - ErrorMsg string 22 - RetryCount int 23 - RetryAfter int64 // Unix timestamp (seconds) 24 - } 25 - 26 - func (r *Repo) AtUri() syntax.ATURI { 27 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 28 - } 29 - 30 - func (r *Repo) DidSlashRepo() string { 31 - return fmt.Sprintf("%s/%s", r.Did, r.Name) 32 - } 33 - 34 - type RepoState string 35 - 36 - const ( 37 - RepoStatePending RepoState = "pending" 38 - RepoStateDesynchronized RepoState = "desynchronized" 39 - RepoStateResyncing RepoState = "resyncing" 40 - RepoStateActive RepoState = "active" 41 - RepoStateSuspended RepoState = "suspended" 42 - RepoStateError RepoState = "error" 43 - ) 44 - 45 - var AllRepoStates = []RepoState{ 46 - RepoStatePending, 47 - RepoStateDesynchronized, 48 - RepoStateResyncing, 49 - RepoStateActive, 50 - RepoStateSuspended, 51 - RepoStateError, 52 - } 53 - 54 - func (s RepoState) IsResyncing() bool { 55 - return s == RepoStateResyncing 56 - } 57 - 58 - type HostCursor struct { 59 - Hostname string 60 - LastSeq int64 61 - } 62 - 63 - type Host struct { 64 - Hostname string 65 - NoSSL bool 66 - Status HostStatus 67 - LastSeq int64 68 - } 69 - 70 - type HostStatus string 71 - 72 - const ( 73 - HostStatusActive HostStatus = "active" 74 - HostStatusIdle HostStatus = "idle" 75 - HostStatusOffline HostStatus = "offline" 76 - HostStatusThrottled HostStatus = "throttled" 77 - HostStatusBanned HostStatus = "banned" 78 - ) 79 - 80 - var AllHostStatuses = []HostStatus{ 81 - HostStatusActive, 82 - HostStatusIdle, 83 - HostStatusOffline, 84 - HostStatusThrottled, 85 - HostStatusBanned, 86 - } 87 - 88 - // func (h *Host) SubscribeGitRefsURL(cursor int64) string { 89 - // scheme := "wss" 90 - // if h.NoSSL { 91 - // scheme = "ws" 92 - // } 93 - // u := fmt.Sprintf("%s://%s/xrpc/%s", scheme, h.Hostname, tangled.SubscribeGitRefsNSID) 94 - // if cursor > 0 { 95 - // u = fmt.Sprintf("%s?cursor=%d", u, h.LastSeq) 96 - // } 97 - // return u 98 - // } 99 - 100 - func (h *Host) LegacyEventsURL(cursor int64) string { 101 - scheme := "wss" 102 - if h.NoSSL { 103 - scheme = "ws" 104 - } 105 - u := fmt.Sprintf("%s://%s/events", scheme, h.Hostname) 106 - if cursor > 0 { 107 - u = fmt.Sprintf("%s?cursor=%d", u, cursor) 108 - } 109 - return u 110 - }
-8
knotmirror/readme.md
··· 1 - # KnotMirror 2 - 3 - KnotMirror is a git mirror service for all known repos. Heavily inspired by [indigo/relay] and [indigo/tap]. 4 - 5 - KnotMirror syncs repo list using tap and subscribe to all known knots as KnotStream. 6 - 7 - [indigo/relay]: https://github.com/bluesky-social/indigo/tree/main/cmd/relay 8 - [indigo/tap]: https://github.com/bluesky-social/indigo/tree/main/cmd/tap
-356
knotmirror/resyncer.go
··· 1 - package knotmirror 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "errors" 7 - "fmt" 8 - "log/slog" 9 - "math/rand" 10 - "net/http" 11 - "net/url" 12 - "strings" 13 - "sync" 14 - "time" 15 - 16 - "github.com/bluesky-social/indigo/atproto/syntax" 17 - "tangled.org/core/knotmirror/config" 18 - "tangled.org/core/knotmirror/db" 19 - "tangled.org/core/knotmirror/models" 20 - "tangled.org/core/log" 21 - ) 22 - 23 - type Resyncer struct { 24 - logger *slog.Logger 25 - db *sql.DB 26 - gitm GitMirrorManager 27 - 28 - claimJobMu sync.Mutex 29 - 30 - runningJobs map[syntax.ATURI]context.CancelFunc 31 - runningJobsMu sync.Mutex 32 - 33 - repoFetchTimeout time.Duration 34 - manualResyncTimeout time.Duration 35 - parallelism int 36 - 37 - knotBackoff map[string]time.Time 38 - knotBackoffMu sync.RWMutex 39 - } 40 - 41 - func NewResyncer(l *slog.Logger, db *sql.DB, gitm GitMirrorManager, cfg *config.Config) *Resyncer { 42 - return &Resyncer{ 43 - logger: log.SubLogger(l, "resyncer"), 44 - db: db, 45 - gitm: gitm, 46 - 47 - runningJobs: make(map[syntax.ATURI]context.CancelFunc), 48 - 49 - repoFetchTimeout: cfg.GitRepoFetchTimeout, 50 - manualResyncTimeout: 30 * time.Minute, 51 - parallelism: cfg.ResyncParallelism, 52 - 53 - knotBackoff: make(map[string]time.Time), 54 - } 55 - } 56 - 57 - func (r *Resyncer) Start(ctx context.Context) { 58 - for i := 0; i < r.parallelism; i++ { 59 - go r.runResyncWorker(ctx, i) 60 - } 61 - } 62 - 63 - func (r *Resyncer) runResyncWorker(ctx context.Context, workerID int) { 64 - l := r.logger.With("worker", workerID) 65 - for { 66 - select { 67 - case <-ctx.Done(): 68 - l.Info("resync worker shutting down", "error", ctx.Err()) 69 - return 70 - default: 71 - } 72 - repoAt, found, err := r.claimResyncJob(ctx) 73 - if err != nil { 74 - l.Error("failed to claim resync job", "error", err) 75 - time.Sleep(time.Second) 76 - continue 77 - } 78 - if !found { 79 - time.Sleep(time.Second) 80 - continue 81 - } 82 - l.Info("processing resync", "aturi", repoAt) 83 - if err := r.resyncRepo(ctx, repoAt); err != nil { 84 - l.Error("resync failed", "aturi", repoAt, "error", err) 85 - } 86 - } 87 - } 88 - 89 - func (r *Resyncer) registerRunning(repo syntax.ATURI, cancel context.CancelFunc) { 90 - r.runningJobsMu.Lock() 91 - defer r.runningJobsMu.Unlock() 92 - 93 - if _, exists := r.runningJobs[repo]; exists { 94 - return 95 - } 96 - r.runningJobs[repo] = cancel 97 - } 98 - 99 - func (r *Resyncer) unregisterRunning(repo syntax.ATURI) { 100 - r.runningJobsMu.Lock() 101 - defer r.runningJobsMu.Unlock() 102 - 103 - delete(r.runningJobs, repo) 104 - } 105 - 106 - func (r *Resyncer) CancelResyncJob(repo syntax.ATURI) { 107 - r.runningJobsMu.Lock() 108 - defer r.runningJobsMu.Unlock() 109 - 110 - cancel, ok := r.runningJobs[repo] 111 - if !ok { 112 - return 113 - } 114 - delete(r.runningJobs, repo) 115 - cancel() 116 - } 117 - 118 - // TriggerResyncJob manually triggers the resync job 119 - func (r *Resyncer) TriggerResyncJob(ctx context.Context, repoAt syntax.ATURI) error { 120 - repo, err := db.GetRepoByAtUri(ctx, r.db, repoAt) 121 - if err != nil { 122 - return fmt.Errorf("failed to get repo: %w", err) 123 - } 124 - if repo == nil { 125 - return fmt.Errorf("repo not found: %s", repoAt) 126 - } 127 - 128 - if repo.State == models.RepoStateResyncing { 129 - return fmt.Errorf("repo already resyncing") 130 - } 131 - 132 - repo.State = models.RepoStatePending 133 - repo.RetryAfter = -1 // resyncer will prioritize this 134 - 135 - if err := db.UpsertRepo(ctx, r.db, repo); err != nil { 136 - return fmt.Errorf("updating repo state to pending %w", err) 137 - } 138 - return nil 139 - } 140 - 141 - func (r *Resyncer) claimResyncJob(ctx context.Context) (syntax.ATURI, bool, error) { 142 - // use mutex to prevent duplicated jobs 143 - r.claimJobMu.Lock() 144 - defer r.claimJobMu.Unlock() 145 - 146 - var repoAt syntax.ATURI 147 - now := time.Now().Unix() 148 - if err := r.db.QueryRowContext(ctx, 149 - `update repos 150 - set state = $1 151 - where at_uri = ( 152 - select at_uri from repos 153 - where state in ($2, $3, $4) 154 - and (retry_after = -1 or retry_after = 0 or retry_after < $5) 155 - order by 156 - (retry_after = -1) desc, 157 - (retry_after = 0) desc, 158 - retry_after 159 - limit 1 160 - ) 161 - returning at_uri 162 - `, 163 - models.RepoStateResyncing, 164 - models.RepoStatePending, models.RepoStateDesynchronized, models.RepoStateError, 165 - now, 166 - ).Scan(&repoAt); err != nil { 167 - if errors.Is(err, sql.ErrNoRows) { 168 - return "", false, nil 169 - } 170 - return "", false, err 171 - } 172 - 173 - return repoAt, true, nil 174 - } 175 - 176 - func (r *Resyncer) resyncRepo(ctx context.Context, repoAt syntax.ATURI) error { 177 - // ctx, span := tracer.Start(ctx, "resyncRepo") 178 - // span.SetAttributes(attribute.String("aturi", repoAt)) 179 - // defer span.End() 180 - 181 - resyncsStarted.Inc() 182 - startTime := time.Now() 183 - 184 - jobCtx, cancel := context.WithCancel(ctx) 185 - r.registerRunning(repoAt, cancel) 186 - defer r.unregisterRunning(repoAt) 187 - 188 - success, err := r.doResync(jobCtx, repoAt) 189 - if !success { 190 - resyncsFailed.Inc() 191 - resyncDuration.Observe(time.Since(startTime).Seconds()) 192 - return r.handleResyncFailure(ctx, repoAt, err) 193 - } 194 - 195 - resyncsCompleted.Inc() 196 - resyncDuration.Observe(time.Since(startTime).Seconds()) 197 - return nil 198 - } 199 - 200 - func (r *Resyncer) doResync(ctx context.Context, repoAt syntax.ATURI) (bool, error) { 201 - // ctx, span := tracer.Start(ctx, "doResync") 202 - // span.SetAttributes(attribute.String("aturi", repoAt)) 203 - // defer span.End() 204 - 205 - repo, err := db.GetRepoByAtUri(ctx, r.db, repoAt) 206 - if err != nil { 207 - return false, fmt.Errorf("failed to get repo: %w", err) 208 - } 209 - if repo == nil { // untracked repo, skip 210 - return false, nil 211 - } 212 - 213 - r.knotBackoffMu.RLock() 214 - backoffUntil, inBackoff := r.knotBackoff[repo.KnotDomain] 215 - r.knotBackoffMu.RUnlock() 216 - if inBackoff && time.Now().Before(backoffUntil) { 217 - return false, nil 218 - } 219 - 220 - // HACK: check knot reachability with short timeout before running actual fetch. 221 - // This is crucial as git-cli doesn't support http connection timeout. 222 - // `http.lowSpeedTime` is only applied _after_ the connection. 223 - if err := r.checkKnotReachability(ctx, repo); err != nil { 224 - if isRateLimitError(err) { 225 - r.knotBackoffMu.Lock() 226 - r.knotBackoff[repo.KnotDomain] = time.Now().Add(10 * time.Second) 227 - r.knotBackoffMu.Unlock() 228 - return false, nil 229 - } 230 - // TODO: suspend repo on 404. KnotStream updates will change the repo state back online 231 - return false, fmt.Errorf("knot unreachable: %w", err) 232 - } 233 - 234 - timeout := r.repoFetchTimeout 235 - if repo.RetryAfter == -1 { 236 - timeout = r.manualResyncTimeout 237 - } 238 - fetchCtx, cancel := context.WithTimeout(ctx, timeout) 239 - defer cancel() 240 - 241 - if err := r.gitm.Sync(fetchCtx, repo); err != nil { 242 - return false, err 243 - } 244 - 245 - // repo.GitRev = <processed git.refUpdate revision> 246 - // repo.RepoSha = <sha256 sum of git refs> 247 - repo.State = models.RepoStateActive 248 - repo.ErrorMsg = "" 249 - repo.RetryCount = 0 250 - repo.RetryAfter = 0 251 - if err := db.UpsertRepo(ctx, r.db, repo); err != nil { 252 - return false, fmt.Errorf("updating repo state to active %w", err) 253 - } 254 - return true, nil 255 - } 256 - 257 - type knotStatusError struct { 258 - StatusCode int 259 - } 260 - 261 - func (ke *knotStatusError) Error() string { 262 - return fmt.Sprintf("request failed with status code (HTTP %d)", ke.StatusCode) 263 - } 264 - 265 - func isRateLimitError(err error) bool { 266 - var knotErr *knotStatusError 267 - if errors.As(err, &knotErr) { 268 - return knotErr.StatusCode == http.StatusTooManyRequests 269 - } 270 - return false 271 - } 272 - 273 - // checkKnotReachability checks if Knot is reachable and is valid git remote server 274 - func (r *Resyncer) checkKnotReachability(ctx context.Context, repo *models.Repo) error { 275 - repoUrl, err := makeRepoRemoteUrl(repo.KnotDomain, repo.DidSlashRepo(), true) 276 - if err != nil { 277 - return err 278 - } 279 - 280 - repoUrl += "/info/refs?service=git-upload-pack" 281 - 282 - client := http.Client{ 283 - Timeout: 30 * time.Second, 284 - } 285 - req, err := http.NewRequestWithContext(ctx, "GET", repoUrl, nil) 286 - if err != nil { 287 - return err 288 - } 289 - req.Header.Set("User-Agent", "git/2.x") 290 - req.Header.Set("Accept", "*/*") 291 - 292 - resp, err := client.Do(req) 293 - if err != nil { 294 - var uerr *url.Error 295 - if errors.As(err, &uerr) { 296 - return fmt.Errorf("request failed: %w", uerr.Unwrap()) 297 - } 298 - return fmt.Errorf("request failed: %w", err) 299 - } 300 - defer resp.Body.Close() 301 - 302 - if resp.StatusCode != http.StatusOK { 303 - return &knotStatusError{resp.StatusCode} 304 - } 305 - 306 - // check if target is git server 307 - ct := resp.Header.Get("Content-Type") 308 - if !strings.Contains(ct, "application/x-git-upload-pack-advertisement") { 309 - return fmt.Errorf("unexpected content-type: %s", ct) 310 - } 311 - 312 - return nil 313 - } 314 - 315 - func (r *Resyncer) handleResyncFailure(ctx context.Context, repoAt syntax.ATURI, err error) error { 316 - r.logger.Debug("handleResyncFailure", "at_uri", repoAt, "err", err) 317 - var state models.RepoState 318 - var errMsg string 319 - if err == nil { 320 - state = models.RepoStateDesynchronized 321 - errMsg = "" 322 - } else { 323 - state = models.RepoStateError 324 - errMsg = err.Error() 325 - } 326 - 327 - repo, err := db.GetRepoByAtUri(ctx, r.db, repoAt) 328 - if err != nil { 329 - return fmt.Errorf("failed to get repo: %w", err) 330 - } 331 - if repo == nil { 332 - return fmt.Errorf("failed to get repo. repo '%s' doesn't exist in db", repoAt) 333 - } 334 - 335 - // start a 1 min & go up to 1 hr between retries 336 - var retryCount = repo.RetryCount + 1 337 - var retryAfter = time.Now().Add(backoff(retryCount, 60) * 60).Unix() 338 - 339 - // remove null bytes 340 - errMsg = strings.ReplaceAll(errMsg, "\x00", "") 341 - 342 - repo.State = state 343 - repo.ErrorMsg = errMsg 344 - repo.RetryCount = retryCount 345 - repo.RetryAfter = retryAfter 346 - if err := db.UpsertRepo(ctx, r.db, repo); err != nil { 347 - return fmt.Errorf("failed to update repo state: %w", err) 348 - } 349 - return nil 350 - } 351 - 352 - func backoff(retries int, max int) time.Duration { 353 - dur := min(1<<retries, max) 354 - jitter := time.Millisecond * time.Duration(rand.Intn(1000)) 355 - return time.Second*time.Duration(dur) + jitter 356 - }
-152
knotmirror/tapclient.go
··· 1 - package knotmirror 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "encoding/json" 7 - "fmt" 8 - "log/slog" 9 - "net/netip" 10 - "net/url" 11 - "time" 12 - 13 - "tangled.org/core/api/tangled" 14 - "tangled.org/core/knotmirror/config" 15 - "tangled.org/core/knotmirror/db" 16 - "tangled.org/core/knotmirror/knotstream" 17 - "tangled.org/core/knotmirror/models" 18 - "tangled.org/core/log" 19 - "tangled.org/core/tapc" 20 - ) 21 - 22 - type Tap struct { 23 - logger *slog.Logger 24 - cfg *config.Config 25 - tap tapc.Client 26 - db *sql.DB 27 - gitm GitMirrorManager 28 - ks *knotstream.KnotStream 29 - } 30 - 31 - func NewTapClient(l *slog.Logger, cfg *config.Config, db *sql.DB, gitm GitMirrorManager, ks *knotstream.KnotStream) *Tap { 32 - return &Tap{ 33 - logger: log.SubLogger(l, "tapclient"), 34 - cfg: cfg, 35 - tap: tapc.NewClient(cfg.TapUrl, ""), 36 - db: db, 37 - gitm: gitm, 38 - ks: ks, 39 - } 40 - } 41 - 42 - func (t *Tap) Start(ctx context.Context) { 43 - // TODO: better reconnect logic 44 - go func() { 45 - for { 46 - t.tap.Connect(ctx, &tapc.SimpleIndexer{ 47 - EventHandler: t.processEvent, 48 - }) 49 - time.Sleep(time.Second) 50 - } 51 - }() 52 - } 53 - 54 - func (t *Tap) processEvent(ctx context.Context, evt tapc.Event) error { 55 - l := t.logger.With("component", "tapIndexer") 56 - 57 - var err error 58 - switch evt.Type { 59 - case tapc.EvtRecord: 60 - switch evt.Record.Collection.String() { 61 - case tangled.RepoNSID: 62 - err = t.processRepo(ctx, evt.Record) 63 - } 64 - } 65 - 66 - if err != nil { 67 - l.Error("failed to process message. will retry later", "event.ID", evt.ID, "err", err) 68 - return err 69 - } 70 - return nil 71 - } 72 - 73 - func (t *Tap) processRepo(ctx context.Context, evt *tapc.RecordEventData) error { 74 - switch evt.Action { 75 - case tapc.RecordCreateAction, tapc.RecordUpdateAction: 76 - record := tangled.Repo{} 77 - if err := json.Unmarshal(evt.Record, &record); err != nil { 78 - return fmt.Errorf("parsing record: %w", err) 79 - } 80 - 81 - status := models.RepoStatePending 82 - errMsg := "" 83 - u, err := url.Parse("http://" + record.Knot) // parsing with fake scheme 84 - if err != nil { 85 - status = models.RepoStateSuspended 86 - errMsg = "failed to parse knot url" 87 - } else if t.cfg.KnotSSRF && isPrivate(u.Hostname()) { 88 - status = models.RepoStateSuspended 89 - errMsg = "suspending non-public knot" 90 - } 91 - 92 - repo := &models.Repo{ 93 - Did: evt.Did, 94 - Rkey: evt.Rkey, 95 - Cid: evt.CID, 96 - Name: record.Name, 97 - KnotDomain: record.Knot, 98 - State: status, 99 - ErrorMsg: errMsg, 100 - RetryAfter: 0, // clear retry info 101 - RetryCount: 0, 102 - } 103 - 104 - if evt.Action == tapc.RecordUpdateAction { 105 - exist, err := t.gitm.Exist(repo) 106 - if err != nil { 107 - return fmt.Errorf("checking git repo existance: %w", err) 108 - } 109 - if exist { 110 - // update git repo remote url 111 - if err := t.gitm.RemoteSetUrl(ctx, repo); err != nil { 112 - return fmt.Errorf("updating git repo remote url: %w", err) 113 - } 114 - } 115 - } 116 - 117 - if err := db.UpsertRepo(ctx, t.db, repo); err != nil { 118 - return fmt.Errorf("upserting repo to db: %w", err) 119 - } 120 - 121 - if !t.ks.CheckIfSubscribed(record.Knot) { 122 - if err := t.ks.SubscribeHost(ctx, record.Knot, !t.cfg.KnotUseSSL); err != nil { 123 - return fmt.Errorf("subscribing to knot: %w", err) 124 - } 125 - } 126 - 127 - case tapc.RecordDeleteAction: 128 - if err := db.DeleteRepo(ctx, t.db, evt.Did, evt.Rkey); err != nil { 129 - return fmt.Errorf("deleting repo from db: %w", err) 130 - } 131 - } 132 - return nil 133 - } 134 - 135 - // isPrivate checks if host is private network. It doesn't perform DNS resolution 136 - func isPrivate(host string) bool { 137 - if host == "localhost" { 138 - return true 139 - } 140 - addr, err := netip.ParseAddr(host) 141 - if err != nil { 142 - return false 143 - } 144 - return isPrivateAddr(addr) 145 - } 146 - 147 - func isPrivateAddr(addr netip.Addr) bool { 148 - return addr.IsLoopback() || 149 - addr.IsPrivate() || 150 - addr.IsLinkLocalUnicast() || 151 - addr.IsLinkLocalMulticast() 152 - }
-55
knotmirror/templates/base.html
··· 1 - {{define "base"}} 2 - <!DOCTYPE html> 3 - <html> 4 - <head> 5 - <title>KnotMirror Admin</title> 6 - <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script> 7 - <style> 8 - nav { margin-bottom: 20px; border-bottom: 1px solid #ccc; padding: 10px 0; } 9 - nav a { margin-right: 15px; } 10 - table { width: 100%; border-collapse: collapse; } 11 - th, td { text-align: left; padding: 8px; border: 1px solid #ddd; } 12 - .pagination { margin-top: 20px; } 13 - .filters { background: #f4f4f4; padding: 15px; margin-bottom: 20px; } 14 - #notifications { 15 - position: fixed; 16 - bottom: 8px; 17 - right: 8px; 18 - z-index: 1000; 19 - pointer-events: none; 20 - } 21 - .notif { 22 - pointer-events: auto; 23 - background: #333; 24 - color: #fff; 25 - padding: 2px 4px; 26 - margin: 4px 0; 27 - opacity: 0.95; 28 - } 29 - .notif.warn { background: #ed6c02 } 30 - .notif.error { background: #d32f2f } 31 - </style> 32 - </head> 33 - <body> 34 - <nav> 35 - <a href="/repos">Repositories</a> 36 - <a href="/hosts">Knot Hosts</a> 37 - </nav> 38 - <main id="main"> 39 - {{template "content" .}} 40 - </main> 41 - <div id="notifications"></div> 42 - <script> 43 - document.body.addEventListener("htmx:oobBeforeSwap", (evt) => { 44 - evt.detail.fragment.querySelectorAll(".notif").forEach((el) => { 45 - console.debug("set timeout to notif element", el) 46 - setTimeout(() => { 47 - console.debug("clearing notif element", el); 48 - el.remove(); 49 - }, 10 * 1000); 50 - }); 51 - }); 52 - </script> 53 - </body> 54 - </html> 55 - {{end}}
-44
knotmirror/templates/hosts.html
··· 1 - {{template "base" .}} 2 - {{define "content"}} 3 - <h2>Knot Hosts</h2> 4 - 5 - <div class="filters"> 6 - <form 7 - hx-get="" 8 - hx-target="#table" 9 - hx-select="#table" 10 - hx-swap="outerHTML" 11 - hx-trigger="every 10s" 12 - > 13 - <select name="status"> 14 - {{ range const.AllHostStatuses }} 15 - <option value="{{.}}" {{ if eq $.FilterByStatus . }}selected{{end}}>{{.}}</option> 16 - {{ end }} 17 - </select> 18 - <button type="submit">Filter</button> 19 - </form> 20 - </div> 21 - 22 - <table id="table"> 23 - <thead> 24 - <tr> 25 - <th>Hostname</th> 26 - <th>SSL</th> 27 - <th>Status</th> 28 - <th>Last Seq</th> 29 - </tr> 30 - </thead> 31 - <tbody> 32 - {{range .Hosts}} 33 - <tr> 34 - <td>{{.Hostname}}</td> 35 - <td>{{if .NoSSL}}False{{else}}True{{end}}</td> 36 - <td>{{.Status}}</td> 37 - <td>{{.LastSeq}}</td> 38 - </tr> 39 - {{else}} 40 - <tr><td colspan="4">No hosts registered.</td></tr> 41 - {{end}} 42 - </tbody> 43 - </table> 44 - {{end}}
-86
knotmirror/templates/repos.html
··· 1 - {{template "base" .}} 2 - {{define "content"}} 3 - <h2>Repositories</h2> 4 - 5 - <div class="filters"> 6 - <form 7 - hx-get="" 8 - hx-target="#table" 9 - hx-select="#table" 10 - hx-swap="outerHTML" 11 - hx-trigger="every 10s" 12 - > 13 - <input type="text" name="did" placeholder="DID" value="{{.FilterByDid}}"> 14 - <input type="text" name="knot" placeholder="Knot Domain" value="{{.FilterByKnot}}"> 15 - <select name="state"> 16 - <option value="">-- State --</option> 17 - {{ range const.AllRepoStates }} 18 - <option value="{{.}}" {{ if eq $.FilterByState . }}selected{{end}}>{{.}}</option> 19 - {{ end }} 20 - </select> 21 - <button type="submit">Filter</button> 22 - <a href="/repos">Clear</a> 23 - </form> 24 - </div> 25 - 26 - <div id="table"> 27 - <div class="repo-state-indicators"> 28 - {{range const.AllRepoStates}} 29 - <span class="state-pill state-{{.}}"> 30 - {{.}}: {{index $.RepoCounts .}} 31 - </span> 32 - {{end}} 33 - </div> 34 - <table> 35 - <thead> 36 - <tr> 37 - <th>DID</th> 38 - <th>Name</th> 39 - <th>Knot</th> 40 - <th>State</th> 41 - <th>Retry</th> 42 - <th>Retry After</th> 43 - <th>Error Message</th> 44 - <th>Action</th> 45 - </tr> 46 - </thead> 47 - <tbody> 48 - {{range .Repos}} 49 - <tr> 50 - <td><code>{{.Did}}</code></td> 51 - <td>{{.Name}}</td> 52 - <td>{{.KnotDomain}}</td> 53 - <td><strong>{{.State}}</strong></td> 54 - <td>{{.RetryCount}}</td> 55 - <td>{{readt .RetryAfter}}</td> 56 - <td>{{.ErrorMsg}}</td> 57 - <td> 58 - <form 59 - {{ if .State.IsResyncing -}} 60 - hx-post="/api/cancelRepoResync" 61 - {{- else -}} 62 - hx-post="/api/triggerRepoResync" 63 - {{- end }} 64 - hx-swap="none" 65 - hx-disabled-elt="find button" 66 - > 67 - <input type="hidden" name="repo" value="{{.AtUri}}"> 68 - <button type="submit">{{ if .State.IsResyncing }}cancel{{ else }}resync{{ end }}</button> 69 - </form> 70 - </td> 71 - </tr> 72 - {{else}} 73 - <tr><td colspan="99">No repositories found.</td></tr> 74 - {{end}} 75 - </tbody> 76 - </table> 77 - </div> 78 - 79 - <div class="pagination"> 80 - {{if gt .Page 1}} 81 - <a href="?page={{sub .Page 1}}&did={{.FilterByDid}}&knot={{.FilterByKnot}}&state={{.FilterByState}}">ยซ Previous</a> 82 - {{end}} 83 - <span>Page {{.Page}}</span> 84 - <a href="?page={{add .Page 1}}&did={{.FilterByDid}}&knot={{.FilterByKnot}}&state={{.FilterByState}}">Next ยป</a> 85 - </div> 86 - {{end}}
-106
knotmirror/xrpc/git_getArchive.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "compress/gzip" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - "strings" 9 - 10 - "github.com/bluesky-social/indigo/atproto/atclient" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/go-git/go-git/v5/plumbing" 13 - "tangled.org/core/api/tangled" 14 - "tangled.org/core/knotmirror/db" 15 - "tangled.org/core/knotserver/git" 16 - ) 17 - 18 - func (x *Xrpc) GetArchive(w http.ResponseWriter, r *http.Request) { 19 - var ( 20 - repoQuery = r.URL.Query().Get("repo") 21 - ref = r.URL.Query().Get("ref") 22 - format = r.URL.Query().Get("format") 23 - prefix = r.URL.Query().Get("prefix") 24 - ) 25 - 26 - repo, err := syntax.ParseATURI(repoQuery) 27 - if err != nil || repo.RecordKey() == "" { 28 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 29 - return 30 - } 31 - 32 - if format != "tar.gz" { 33 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "only tar.gz format is supported"}) 34 - return 35 - } 36 - if format == "" { 37 - format = "tar.gz" 38 - } 39 - 40 - l := x.logger.With("repo", repo, "ref", ref, "format", format, "prefix", prefix) 41 - ctx := r.Context() 42 - 43 - repoPath, err := x.makeRepoPath(ctx, repo) 44 - if err != nil { 45 - l.Error("failed to resolve repo at-uri", "err", err) 46 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to resolve repo"}) 47 - return 48 - } 49 - 50 - gr, err := git.Open(repoPath, ref) 51 - if err != nil { 52 - l.Error("failed to open git repo", "err", err) 53 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to open git repo"}) 54 - return 55 - } 56 - 57 - repoName, err := func() (string, error) { 58 - r, err := db.GetRepoByAtUri(ctx, x.db, repo) 59 - if err != nil { 60 - return "", err 61 - } 62 - if r == nil { 63 - return "", fmt.Errorf("repo not found: %s", repo) 64 - } 65 - return r.Name, nil 66 - }() 67 - if err != nil { 68 - l.Error("failed to get repo name", "err", err) 69 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to retrieve repo name"}) 70 - return 71 - } 72 - 73 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 74 - immutableLink := func() string { 75 - params := url.Values{} 76 - params.Set("repo", repo.String()) 77 - params.Set("ref", gr.Hash().String()) 78 - params.Set("format", format) 79 - params.Set("prefix", prefix) 80 - return fmt.Sprintf("%s/xrpc/%s?%s", x.cfg.BaseUrl(), tangled.GitTempGetArchiveNSID, params.Encode()) 81 - }() 82 - 83 - filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 84 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 85 - w.Header().Set("Content-Type", "application/gzip") 86 - w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 87 - 88 - gw := gzip.NewWriter(w) 89 - defer gw.Close() 90 - 91 - if err := gr.WriteTar(gw, prefix); err != nil { 92 - // once we start writing to the body we can't report error anymore 93 - // so we are only left with logging the error 94 - l.Error("writing tar file", "err", err.Error()) 95 - w.WriteHeader(http.StatusInternalServerError) 96 - return 97 - } 98 - 99 - if err := gw.Flush(); err != nil { 100 - // once we start writing to the body we can't report error anymore 101 - // so we are only left with logging the error 102 - l.Error("flushing", "err", err.Error()) 103 - w.WriteHeader(http.StatusInternalServerError) 104 - return 105 - } 106 - }
-86
knotmirror/xrpc/git_getBlob.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "io" 7 - "net/http" 8 - "slices" 9 - 10 - "github.com/bluesky-social/indigo/atproto/atclient" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "github.com/go-git/go-git/v5/plumbing/object" 13 - "tangled.org/core/knotserver/git" 14 - ) 15 - 16 - func (x *Xrpc) GetBlob(w http.ResponseWriter, r *http.Request) { 17 - var ( 18 - repoQuery = r.URL.Query().Get("repo") 19 - ref = r.URL.Query().Get("ref") // ref can be empty (git.Open handles this) 20 - path = r.URL.Query().Get("path") 21 - ) 22 - 23 - repo, err := syntax.ParseATURI(repoQuery) 24 - if err != nil || repo.RecordKey() == "" { 25 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 26 - return 27 - } 28 - 29 - l := x.logger.With("repo", repo, "ref", ref, "path", path) 30 - 31 - if path == "" { 32 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing path parameter"}) 33 - return 34 - } 35 - 36 - file, err := x.getFile(r.Context(), repo, ref, path) 37 - if err != nil { 38 - // TODO: better error return 39 - l.Error("failed to get blob", "err", err) 40 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) 41 - return 42 - } 43 - 44 - reader, err := file.Reader() 45 - if err != nil { 46 - l.Error("failed to read blob", "err", err) 47 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read the blob"}) 48 - return 49 - } 50 - defer reader.Close() 51 - 52 - w.Header().Set("Content-Type", "application/octet-stream") 53 - if _, err := io.Copy(w, reader); err != nil { 54 - l.Error("failed to serve the blob", "err", err) 55 - } 56 - } 57 - 58 - func (x *Xrpc) getFile(ctx context.Context, repo syntax.ATURI, ref, path string) (*object.File, error) { 59 - repoPath, err := x.makeRepoPath(ctx, repo) 60 - if err != nil { 61 - return nil, fmt.Errorf("resolving repo at-uri: %w", err) 62 - } 63 - 64 - gr, err := git.Open(repoPath, ref) 65 - if err != nil { 66 - return nil, fmt.Errorf("opening git repo: %w", err) 67 - } 68 - 69 - return gr.File(path) 70 - } 71 - 72 - var textualMimeTypes = []string{ 73 - "application/json", 74 - "application/xml", 75 - "application/yaml", 76 - "application/x-yaml", 77 - "application/toml", 78 - "application/javascript", 79 - "application/ecmascript", 80 - } 81 - 82 - // isTextualMimeType returns true if the MIME type represents textual content 83 - // that should be served as text/plain for security reasons 84 - func isTextualMimeType(mimeType string) bool { 85 - return slices.Contains(textualMimeTypes, mimeType) 86 - }
-85
knotmirror/xrpc/git_getBranch.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - "time" 9 - 10 - "github.com/bluesky-social/indigo/atproto/atclient" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "tangled.org/core/api/tangled" 13 - "tangled.org/core/knotserver/git" 14 - ) 15 - 16 - // TODO: maybe rename to `sh.tangled.repo.temp.getCommit`? 17 - // then, we should ensure the given `ref` is valid 18 - func (x *Xrpc) GetBranch(w http.ResponseWriter, r *http.Request) { 19 - var ( 20 - repoQuery = r.URL.Query().Get("repo") 21 - nameQuery = r.URL.Query().Get("name") 22 - ) 23 - 24 - repo, err := syntax.ParseATURI(repoQuery) 25 - if err != nil || repo.RecordKey() == "" { 26 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 27 - return 28 - } 29 - 30 - if nameQuery == "" { 31 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing name parameter"}) 32 - return 33 - } 34 - branchName, _ := url.PathUnescape(nameQuery) 35 - 36 - l := x.logger.With("repo", repo, "branch", branchName) 37 - 38 - out, err := x.getBranch(r.Context(), repo, branchName) 39 - if err != nil { 40 - // TODO: better error return 41 - l.Error("failed to get branch", "err", err) 42 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get branch"}) 43 - return 44 - } 45 - writeJson(w, http.StatusOK, out) 46 - } 47 - 48 - func (x *Xrpc) getBranch(ctx context.Context, repo syntax.ATURI, branchName string) (*tangled.GitTempGetBranch_Output, error) { 49 - repoPath, err := x.makeRepoPath(ctx, repo) 50 - if err != nil { 51 - return nil, fmt.Errorf("failed to resolve repo at-uri: %w", err) 52 - } 53 - 54 - gr, err := git.PlainOpen(repoPath) 55 - if err != nil { 56 - return nil, fmt.Errorf("failed to open git repo: %w", err) 57 - } 58 - 59 - ref, err := gr.Branch(branchName) 60 - if err != nil { 61 - return nil, fmt.Errorf("getting branch '%s': %w", branchName, err) 62 - } 63 - 64 - commit, err := gr.Commit(ref.Hash()) 65 - if err != nil { 66 - return nil, fmt.Errorf("getting commit '%s': %w", ref.Hash(), err) 67 - } 68 - 69 - out := tangled.GitTempGetBranch_Output{ 70 - Name: ref.Name().Short(), 71 - Hash: ref.Hash().String(), 72 - When: commit.Author.When.Format(time.RFC3339), 73 - Author: &tangled.GitTempDefs_Signature{ 74 - Name: commit.Author.Name, 75 - Email: commit.Author.Email, 76 - When: commit.Author.When.Format(time.RFC3339), 77 - }, 78 - } 79 - 80 - if commit.Message != "" { 81 - out.Message = &commit.Message 82 - } 83 - 84 - return &out, nil 85 - }
-92
knotmirror/xrpc/git_getTag.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - 8 - "github.com/bluesky-social/indigo/atproto/atclient" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/go-git/go-git/v5/plumbing" 11 - "github.com/go-git/go-git/v5/plumbing/object" 12 - "tangled.org/core/knotserver/git" 13 - "tangled.org/core/types" 14 - ) 15 - 16 - func (x *Xrpc) GetTag(w http.ResponseWriter, r *http.Request) { 17 - var ( 18 - repoQuery = r.URL.Query().Get("repo") 19 - tagName = r.URL.Query().Get("tag") 20 - ) 21 - 22 - repo, err := syntax.ParseATURI(repoQuery) 23 - if err != nil || repo.RecordKey() == "" { 24 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 25 - return 26 - } 27 - 28 - if tagName == "" { 29 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing 'tag' parameter"}) 30 - return 31 - } 32 - 33 - l := x.logger.With("repo", repo, "tag", tagName) 34 - 35 - out, err := x.getTag(r.Context(), repo, tagName) 36 - if err != nil { 37 - // TODO: better error return 38 - l.Error("failed to get tag", "err", err) 39 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get tag"}) 40 - return 41 - } 42 - writeJson(w, http.StatusOK, out) 43 - } 44 - 45 - func (x *Xrpc) getTag(ctx context.Context, repo syntax.ATURI, tagName string) (*types.RepoTagResponse, error) { 46 - repoPath, err := x.makeRepoPath(ctx, repo) 47 - if err != nil { 48 - return nil, fmt.Errorf("failed to resolve repo at-uri: %w", err) 49 - } 50 - 51 - gr, err := git.PlainOpen(repoPath) 52 - if err != nil { 53 - return nil, fmt.Errorf("failed to open git repo: %w", err) 54 - } 55 - 56 - // if this is not already formatted as refs/tags/v0.1.0, then format it 57 - if !plumbing.ReferenceName(tagName).IsTag() { 58 - tagName = plumbing.NewTagReferenceName(tagName).String() 59 - } 60 - 61 - tag, err := func() (object.Tag, error) { 62 - tags, err := gr.Tags(&git.TagsOptions{ 63 - Pattern: tagName, 64 - }) 65 - if err != nil { 66 - return object.Tag{}, err 67 - } 68 - if len(tags) != 1 { 69 - return object.Tag{}, fmt.Errorf("expected 1 tag to be returned, got %d tags", len(tags)) 70 - } 71 - return tags[0], nil 72 - }() 73 - if err != nil { 74 - return nil, fmt.Errorf("getting tag: %w", err) 75 - } 76 - 77 - var target *object.Tag 78 - if tag.Target != plumbing.ZeroHash { 79 - target = &tag 80 - } 81 - 82 - return &types.RepoTagResponse{ 83 - Tag: &types.TagReference{ 84 - Tag: target, 85 - Reference: types.Reference{ 86 - Name: tag.Name, 87 - Hash: tag.Hash.String(), 88 - }, 89 - Message: tag.Message, 90 - }, 91 - }, nil 92 - }
-118
knotmirror/xrpc/git_getTree.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "path/filepath" 8 - "time" 9 - "unicode/utf8" 10 - 11 - "github.com/bluesky-social/indigo/atproto/atclient" 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "tangled.org/core/api/tangled" 14 - "tangled.org/core/appview/pages/markup" 15 - "tangled.org/core/knotserver/git" 16 - ) 17 - 18 - func (x *Xrpc) GetTree(w http.ResponseWriter, r *http.Request) { 19 - var ( 20 - repoQuery = r.URL.Query().Get("repo") 21 - ref = r.URL.Query().Get("ref") // ref can be empty (git.Open handles this) 22 - path = r.URL.Query().Get("path") // path can be empty (defaults to root) 23 - ) 24 - 25 - repo, err := syntax.ParseATURI(repoQuery) 26 - if err != nil || repo.RecordKey() == "" { 27 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 28 - return 29 - } 30 - 31 - l := x.logger.With("repo", repo, "ref", ref, "path", path) 32 - 33 - out, err := x.getTree(r.Context(), repo, ref, path) 34 - if err != nil { 35 - // TODO: better error return 36 - l.Error("failed to get tree", "err", err) 37 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get tree"}) 38 - return 39 - } 40 - writeJson(w, http.StatusOK, out) 41 - } 42 - 43 - func (x *Xrpc) getTree(ctx context.Context, repo syntax.ATURI, ref, path string) (*tangled.GitTempGetTree_Output, error) { 44 - repoPath, err := x.makeRepoPath(ctx, repo) 45 - if err != nil { 46 - return nil, fmt.Errorf("failed to resolve repo at-uri: %w", err) 47 - } 48 - 49 - gr, err := git.Open(repoPath, ref) 50 - if err != nil { 51 - return nil, fmt.Errorf("opening git repo: %w", err) 52 - } 53 - 54 - files, err := gr.FileTree(ctx, path) 55 - if err != nil { 56 - return nil, fmt.Errorf("reading file tree: %w", err) 57 - } 58 - 59 - // if any of these files are a readme candidate, pass along its blob contents too 60 - var readmeFileName string 61 - var readmeContents string 62 - for _, file := range files { 63 - if markup.IsReadmeFile(file.Name) { 64 - contents, err := gr.RawContent(filepath.Join(path, file.Name)) 65 - if err != nil { 66 - x.logger.Error("failed to read contents of file", "path", path, "file", file.Name) 67 - } 68 - 69 - if utf8.Valid(contents) { 70 - readmeFileName = file.Name 71 - readmeContents = string(contents) 72 - break 73 - } 74 - } 75 - } 76 - 77 - // convert NiceTree -> tangled.RepoTempGetTree_TreeEntry 78 - treeEntries := make([]*tangled.GitTempGetTree_TreeEntry, len(files)) 79 - for i, file := range files { 80 - entry := &tangled.GitTempGetTree_TreeEntry{ 81 - Name: file.Name, 82 - Mode: file.Mode, 83 - Size: file.Size, 84 - } 85 - if file.LastCommit != nil { 86 - entry.Last_commit = &tangled.GitTempGetTree_LastCommit{ 87 - Hash: file.LastCommit.Hash.String(), 88 - Message: file.LastCommit.Message, 89 - When: file.LastCommit.When.Format(time.RFC3339), 90 - } 91 - } 92 - treeEntries[i] = entry 93 - } 94 - 95 - var parentPtr *string 96 - if path != "" { 97 - parentPtr = &path 98 - } 99 - 100 - var dotdotPtr *string 101 - if path != "" { 102 - dotdot := filepath.Dir(path) 103 - if dotdot != "." { 104 - dotdotPtr = &dotdot 105 - } 106 - } 107 - 108 - return &tangled.GitTempGetTree_Output{ 109 - Ref: ref, 110 - Parent: parentPtr, 111 - Dotdot: dotdotPtr, 112 - Files: treeEntries, 113 - Readme: &tangled.GitTempGetTree_Readme{ 114 - Filename: readmeFileName, 115 - Contents: readmeContents, 116 - }, 117 - }, nil 118 - }
-95
knotmirror/xrpc/git_listBranches.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "path/filepath" 8 - "strconv" 9 - 10 - "github.com/bluesky-social/indigo/atproto/atclient" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "tangled.org/core/knotserver/git" 13 - "tangled.org/core/types" 14 - ) 15 - 16 - func (x *Xrpc) ListBranches(w http.ResponseWriter, r *http.Request) { 17 - var ( 18 - repoQuery = r.URL.Query().Get("repo") 19 - limitQuery = r.URL.Query().Get("limit") 20 - cursorQuery = r.URL.Query().Get("cursor") 21 - ) 22 - 23 - repo, err := syntax.ParseATURI(repoQuery) 24 - if err != nil || repo.RecordKey() == "" { 25 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 26 - return 27 - } 28 - 29 - limit := 50 30 - if limitQuery != "" { 31 - limit, err = strconv.Atoi(limitQuery) 32 - if err != nil || limit < 1 || limit > 1000 { 33 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("limit parameter invalid: %s", limitQuery)}) 34 - return 35 - } 36 - } 37 - 38 - var cursor int64 39 - if cursorQuery != "" { 40 - cursor, err = strconv.ParseInt(cursorQuery, 10, 64) 41 - if err != nil || cursor < 0 { 42 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("cursor parameter invalid: %s", cursorQuery)}) 43 - return 44 - } 45 - } 46 - 47 - l := x.logger.With("repo", repoQuery, "limit", limit, "cursor", cursor) 48 - 49 - out, err := x.listBranches(r.Context(), repo, limit, cursor) 50 - if err != nil { 51 - // TODO: better error return 52 - l.Error("failed to list branches", "err", err) 53 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to list branches"}) 54 - return 55 - } 56 - writeJson(w, http.StatusOK, out) 57 - } 58 - 59 - func (x *Xrpc) listBranches(ctx context.Context, repo syntax.ATURI, limit int, cursor int64) (*types.RepoBranchesResponse, error) { 60 - repoPath, err := x.makeRepoPath(ctx, repo) 61 - if err != nil { 62 - return nil, fmt.Errorf("resolving repo at-uri: %w", err) 63 - } 64 - 65 - gr, err := git.PlainOpen(repoPath) 66 - if err != nil { 67 - return nil, fmt.Errorf("opening git repo: %w", err) 68 - } 69 - 70 - branches, err := gr.Branches(&git.BranchesOptions{ 71 - Limit: limit, 72 - Offset: int(cursor), 73 - }) 74 - if err != nil { 75 - return nil, fmt.Errorf("listing git branches: %w", err) 76 - } 77 - 78 - return &types.RepoBranchesResponse{ 79 - // TODO: include default branch and cursor 80 - Branches: branches, 81 - }, nil 82 - } 83 - 84 - func (x *Xrpc) makeRepoPath(ctx context.Context, repo syntax.ATURI) (string, error) { 85 - id, err := x.resolver.ResolveIdent(ctx, repo.Authority().String()) 86 - if err != nil { 87 - return "", err 88 - } 89 - 90 - return filepath.Join( 91 - x.cfg.GitRepoBasePath, 92 - id.DID.String(), 93 - repo.RecordKey().String(), 94 - ), nil 95 - }
-95
knotmirror/xrpc/git_listCommits.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "strconv" 8 - 9 - "github.com/bluesky-social/indigo/atproto/atclient" 10 - "github.com/bluesky-social/indigo/atproto/syntax" 11 - "tangled.org/core/knotserver/git" 12 - "tangled.org/core/types" 13 - ) 14 - 15 - func (x *Xrpc) ListCommits(w http.ResponseWriter, r *http.Request) { 16 - var ( 17 - repoQuery = r.URL.Query().Get("repo") 18 - ref = r.URL.Query().Get("ref") // ref can be empty (git.Open handles this) 19 - limitQuery = r.URL.Query().Get("limit") 20 - cursorQuery = r.URL.Query().Get("cursor") 21 - ) 22 - 23 - repo, err := syntax.ParseATURI(repoQuery) 24 - if err != nil || repo.RecordKey() == "" { 25 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 26 - return 27 - } 28 - 29 - limit := 50 30 - if limitQuery != "" { 31 - limit, err = strconv.Atoi(limitQuery) 32 - if err != nil || limit < 1 || limit > 1000 { 33 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("limit parameter invalid: %s", limitQuery)}) 34 - return 35 - } 36 - } 37 - 38 - var cursor int64 39 - if cursorQuery != "" { 40 - cursor, err = strconv.ParseInt(cursorQuery, 10, 64) 41 - if err != nil || cursor < 0 { 42 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("cursor parameter invalid: %s", cursorQuery)}) 43 - return 44 - } 45 - } 46 - 47 - l := x.logger.With("repo", repo, "ref", ref) 48 - 49 - out, err := x.listCommits(r.Context(), repo, ref, limit, cursor) 50 - if err != nil { 51 - // TODO: better error return 52 - l.Error("failed to list commits", "err", err) 53 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to list commits"}) 54 - return 55 - } 56 - writeJson(w, http.StatusOK, out) 57 - } 58 - 59 - func (x *Xrpc) listCommits(ctx context.Context, repo syntax.ATURI, ref string, limit int, cursor int64) (*types.RepoLogResponse, error) { 60 - repoPath, err := x.makeRepoPath(ctx, repo) 61 - if err != nil { 62 - return nil, fmt.Errorf("resolving repo at-uri: %w", err) 63 - } 64 - 65 - gr, err := git.Open(repoPath, ref) 66 - if err != nil { 67 - return nil, fmt.Errorf("opening git repo: %w", err) 68 - } 69 - 70 - offset := int(cursor) 71 - 72 - commits, err := gr.Commits(offset, limit) 73 - if err != nil { 74 - return nil, fmt.Errorf("listing git commits: %w", err) 75 - } 76 - 77 - tcommits := make([]types.Commit, len(commits)) 78 - for i, c := range commits { 79 - tcommits[i].FromGoGitCommit(c) 80 - } 81 - 82 - total, err := gr.TotalCommits() 83 - if err != nil { 84 - return nil, fmt.Errorf("counting total commits: %w", err) 85 - } 86 - 87 - return &types.RepoLogResponse{ 88 - Commits: tcommits, 89 - Ref: ref, 90 - Page: (offset / limit) + 1, 91 - PerPage: limit, 92 - Total: total, 93 - Log: true, 94 - }, nil 95 - }
-86
knotmirror/xrpc/git_listLanguages.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "math" 7 - "net/http" 8 - "time" 9 - 10 - "github.com/bluesky-social/indigo/atproto/atclient" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - "tangled.org/core/api/tangled" 13 - "tangled.org/core/knotserver/git" 14 - ) 15 - 16 - func (x *Xrpc) ListLanguages(w http.ResponseWriter, r *http.Request) { 17 - var ( 18 - repoQuery = r.URL.Query().Get("repo") 19 - ref = r.URL.Query().Get("ref") 20 - ) 21 - l := x.logger.With("repo", repoQuery, "ref", ref) 22 - 23 - repo, err := syntax.ParseATURI(repoQuery) 24 - if err != nil || repo.RecordKey() == "" { 25 - l.Error("invalid repo at-uri", "err", err) 26 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 27 - return 28 - } 29 - 30 - out, err := x.listLanguages(r.Context(), repo, ref) 31 - if err != nil { 32 - l.Error("failed to list languages", "err", err) 33 - writeErr(w, err) 34 - return 35 - } 36 - 37 - writeJson(w, http.StatusOK, out) 38 - } 39 - 40 - func (x *Xrpc) listLanguages(ctx context.Context, repo syntax.ATURI, ref string) (*tangled.GitTempListLanguages_Output, error) { 41 - repoPath, err := x.makeRepoPath(ctx, repo) 42 - if err != nil { 43 - return nil, fmt.Errorf("resolving repo at-uri: %w", err) 44 - } 45 - 46 - gr, err := git.Open(repoPath, ref) 47 - if err != nil { 48 - return nil, &atclient.APIError{StatusCode: http.StatusNotFound, Name: "RepoNotFound", Message: "failed to find git repo"} 49 - } 50 - 51 - ctx, cancel := context.WithTimeout(ctx, 1*time.Second) 52 - defer cancel() 53 - 54 - sizes, err := gr.AnalyzeLanguages(ctx) 55 - if err != nil { 56 - return nil, fmt.Errorf("analyzing languages: %w", err) 57 - } 58 - 59 - return &tangled.GitTempListLanguages_Output{ 60 - Ref: ref, 61 - Languages: sizesToLanguages(sizes), 62 - }, nil 63 - } 64 - 65 - func sizesToLanguages(sizes git.LangBreakdown) []*tangled.GitTempListLanguages_Language { 66 - var apiLanguages []*tangled.GitTempListLanguages_Language 67 - var totalSize int64 68 - for _, size := range sizes { 69 - totalSize += size 70 - } 71 - 72 - for name, size := range sizes { 73 - percentagef64 := float64(size) / float64(totalSize) * 100 74 - percentage := math.Round(percentagef64) 75 - 76 - lang := &tangled.GitTempListLanguages_Language{ 77 - Name: name, 78 - Size: size, 79 - Percentage: int64(percentage), 80 - } 81 - 82 - apiLanguages = append(apiLanguages, lang) 83 - } 84 - 85 - return apiLanguages 86 - }
-98
knotmirror/xrpc/git_listTags.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "strconv" 8 - 9 - "github.com/bluesky-social/indigo/atproto/atclient" 10 - "github.com/bluesky-social/indigo/atproto/syntax" 11 - "github.com/go-git/go-git/v5/plumbing" 12 - "github.com/go-git/go-git/v5/plumbing/object" 13 - "tangled.org/core/knotserver/git" 14 - "tangled.org/core/types" 15 - ) 16 - 17 - func (x *Xrpc) ListTags(w http.ResponseWriter, r *http.Request) { 18 - var ( 19 - repoQuery = r.URL.Query().Get("repo") 20 - limitQuery = r.URL.Query().Get("limit") 21 - cursorQuery = r.URL.Query().Get("cursor") 22 - ) 23 - 24 - repo, err := syntax.ParseATURI(repoQuery) 25 - if err != nil || repo.RecordKey() == "" { 26 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) 27 - return 28 - } 29 - 30 - limit := 50 31 - if limitQuery != "" { 32 - limit, err = strconv.Atoi(limitQuery) 33 - if err != nil || limit < 1 || limit > 1000 { 34 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("limit parameter invalid: %s", limitQuery)}) 35 - return 36 - } 37 - } 38 - 39 - var cursor int64 40 - if cursorQuery != "" { 41 - cursor, err = strconv.ParseInt(cursorQuery, 10, 64) 42 - if err != nil || cursor < 0 { 43 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("cursor parameter invalid: %s", cursorQuery)}) 44 - return 45 - } 46 - } 47 - 48 - l := x.logger.With("repo", repo, "limit", limit, "cursor", cursor) 49 - 50 - out, err := x.listTags(r.Context(), repo, limit, cursor) 51 - if err != nil { 52 - // TODO: better error return 53 - l.Error("failed to list tags", "err", err) 54 - writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to list tags"}) 55 - return 56 - } 57 - writeJson(w, http.StatusOK, out) 58 - } 59 - 60 - func (x *Xrpc) listTags(ctx context.Context, repo syntax.ATURI, limit int, cursor int64) (*types.RepoTagsResponse, error) { 61 - repoPath, err := x.makeRepoPath(ctx, repo) 62 - if err != nil { 63 - return nil, fmt.Errorf("failed to resolve repo at-uri: %w", err) 64 - } 65 - 66 - gr, err := git.PlainOpen(repoPath) 67 - if err != nil { 68 - return nil, fmt.Errorf("failed to open git repo: %w", err) 69 - } 70 - 71 - tags, err := gr.Tags(&git.TagsOptions{ 72 - Limit: limit, 73 - Offset: int(cursor), 74 - }) 75 - if err != nil { 76 - return nil, fmt.Errorf("failed to get git tags: %w", err) 77 - } 78 - 79 - rtags := make([]*types.TagReference, len(tags)) 80 - for i, tag := range tags { 81 - var target *object.Tag 82 - if tag.Target != plumbing.ZeroHash { 83 - target = &tag 84 - } 85 - rtags[i] = &types.TagReference{ 86 - Reference: types.Reference{ 87 - Name: tag.Name, 88 - Hash: tag.Hash.String(), 89 - }, 90 - Tag: target, 91 - Message: tag.Message, 92 - } 93 - } 94 - 95 - return &types.RepoTagsResponse{ 96 - Tags: rtags, 97 - }, nil 98 - }
-69
knotmirror/xrpc/xrpc.go
··· 1 - package xrpc 2 - 3 - import ( 4 - "database/sql" 5 - "encoding/json" 6 - "errors" 7 - "log/slog" 8 - "net/http" 9 - 10 - "github.com/bluesky-social/indigo/atproto/atclient" 11 - "github.com/go-chi/chi/v5" 12 - "tangled.org/core/api/tangled" 13 - "tangled.org/core/idresolver" 14 - "tangled.org/core/knotmirror/config" 15 - "tangled.org/core/log" 16 - ) 17 - 18 - type Xrpc struct { 19 - cfg *config.Config 20 - db *sql.DB 21 - resolver *idresolver.Resolver 22 - logger *slog.Logger 23 - } 24 - 25 - func New(logger *slog.Logger, cfg *config.Config, db *sql.DB, resolver *idresolver.Resolver) *Xrpc { 26 - return &Xrpc{ 27 - cfg, 28 - db, 29 - resolver, 30 - log.SubLogger(logger, "xrpc"), 31 - } 32 - } 33 - 34 - func (x *Xrpc) Router() http.Handler { 35 - r := chi.NewRouter() 36 - 37 - r.Get("/"+tangled.GitTempGetArchiveNSID, x.GetArchive) 38 - r.Get("/"+tangled.GitTempGetBlobNSID, x.GetBlob) 39 - r.Get("/"+tangled.GitTempGetBranchNSID, x.GetBranch) 40 - // r.Get("/"+tangled.GitTempGetCommitNSID, x.GetCommit) // todo 41 - // r.Get("/"+tangled.GitTempGetDiffNSID, x.GetDiff) // todo 42 - // r.Get("/"+tangled.GitTempGetEntityNSID, x.GetEntity) // todo 43 - // r.Get("/"+tangled.GitTempGetHeadNSID, x.GetHead) // todo 44 - r.Get("/"+tangled.GitTempGetTagNSID, x.GetTag) // using types.Response 45 - r.Get("/"+tangled.GitTempGetTreeNSID, x.GetTree) 46 - r.Get("/"+tangled.GitTempListBranchesNSID, x.ListBranches) // wip, unknown output 47 - r.Get("/"+tangled.GitTempListCommitsNSID, x.ListCommits) 48 - r.Get("/"+tangled.GitTempListLanguagesNSID, x.ListLanguages) 49 - r.Get("/"+tangled.GitTempListTagsNSID, x.ListTags) 50 - 51 - return r 52 - } 53 - 54 - func writeJson(w http.ResponseWriter, status int, response any) error { 55 - w.Header().Set("Content-Type", "application/json") 56 - w.WriteHeader(status) 57 - if err := json.NewEncoder(w).Encode(response); err != nil { 58 - return err 59 - } 60 - return nil 61 - } 62 - 63 - func writeErr(w http.ResponseWriter, err error) error { 64 - var apiErr *atclient.APIError 65 - if errors.As(err, &apiErr) { 66 - return writeJson(w, apiErr.StatusCode, atclient.ErrorBody{Name: apiErr.Name, Message: apiErr.Message}) 67 - } 68 - return writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "internal server error"}) 69 - }
-14
knotserver/git/git.go
··· 199 199 return io.ReadAll(reader) 200 200 } 201 201 202 - func (g *GitRepo) File(path string) (*object.File, error) { 203 - c, err := g.r.CommitObject(g.h) 204 - if err != nil { 205 - return nil, fmt.Errorf("commit object: %w", err) 206 - } 207 - 208 - tree, err := c.Tree() 209 - if err != nil { 210 - return nil, fmt.Errorf("file tree: %w", err) 211 - } 212 - 213 - return tree.File(path) 214 - } 215 - 216 202 // read and parse .gitmodules 217 203 func (g *GitRepo) Submodules() (*config.Modules, error) { 218 204 c, err := g.r.CommitObject(g.h)
+2 -3
knotserver/router.go
··· 83 83 84 84 r.Route("/{did}", func(r chi.Router) { 85 85 r.Use(h.resolveDidRedirect) 86 + r.Use(h.resolveRepo) 86 87 r.Route("/{name}", func(r chi.Router) { 87 - r.Use(h.resolveRepo) 88 - 89 88 // routes for git operations 90 89 r.Get("/info/refs", h.InfoRefs) 91 90 r.Post("/git-upload-archive", h.UploadArchive) ··· 177 176 return 178 177 } 179 178 180 - ctx := context.WithValue(r.Context(), ctxRepoPathKey{}, repoPath) 179 + ctx := context.WithValue(r.Context(), "repoPath", repoPath) 181 180 next.ServeHTTP(w, r.WithContext(ctx)) 182 181 }) 183 182 }
-64
lexicons/git/temp/analyzeMerge.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.analyzeMerge", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "Check if a merge is possible between two branches", 8 - "parameters": { 9 - "type": "params", 10 - "required": ["repo", "patch", "branch"], 11 - "properties": { 12 - "repo": { 13 - "type": "string", 14 - "format": "at-uri", 15 - "description": "AT-URI of the repository" 16 - }, 17 - "patch": { 18 - "type": "string", 19 - "description": "Patch or pull request to check for merge conflicts" 20 - }, 21 - "branch": { 22 - "type": "string", 23 - "description": "Target branch to merge into" 24 - } 25 - } 26 - }, 27 - "output": { 28 - "encoding": "application/json", 29 - "schema": { 30 - "type": "object", 31 - "required": ["is_conflicted"], 32 - "properties": { 33 - "is_conflicted": { 34 - "type": "boolean", 35 - "description": "Whether the merge has conflicts" 36 - }, 37 - "conflicts": { 38 - "type": "array", 39 - "description": "List of files with merge conflicts", 40 - "items": { 41 - "type": "ref", 42 - "ref": "#conflictInfo" 43 - } 44 - } 45 - } 46 - } 47 - } 48 - }, 49 - "conflictInfo": { 50 - "type": "object", 51 - "required": ["filename", "reason"], 52 - "properties": { 53 - "filename": { 54 - "type": "string", 55 - "description": "Name of the conflicted file" 56 - }, 57 - "reason": { 58 - "type": "string", 59 - "description": "Reason for the conflict" 60 - } 61 - } 62 - } 63 - } 64 - }
-112
lexicons/git/temp/defs.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.defs", 4 - "defs": { 5 - "blob": { 6 - "type": "object", 7 - "description": "blob metadata. This object doesn't include the blob content", 8 - "required": ["name", "mode", "size", "lastCommit"], 9 - "properties": { 10 - "name": { 11 - "type": "string", 12 - "description": "The file name" 13 - }, 14 - "mode": { 15 - "type": "string" 16 - }, 17 - "size": { 18 - "type": "integer", 19 - "description": "File size in bytes" 20 - }, 21 - "lastCommit": { 22 - "type": "ref", 23 - "ref": "#commit" 24 - }, 25 - "submodule": { 26 - "type": "ref", 27 - "ref": "#submodule", 28 - "description": "Submodule information if path is a submodule" 29 - } 30 - } 31 - }, 32 - "branch": { 33 - "type": "object", 34 - "required": ["name", "commit"], 35 - "properties": { 36 - "name": { 37 - "type": "string", 38 - "description": "branch name" 39 - }, 40 - "commit": { 41 - "type": "ref", 42 - "ref": "#commit", 43 - "description": "hydrated commit object" 44 - } 45 - } 46 - }, 47 - "tag": { 48 - "type": "object", 49 - "required": ["name", "tagger", "target"], 50 - "properties": { 51 - "name": { 52 - "type": "string", 53 - "description": "tag name" 54 - }, 55 - "tagger": { "type": "ref", "ref": "#signature" }, 56 - "message": { "type": "string" }, 57 - "target": { "type": "unknown" } 58 - } 59 - }, 60 - "commit": { 61 - "type": "object", 62 - "required": ["hash", "author", "committer", "message", "tree"], 63 - "properties": { 64 - "hash": { "type": "ref", "ref": "#hash" }, 65 - "author": { "type": "ref", "ref": "#signature" }, 66 - "committer": { "type": "ref", "ref": "#signature" }, 67 - "message": { "type": "string" }, 68 - "tree": { "type": "ref", "ref": "#hash" } 69 - } 70 - }, 71 - "hash": { 72 - "type": "string" 73 - }, 74 - "signature": { 75 - "type": "object", 76 - "required": ["name", "email", "when"], 77 - "properties": { 78 - "name": { 79 - "type": "string", 80 - "description": "Person name" 81 - }, 82 - "email": { 83 - "type": "string", 84 - "description": "Person email" 85 - }, 86 - "when": { 87 - "type": "string", 88 - "format": "datetime", 89 - "description": "Timestamp of the signature" 90 - } 91 - } 92 - }, 93 - "submodule": { 94 - "type": "object", 95 - "required": ["name", "url"], 96 - "properties": { 97 - "name": { 98 - "type": "string", 99 - "description": "Submodule name" 100 - }, 101 - "url": { 102 - "type": "string", 103 - "description": "Submodule repository URL" 104 - }, 105 - "branch": { 106 - "type": "string", 107 - "description": "Branch to track in the submodule" 108 - } 109 - } 110 - } 111 - } 112 - }
-56
lexicons/git/temp/getArchive.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getArchive", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo", "ref"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "ref": { 17 - "type": "string", 18 - "description": "Git reference (branch, tag, or commit SHA)" 19 - }, 20 - "format": { 21 - "type": "string", 22 - "description": "Archive format", 23 - "enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"], 24 - "default": "tar.gz" 25 - }, 26 - "prefix": { 27 - "type": "string", 28 - "description": "Prefix for files in the archive" 29 - } 30 - } 31 - }, 32 - "output": { 33 - "encoding": "*/*", 34 - "description": "Binary archive data" 35 - }, 36 - "errors": [ 37 - { 38 - "name": "RepoNotFound", 39 - "description": "Repository not found or access denied" 40 - }, 41 - { 42 - "name": "RefNotFound", 43 - "description": "Git reference not found" 44 - }, 45 - { 46 - "name": "InvalidRequest", 47 - "description": "Invalid request parameters" 48 - }, 49 - { 50 - "name": "ArchiveError", 51 - "description": "Failed to create archive" 52 - } 53 - ] 54 - } 55 - } 56 - }
-47
lexicons/git/temp/getBlob.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getBlob", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo", "path"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "ref": { 17 - "type": "string", 18 - "description": "Git reference (branch, tag, or commit SHA)", 19 - "default": "HEAD" 20 - }, 21 - "path": { 22 - "type": "string", 23 - "description": "Path within the repository tree" 24 - } 25 - } 26 - }, 27 - "output": { 28 - "encoding": "*/*", 29 - "description": "raw blob served in octet-stream" 30 - }, 31 - "errors": [ 32 - { 33 - "name": "RepoNotFound", 34 - "description": "Repository not found or access denied" 35 - }, 36 - { 37 - "name": "BlobNotFound", 38 - "description": "Blob not found" 39 - }, 40 - { 41 - "name": "InvalidRequest", 42 - "description": "Invalid request parameters" 43 - } 44 - ] 45 - } 46 - } 47 - }
-68
lexicons/git/temp/getBranch.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getBranch", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo", "name"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "name": { 17 - "type": "string", 18 - "description": "Branch name to get information for" 19 - } 20 - } 21 - }, 22 - "output": { 23 - "encoding": "application/json", 24 - "schema": { 25 - "type": "object", 26 - "required": ["name", "hash", "when"], 27 - "properties": { 28 - "name": { 29 - "type": "string", 30 - "description": "Branch name" 31 - }, 32 - "hash": { 33 - "type": "string", 34 - "description": "Latest commit hash on this branch" 35 - }, 36 - "when": { 37 - "type": "string", 38 - "format": "datetime", 39 - "description": "Timestamp of latest commit" 40 - }, 41 - "message": { 42 - "type": "string", 43 - "description": "Latest commit message" 44 - }, 45 - "author": { 46 - "type": "ref", 47 - "ref": "sh.tangled.git.temp.defs#signature" 48 - } 49 - } 50 - } 51 - }, 52 - "errors": [ 53 - { 54 - "name": "RepoNotFound", 55 - "description": "Repository not found or access denied" 56 - }, 57 - { 58 - "name": "BranchNotFound", 59 - "description": "Branch not found" 60 - }, 61 - { 62 - "name": "InvalidRequest", 63 - "description": "Invalid request parameters" 64 - } 65 - ] 66 - } 67 - } 68 - }
-46
lexicons/git/temp/getCommit.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getCommit", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "resolve commit from given ref", 8 - "parameters": { 9 - "type": "params", 10 - "required": ["repo", "ref"], 11 - "properties": { 12 - "repo": { 13 - "type": "string", 14 - "format": "at-uri", 15 - "description": "AT-URI of the repository" 16 - }, 17 - "ref": { 18 - "type": "string", 19 - "description": "reference name to resolve" 20 - } 21 - } 22 - }, 23 - "output": { 24 - "encoding": "application/json", 25 - "schema": { 26 - "type": "ref", 27 - "ref": "sh.tangled.git.temp.defs#commit" 28 - } 29 - }, 30 - "errors": [ 31 - { 32 - "name": "RepoNotFound", 33 - "description": "Repository not found or access denied" 34 - }, 35 - { 36 - "name": "CommitNotFound", 37 - "description": "Commit not found" 38 - }, 39 - { 40 - "name": "InvalidRequest", 41 - "description": "Invalid request parameters" 42 - } 43 - ] 44 - } 45 - } 46 - }
-50
lexicons/git/temp/getDiff.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getDiff", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo", "rev1", "rev2"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "rev1": { 17 - "type": "string", 18 - "description": "First revision (commit, branch, or tag)" 19 - }, 20 - "rev2": { 21 - "type": "string", 22 - "description": "Second revision (commit, branch, or tag)" 23 - } 24 - } 25 - }, 26 - "output": { 27 - "encoding": "*/*", 28 - "description": "Compare output in application/json" 29 - }, 30 - "errors": [ 31 - { 32 - "name": "RepoNotFound", 33 - "description": "Repository not found or access denied" 34 - }, 35 - { 36 - "name": "RevisionNotFound", 37 - "description": "One or both revisions not found" 38 - }, 39 - { 40 - "name": "InvalidRequest", 41 - "description": "Invalid request parameters" 42 - }, 43 - { 44 - "name": "CompareError", 45 - "description": "Failed to compare revisions" 46 - } 47 - ] 48 - } 49 - } 50 - }
-51
lexicons/git/temp/getEntity.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getEntity", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "description": "get metadata of blob by ref and path", 8 - "parameters": { 9 - "type": "params", 10 - "required": ["repo", "path"], 11 - "properties": { 12 - "repo": { 13 - "type": "string", 14 - "format": "at-uri", 15 - "description": "AT-URI of the repository" 16 - }, 17 - "ref": { 18 - "type": "string", 19 - "description": "Git reference (branch, tag, or commit SHA)", 20 - "default": "HEAD" 21 - }, 22 - "path": { 23 - "type": "string", 24 - "description": "path of the entity" 25 - } 26 - } 27 - }, 28 - "output": { 29 - "encoding": "application/json", 30 - "schema": { 31 - "type": "ref", 32 - "ref": "sh.tangled.git.temp.defs#blob" 33 - } 34 - }, 35 - "errors": [ 36 - { 37 - "name": "RepoNotFound", 38 - "description": "Repository not found or access denied" 39 - }, 40 - { 41 - "name": "BlobNotFound", 42 - "description": "Blob not found" 43 - }, 44 - { 45 - "name": "InvalidRequest", 46 - "description": "Invalid request parameters" 47 - } 48 - ] 49 - } 50 - } 51 - }
-37
lexicons/git/temp/getHead.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getHead", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - } 16 - } 17 - }, 18 - "output": { 19 - "encoding": "application/json", 20 - "schema": { 21 - "type": "ref", 22 - "ref": "sh.tangled.git.temp.defs#branch" 23 - } 24 - }, 25 - "errors": [ 26 - { 27 - "name": "RepoNotFound", 28 - "description": "Repository not found or access denied" 29 - }, 30 - { 31 - "name": "InvalidRequest", 32 - "description": "Invalid request parameters" 33 - } 34 - ] 35 - } 36 - } 37 - }
-44
lexicons/git/temp/getTag.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getTag", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": [ 10 - "repo", 11 - "tag" 12 - ], 13 - "properties": { 14 - "repo": { 15 - "type": "string", 16 - "format": "at-uri", 17 - "description": "AT-URI of the repository" 18 - }, 19 - "tag": { 20 - "type": "string", 21 - "description": "Name of tag, such as v1.3.0" 22 - } 23 - } 24 - }, 25 - "output": { 26 - "encoding": "*/*" 27 - }, 28 - "errors": [ 29 - { 30 - "name": "RepoNotFound", 31 - "description": "Repository not found or access denied" 32 - }, 33 - { 34 - "name": "TagNotFound", 35 - "description": "Tag not found" 36 - }, 37 - { 38 - "name": "InvalidRequest", 39 - "description": "Invalid request parameters" 40 - } 41 - ] 42 - } 43 - } 44 - }
-183
lexicons/git/temp/getTree.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.getTree", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": [ 10 - "repo", 11 - "ref" 12 - ], 13 - "properties": { 14 - "repo": { 15 - "type": "string", 16 - "format": "at-uri", 17 - "description": "AT-URI of the repository" 18 - }, 19 - "ref": { 20 - "type": "string", 21 - "description": "Git reference (branch, tag, or commit SHA)" 22 - }, 23 - "path": { 24 - "type": "string", 25 - "description": "Path within the repository tree", 26 - "default": "" 27 - } 28 - } 29 - }, 30 - "output": { 31 - "encoding": "application/json", 32 - "schema": { 33 - "type": "object", 34 - "required": [ 35 - "ref", 36 - "files" 37 - ], 38 - "properties": { 39 - "ref": { 40 - "type": "string", 41 - "description": "The git reference used" 42 - }, 43 - "parent": { 44 - "type": "string", 45 - "description": "The parent path in the tree" 46 - }, 47 - "dotdot": { 48 - "type": "string", 49 - "description": "Parent directory path" 50 - }, 51 - "readme": { 52 - "type": "ref", 53 - "ref": "#readme", 54 - "description": "Readme for this file tree" 55 - }, 56 - "lastCommit": { 57 - "type": "ref", 58 - "ref": "#lastCommit" 59 - }, 60 - "files": { 61 - "type": "array", 62 - "items": { 63 - "type": "ref", 64 - "ref": "#treeEntry" 65 - } 66 - } 67 - } 68 - } 69 - }, 70 - "errors": [ 71 - { 72 - "name": "RepoNotFound", 73 - "description": "Repository not found or access denied" 74 - }, 75 - { 76 - "name": "RefNotFound", 77 - "description": "Git reference not found" 78 - }, 79 - { 80 - "name": "PathNotFound", 81 - "description": "Path not found in repository tree" 82 - }, 83 - { 84 - "name": "InvalidRequest", 85 - "description": "Invalid request parameters" 86 - } 87 - ] 88 - }, 89 - "readme": { 90 - "type": "object", 91 - "required": [ 92 - "filename", 93 - "contents" 94 - ], 95 - "properties": { 96 - "filename": { 97 - "type": "string", 98 - "description": "Name of the readme file" 99 - }, 100 - "contents": { 101 - "type": "string", 102 - "description": "Contents of the readme file" 103 - } 104 - } 105 - }, 106 - "treeEntry": { 107 - "type": "object", 108 - "required": [ 109 - "name", 110 - "mode", 111 - "size" 112 - ], 113 - "properties": { 114 - "name": { 115 - "type": "string", 116 - "description": "Relative file or directory name" 117 - }, 118 - "mode": { 119 - "type": "string", 120 - "description": "File mode" 121 - }, 122 - "size": { 123 - "type": "integer", 124 - "description": "File size in bytes" 125 - }, 126 - "last_commit": { 127 - "type": "ref", 128 - "ref": "#lastCommit" 129 - } 130 - } 131 - }, 132 - "lastCommit": { 133 - "type": "object", 134 - "required": [ 135 - "hash", 136 - "message", 137 - "when" 138 - ], 139 - "properties": { 140 - "hash": { 141 - "type": "string", 142 - "description": "Commit hash" 143 - }, 144 - "message": { 145 - "type": "string", 146 - "description": "Commit message" 147 - }, 148 - "author": { 149 - "type": "ref", 150 - "ref": "#signature" 151 - }, 152 - "when": { 153 - "type": "string", 154 - "format": "datetime", 155 - "description": "Commit timestamp" 156 - } 157 - } 158 - }, 159 - "signature": { 160 - "type": "object", 161 - "required": [ 162 - "name", 163 - "email", 164 - "when" 165 - ], 166 - "properties": { 167 - "name": { 168 - "type": "string", 169 - "description": "Author name" 170 - }, 171 - "email": { 172 - "type": "string", 173 - "description": "Author email" 174 - }, 175 - "when": { 176 - "type": "string", 177 - "format": "datetime", 178 - "description": "Author timestamp" 179 - } 180 - } 181 - } 182 - } 183 - }
-44
lexicons/git/temp/listBranches.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.listBranches", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "limit": { 17 - "type": "integer", 18 - "description": "Maximum number of branches to return", 19 - "minimum": 1, 20 - "maximum": 100, 21 - "default": 50 22 - }, 23 - "cursor": { 24 - "type": "string", 25 - "description": "Pagination cursor" 26 - } 27 - } 28 - }, 29 - "output": { 30 - "encoding": "*/*" 31 - }, 32 - "errors": [ 33 - { 34 - "name": "RepoNotFound", 35 - "description": "Repository not found or access denied" 36 - }, 37 - { 38 - "name": "InvalidRequest", 39 - "description": "Invalid request parameters" 40 - } 41 - ] 42 - } 43 - } 44 - }
-56
lexicons/git/temp/listCommits.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.listCommits", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "ref": { 17 - "type": "string", 18 - "description": "Git reference (branch, tag, or commit SHA)" 19 - }, 20 - "limit": { 21 - "type": "integer", 22 - "description": "Maximum number of commits to return", 23 - "minimum": 1, 24 - "maximum": 100, 25 - "default": 50 26 - }, 27 - "cursor": { 28 - "type": "string", 29 - "description": "Pagination cursor (commit SHA)" 30 - } 31 - } 32 - }, 33 - "output": { 34 - "encoding": "*/*" 35 - }, 36 - "errors": [ 37 - { 38 - "name": "RepoNotFound", 39 - "description": "Repository not found or access denied" 40 - }, 41 - { 42 - "name": "RefNotFound", 43 - "description": "Git reference not found" 44 - }, 45 - { 46 - "name": "PathNotFound", 47 - "description": "Path not found in repository" 48 - }, 49 - { 50 - "name": "InvalidRequest", 51 - "description": "Invalid request parameters" 52 - } 53 - ] 54 - } 55 - } 56 - }
-100
lexicons/git/temp/listLanguages.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.listLanguages", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "ref": { 17 - "type": "string", 18 - "description": "Git reference (branch, tag, or commit SHA)", 19 - "default": "HEAD" 20 - } 21 - } 22 - }, 23 - "output": { 24 - "encoding": "application/json", 25 - "schema": { 26 - "type": "object", 27 - "required": ["ref", "languages"], 28 - "properties": { 29 - "ref": { 30 - "type": "string", 31 - "description": "The git reference used" 32 - }, 33 - "languages": { 34 - "type": "array", 35 - "items": { 36 - "type": "ref", 37 - "ref": "#language" 38 - } 39 - }, 40 - "totalSize": { 41 - "type": "integer", 42 - "description": "Total size of all analyzed files in bytes" 43 - }, 44 - "totalFiles": { 45 - "type": "integer", 46 - "description": "Total number of files analyzed" 47 - } 48 - } 49 - } 50 - }, 51 - "errors": [ 52 - { 53 - "name": "RepoNotFound", 54 - "description": "Repository not found or access denied" 55 - }, 56 - { 57 - "name": "RefNotFound", 58 - "description": "Git reference not found" 59 - }, 60 - { 61 - "name": "InvalidRequest", 62 - "description": "Invalid request parameters" 63 - } 64 - ] 65 - }, 66 - "language": { 67 - "type": "object", 68 - "required": ["name", "size", "percentage"], 69 - "properties": { 70 - "name": { 71 - "type": "string", 72 - "description": "Programming language name" 73 - }, 74 - "size": { 75 - "type": "integer", 76 - "description": "Total size of files in this language (bytes)" 77 - }, 78 - "percentage": { 79 - "type": "integer", 80 - "description": "Percentage of total codebase (0-100)" 81 - }, 82 - "fileCount": { 83 - "type": "integer", 84 - "description": "Number of files in this language" 85 - }, 86 - "color": { 87 - "type": "string", 88 - "description": "Hex color code for this language" 89 - }, 90 - "extensions": { 91 - "type": "array", 92 - "items": { 93 - "type": "string" 94 - }, 95 - "description": "File extensions associated with this language" 96 - } 97 - } 98 - } 99 - } 100 - }
-44
lexicons/git/temp/listTags.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.git.temp.listTags", 4 - "defs": { 5 - "main": { 6 - "type": "query", 7 - "parameters": { 8 - "type": "params", 9 - "required": ["repo"], 10 - "properties": { 11 - "repo": { 12 - "type": "string", 13 - "format": "at-uri", 14 - "description": "AT-URI of the repository" 15 - }, 16 - "limit": { 17 - "type": "integer", 18 - "description": "Maximum number of tags to return", 19 - "minimum": 1, 20 - "maximum": 100, 21 - "default": 50 22 - }, 23 - "cursor": { 24 - "type": "string", 25 - "description": "Pagination cursor" 26 - } 27 - } 28 - }, 29 - "output": { 30 - "encoding": "*/*" 31 - }, 32 - "errors": [ 33 - { 34 - "name": "RepoNotFound", 35 - "description": "Repository not found or access denied" 36 - }, 37 - { 38 - "name": "InvalidRequest", 39 - "description": "Invalid request parameters" 40 - } 41 - ] 42 - } 43 - } 44 - }
+20 -39
nix/gomod2nix.toml
··· 139 139 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 140 140 replaced = "tangled.sh/oppi.li/go-gitdiff" 141 141 [mod."github.com/bluesky-social/indigo"] 142 - version = "v0.0.0-20260315101958-fb1dfa36fed2" 143 - hash = "sha256-R5Dmcsi1a5LquA/a30YyjLAh7Mjg17EuTNVCDxyw4JE=" 144 - replaced = "github.com/boltlessengineer/indigo" 142 + version = "v0.0.0-20251003000214-3259b215110e" 143 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 145 144 [mod."github.com/bluesky-social/jetstream"] 146 - version = "v0.0.0-20260226214936-e0274250f654" 147 - hash = "sha256-VE93NvI3PreteLHnlv7WT6GgH2vSjtoFjMygCmrznfg=" 145 + version = "v0.0.0-20241210005130-ea96859b93d1" 146 + hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 148 147 [mod."github.com/bmatcuk/doublestar/v4"] 149 148 version = "v4.9.1" 150 149 hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" ··· 226 225 [mod."github.com/dustin/go-humanize"] 227 226 version = "v1.0.1" 228 227 hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 229 - [mod."github.com/earthboundkid/versioninfo/v2"] 230 - version = "v2.24.1" 231 - hash = "sha256-nbRdiX9WN2y1aiw1CR/DQ6AYqztow8FazndwY3kByHM=" 232 228 [mod."github.com/emirpasic/gods"] 233 229 version = "v1.18.1" 234 230 hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" ··· 398 394 [mod."github.com/ipfs/go-metrics-interface"] 399 395 version = "v0.3.0" 400 396 hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 401 - [mod."github.com/jackc/pgpassfile"] 402 - version = "v1.0.0" 403 - hash = "sha256-H0nFbC34/3pZUFnuiQk9W7yvAMh6qJDrqvHp+akBPLM=" 404 - [mod."github.com/jackc/pgservicefile"] 405 - version = "v0.0.0-20240606120523-5a60cdf6a761" 406 - hash = "sha256-ETpGsLAA2wcm5xJBayr/mZrCE1YsWbnkbSSX3ptrFn0=" 407 - [mod."github.com/jackc/pgx/v5"] 408 - version = "v5.8.0" 409 - hash = "sha256-Mq5/A/Obcceu6kKxUv30DPC2ZaVvD8Iq/YtmLm1BVec=" 410 - [mod."github.com/jackc/puddle/v2"] 411 - version = "v2.2.2" 412 - hash = "sha256-IUxdu4JYfsCh/qlz2SiUWu7EVPHhyooiVA4oaS2Z6yk=" 413 397 [mod."github.com/json-iterator/go"] 414 398 version = "v1.1.12" 415 399 hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" ··· 519 503 version = "v1.5.5" 520 504 hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8=" 521 505 [mod."github.com/prometheus/client_golang"] 522 - version = "v1.23.2" 523 - hash = "sha256-3GD4fBFa1tJu8MS4TNP6r2re2eViUE+kWUaieIOQXCg=" 506 + version = "v1.22.0" 507 + hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ=" 524 508 [mod."github.com/prometheus/client_model"] 525 509 version = "v0.6.2" 526 510 hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 527 511 [mod."github.com/prometheus/common"] 528 - version = "v0.66.1" 529 - hash = "sha256-bqHPaV9IV70itx63wqwgy2PtxMN0sn5ThVxDmiD7+Tk=" 512 + version = "v0.64.0" 513 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 530 514 [mod."github.com/prometheus/procfs"] 531 515 version = "v0.16.1" 532 516 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" ··· 559 543 version = "v0.0.0-20220730225603-2ab79fcdd4ef" 560 544 hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 561 545 [mod."github.com/stretchr/testify"] 562 - version = "v1.11.1" 563 - hash = "sha256-sWfjkuKJyDllDEtnM8sb/pdLzPQmUYWYtmeWz/5suUc=" 546 + version = "v1.10.0" 547 + hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 564 548 [mod."github.com/tidwall/gjson"] 565 549 version = "v1.18.0" 566 550 hash = "sha256-CO6hqDu8Y58Po6A01e5iTpwiUBQ5khUZsw7czaJHw0I=" ··· 574 558 version = "v1.2.5" 575 559 hash = "sha256-OYGNolkmL7E1Qs2qrQ3IVpQp5gkcHNU/AB/z2O+Myps=" 576 560 [mod."github.com/urfave/cli/v3"] 577 - version = "v3.4.1" 578 - hash = "sha256-cDMaQrIVMthUhdyI1mKXzDC5/wIK151073lzRl92RnA=" 561 + version = "v3.3.3" 562 + hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" 579 563 [mod."github.com/vmihailenco/go-tinylfu"] 580 564 version = "v0.2.2" 581 565 hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM=" ··· 645 629 [mod."go.uber.org/zap"] 646 630 version = "v1.27.0" 647 631 hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 648 - [mod."go.yaml.in/yaml/v2"] 649 - version = "v2.4.2" 650 - hash = "sha256-oC8RWdf1zbMYCtmR0ATy/kCkhIwPR9UqFZSMOKLVF/A=" 651 632 [mod."golang.org/x/crypto"] 652 - version = "v0.41.0" 653 - hash = "sha256-o5Di0lsFmYnXl7a5MBTqmN9vXMCRpE9ay71C1Ar8jEY=" 633 + version = "v0.40.0" 634 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 654 635 [mod."golang.org/x/exp"] 655 636 version = "v0.0.0-20250620022241-b7579e27df2b" 656 637 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" ··· 658 639 version = "v0.31.0" 659 640 hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 660 641 [mod."golang.org/x/net"] 661 - version = "v0.43.0" 662 - hash = "sha256-bf3iQFrsC8BoarVaS0uSspEFAcr1zHp1uziTtBpwV34=" 642 + version = "v0.42.0" 643 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 663 644 [mod."golang.org/x/sync"] 664 645 version = "v0.17.0" 665 646 hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 666 647 [mod."golang.org/x/sys"] 667 - version = "v0.35.0" 668 - hash = "sha256-ZKM8pesQE6NAFZeKQ84oPn5JMhGr8g4TSwLYAsHMGSI=" 648 + version = "v0.34.0" 649 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 669 650 [mod."golang.org/x/text"] 670 651 version = "v0.29.0" 671 652 hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" ··· 685 666 version = "v1.73.0" 686 667 hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 687 668 [mod."google.golang.org/protobuf"] 688 - version = "v1.36.8" 689 - hash = "sha256-yZN8ZON0b5HjUNUSubHst7zbvnMsOzd81tDPYQRtPgM=" 669 + version = "v1.36.6" 670 + hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" 690 671 [mod."gopkg.in/fsnotify.v1"] 691 672 version = "v1.4.7" 692 673 hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8="
-143
nix/modules/knotmirror.nix
··· 1 - { 2 - config, 3 - pkgs, 4 - lib, 5 - ... 6 - }: let 7 - cfg = config.services.tangled.knotmirror; 8 - in 9 - with lib; { 10 - options.services.tangled.knotmirror = { 11 - enable = mkOption { 12 - type = types.bool; 13 - default = false; 14 - description = "Enable a tangled knot"; 15 - }; 16 - 17 - package = mkOption { 18 - type = types.package; 19 - description = "Package to use for the knotmirror"; 20 - }; 21 - 22 - tap-package = mkOption { 23 - type = types.package; 24 - description = "tap package to use for the knotmirror"; 25 - }; 26 - 27 - listenAddr = mkOption { 28 - type = types.str; 29 - default = "0.0.0.0:7000"; 30 - description = "Address to listen on"; 31 - }; 32 - 33 - adminListenAddr = mkOption { 34 - type = types.str; 35 - default = "127.0.0.1:7200"; 36 - description = "Address to listen on"; 37 - }; 38 - 39 - hostname = mkOption { 40 - type = types.str; 41 - example = "my.knotmirror.com"; 42 - description = "Hostname for the server (required)"; 43 - }; 44 - 45 - dbUrl = mkOption { 46 - type = types.str; 47 - example = "postgresql://..."; 48 - description = "Database URL. postgresql expected (required)"; 49 - }; 50 - 51 - atpPlcUrl = mkOption { 52 - type = types.str; 53 - default = "https://plc.directory"; 54 - description = "atproto PLC directory"; 55 - }; 56 - 57 - atpRelayUrl = mkOption { 58 - type = types.str; 59 - default = "https://relay1.us-east.bsky.network"; 60 - description = "atproto relay"; 61 - }; 62 - 63 - fullNetwork = mkOption { 64 - type = types.bool; 65 - default = false; 66 - description = "Whether to automatically mirror from entire network"; 67 - }; 68 - 69 - tap = { 70 - port = mkOption { 71 - type = types.port; 72 - default = 7480; 73 - description = "Internal port to run the knotmirror tap"; 74 - }; 75 - 76 - dbUrl = mkOption { 77 - type = types.str; 78 - default = "sqlite:///var/lib/knotmirror-tap/tap.db"; 79 - description = "database connection string (sqlite://path or postgres://...)"; 80 - }; 81 - }; 82 - }; 83 - config = mkIf cfg.enable { 84 - environment.systemPackages = [ 85 - pkgs.git 86 - cfg.package 87 - ]; 88 - 89 - systemd.services.tap-knotmirror = { 90 - description = "knotmirror tap service"; 91 - after = ["network.target"]; 92 - wantedBy = ["multi-user.target"]; 93 - serviceConfig = { 94 - LogsDirectory = "knotmirror-tap"; 95 - StateDirectory = "knotmirror-tap"; 96 - Environment = [ 97 - "TAP_BIND=:${toString cfg.tap.port}" 98 - "TAP_PLC_URL=${cfg.atpPlcUrl}" 99 - "TAP_RELAY_URL=${cfg.atpRelayUrl}" 100 - "TAP_RESYNC_PARALLELISM=10" 101 - "TAP_DATABASE_URL=${cfg.tap.dbUrl}" 102 - "TAP_RETRY_TIMEOUT=60s" 103 - "TAP_COLLECTION_FILTERS=sh.tangled.repo" 104 - ( 105 - if cfg.fullNetwork 106 - then "TAP_SIGNAL_COLLECTION=sh.tangled.repo" 107 - else "TAP_FULL_NETWORK=false" 108 - ) 109 - ]; 110 - ExecStart = "${getExe cfg.tap-package} run"; 111 - }; 112 - }; 113 - 114 - systemd.services.knotmirror = { 115 - description = "knotmirror service"; 116 - after = ["network.target" "tap-knotmirror.service"]; 117 - wantedBy = ["multi-user.target"]; 118 - path = [ 119 - pkgs.git 120 - ]; 121 - serviceConfig = { 122 - LogsDirectory = "knotmirror"; 123 - StateDirectory = "knotmirror"; 124 - Environment = [ 125 - # TODO: add environment variables 126 - "MIRROR_LISTEN=${cfg.listenAddr}" 127 - "MIRROR_HOSTNAME=${cfg.hostname}" 128 - "MIRROR_TAP_URL=http://localhost:${toString cfg.tap.port}" 129 - "MIRROR_DB_URL=${cfg.dbUrl}" 130 - "MIRROR_GIT_BASEPATH=/var/lib/knotmirror/repos" 131 - "MIRROR_KNOT_USE_SSL=true" 132 - "MIRROR_KNOT_SSRF=true" 133 - "MIRROR_RESYNC_PARALLELISM=12" 134 - "MIRROR_METRICS_LISTEN=127.0.0.1:7100" 135 - "MIRROR_ADMIN_LISTEN=${cfg.adminListenAddr}" 136 - "MIRROR_SLURPER_CONCURRENCY=4" 137 - ]; 138 - ExecStart = "${getExe cfg.package} serve"; 139 - Restart = "always"; 140 - }; 141 - }; 142 - }; 143 - }
-18
nix/pkgs/knot-mirror.nix
··· 1 - { 2 - buildGoApplication, 3 - modules, 4 - src, 5 - }: 6 - buildGoApplication { 7 - pname = "knotmirror"; 8 - version = "0.1.0"; 9 - inherit src modules; 10 - 11 - doCheck = false; 12 - 13 - subPackages = ["cmd/knotmirror"]; 14 - 15 - meta = { 16 - mainProgram = "knotmirror"; 17 - }; 18 - }
-20
nix/pkgs/tap.nix
··· 1 - { 2 - buildGoModule, 3 - fetchFromGitHub, 4 - }: 5 - buildGoModule { 6 - pname = "tap"; 7 - version = "0.1.0"; 8 - src = fetchFromGitHub { 9 - owner = "bluesky-social"; 10 - repo = "indigo"; 11 - rev = "498ecb9693e8ae050f73234c86f340f51ad896a9"; 12 - sha256 = "sha256-KASCdwkg/hlKBt7RTW3e3R5J3hqJkphoarFbaMgtN1k="; 13 - }; 14 - subPackages = ["cmd/tap"]; 15 - vendorHash = "sha256-UOedwNYnM8Jx6B7Y9tFcZX8IeUBESAFAPTRYk7n0yo8="; 16 - doCheck = false; 17 - meta = { 18 - mainProgram = "tap"; 19 - }; 20 - }
-3
tapc/readme.md
··· 1 - basic tap client package 2 - 3 - Replace this to official indigo package when <https://github.com/bluesky-social/indigo/pull/1241> gets merged.
-24
tapc/simpleIndexer.go
··· 1 - package tapc 2 - 3 - import "context" 4 - 5 - type SimpleIndexer struct { 6 - EventHandler func(ctx context.Context, evt Event) error 7 - ErrorHandler func(ctx context.Context, err error) 8 - } 9 - 10 - var _ Handler = (*SimpleIndexer)(nil) 11 - 12 - func (i *SimpleIndexer) OnEvent(ctx context.Context, evt Event) error { 13 - if i.EventHandler == nil { 14 - return nil 15 - } 16 - return i.EventHandler(ctx, evt) 17 - } 18 - 19 - func (i *SimpleIndexer) OnError(ctx context.Context, err error) { 20 - if i.ErrorHandler == nil { 21 - return 22 - } 23 - i.ErrorHandler(ctx, err) 24 - }
-170
tapc/tap.go
··· 1 - /// heavily inspired by <https://github.com/bluesky-social/atproto/blob/c7f5a868837d3e9b3289f988fee2267789327b06/packages/tap/README.md> 2 - 3 - package tapc 4 - 5 - import ( 6 - "bytes" 7 - "context" 8 - "encoding/json" 9 - "fmt" 10 - "net/http" 11 - "net/url" 12 - "time" 13 - 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/gorilla/websocket" 16 - "tangled.org/core/log" 17 - ) 18 - 19 - type Handler interface { 20 - OnEvent(ctx context.Context, evt Event) error 21 - OnError(ctx context.Context, err error) 22 - } 23 - 24 - type Client struct { 25 - Url string 26 - AdminPassword string 27 - HTTPClient *http.Client 28 - } 29 - 30 - func NewClient(url, adminPassword string) Client { 31 - return Client{ 32 - Url: url, 33 - AdminPassword: adminPassword, 34 - HTTPClient: &http.Client{}, 35 - } 36 - } 37 - 38 - func (c *Client) AddRepos(ctx context.Context, dids []syntax.DID) error { 39 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 40 - if err != nil { 41 - return err 42 - } 43 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/add", bytes.NewReader(body)) 44 - if err != nil { 45 - return err 46 - } 47 - req.SetBasicAuth("admin", c.AdminPassword) 48 - req.Header.Set("Content-Type", "application/json") 49 - 50 - resp, err := c.HTTPClient.Do(req) 51 - if err != nil { 52 - return err 53 - } 54 - defer resp.Body.Close() 55 - if resp.StatusCode != http.StatusOK { 56 - return fmt.Errorf("tap: /repos/add failed with status %d", resp.StatusCode) 57 - } 58 - return nil 59 - } 60 - 61 - func (c *Client) RemoveRepos(ctx context.Context, dids []syntax.DID) error { 62 - body, err := json.Marshal(map[string][]syntax.DID{"dids": dids}) 63 - if err != nil { 64 - return err 65 - } 66 - req, err := http.NewRequestWithContext(ctx, "POST", c.Url+"/repos/remove", bytes.NewReader(body)) 67 - if err != nil { 68 - return err 69 - } 70 - req.SetBasicAuth("admin", c.AdminPassword) 71 - req.Header.Set("Content-Type", "application/json") 72 - 73 - resp, err := c.HTTPClient.Do(req) 74 - if err != nil { 75 - return err 76 - } 77 - defer resp.Body.Close() 78 - if resp.StatusCode != http.StatusOK { 79 - return fmt.Errorf("tap: /repos/remove failed with status %d", resp.StatusCode) 80 - } 81 - return nil 82 - } 83 - 84 - func (c *Client) Connect(ctx context.Context, handler Handler) error { 85 - l := log.FromContext(ctx) 86 - 87 - u, err := url.Parse(c.Url) 88 - if err != nil { 89 - return err 90 - } 91 - if u.Scheme == "https" { 92 - u.Scheme = "wss" 93 - } else { 94 - u.Scheme = "ws" 95 - } 96 - u.Path = "/channel" 97 - 98 - // TODO: set auth on dial 99 - 100 - url := u.String() 101 - 102 - var backoff int 103 - for { 104 - select { 105 - case <-ctx.Done(): 106 - return ctx.Err() 107 - default: 108 - } 109 - 110 - header := http.Header{ 111 - "Authorization": []string{""}, 112 - } 113 - conn, res, err := websocket.DefaultDialer.DialContext(ctx, url, header) 114 - if err != nil { 115 - l.Warn("dialing failed", "url", url, "err", err, "backoff", backoff) 116 - time.Sleep(time.Duration(5+backoff) * time.Second) 117 - backoff++ 118 - 119 - continue 120 - } 121 - l.Info("connected to tap service") 122 - 123 - l.Info("tap event subscription response", "code", res.StatusCode) 124 - 125 - if err = c.handleConnection(ctx, conn, handler); err != nil { 126 - l.Warn("tap connection failed", "err", err, "backoff", backoff) 127 - } 128 - } 129 - } 130 - 131 - func (c *Client) handleConnection(ctx context.Context, conn *websocket.Conn, handler Handler) error { 132 - l := log.FromContext(ctx) 133 - 134 - defer func() { 135 - conn.Close() 136 - l.Warn("closed tap conection") 137 - }() 138 - l.Info("established tap conection") 139 - 140 - for { 141 - select { 142 - case <-ctx.Done(): 143 - return ctx.Err() 144 - default: 145 - } 146 - _, message, err := conn.ReadMessage() 147 - if err != nil { 148 - return err 149 - } 150 - 151 - var ev Event 152 - if err := json.Unmarshal(message, &ev); err != nil { 153 - handler.OnError(ctx, fmt.Errorf("failed to parse message: %w", err)) 154 - continue 155 - } 156 - if err := handler.OnEvent(ctx, ev); err != nil { 157 - handler.OnError(ctx, fmt.Errorf("failed to process event %d: %w", ev.ID, err)) 158 - continue 159 - } 160 - 161 - ack := map[string]any{ 162 - "type": "ack", 163 - "id": ev.ID, 164 - } 165 - if err := conn.WriteJSON(ack); err != nil { 166 - l.Warn("failed to send ack", "err", err) 167 - continue 168 - } 169 - } 170 - }
-62
tapc/types.go
··· 1 - package tapc 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - 7 - "github.com/bluesky-social/indigo/atproto/syntax" 8 - ) 9 - 10 - type EventType string 11 - 12 - const ( 13 - EvtRecord EventType = "record" 14 - EvtIdentity EventType = "identity" 15 - ) 16 - 17 - type Event struct { 18 - ID int64 `json:"id"` 19 - Type EventType `json:"type"` 20 - Record *RecordEventData `json:"record,omitempty"` 21 - Identity *IdentityEventData `json:"identity,omitempty"` 22 - } 23 - 24 - type RecordEventData struct { 25 - Live bool `json:"live"` 26 - Did syntax.DID `json:"did"` 27 - Rev string `json:"rev"` 28 - Collection syntax.NSID `json:"collection"` 29 - Rkey syntax.RecordKey `json:"rkey"` 30 - Action RecordAction `json:"action"` 31 - Record json.RawMessage `json:"record,omitempty"` 32 - CID *syntax.CID `json:"cid,omitempty"` 33 - } 34 - 35 - func (r *RecordEventData) AtUri() syntax.ATURI { 36 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, r.Collection, r.Rkey)) 37 - } 38 - 39 - type RecordAction string 40 - 41 - const ( 42 - RecordCreateAction RecordAction = "create" 43 - RecordUpdateAction RecordAction = "update" 44 - RecordDeleteAction RecordAction = "delete" 45 - ) 46 - 47 - type IdentityEventData struct { 48 - DID syntax.DID `json:"did"` 49 - Handle string `json:"handle"` 50 - IsActive bool `json:"is_active"` 51 - Status RepoStatus `json:"status"` 52 - } 53 - 54 - type RepoStatus string 55 - 56 - const ( 57 - RepoStatusActive RepoStatus = "active" 58 - RepoStatusTakendown RepoStatus = "takendown" 59 - RepoStatusSuspended RepoStatus = "suspended" 60 - RepoStatusDeactivated RepoStatus = "deactivated" 61 - RepoStatusDeleted RepoStatus = "deleted" 62 - )

History

5 rounds 20 comments
sign up or login to add to the discussion
1 commit
expand
appview/ogcard: split rendering into external worker service using satori and resvg-wasm
expand 5 comments

for some reason the amount of files changed here (36) is lower than the amount of files changed on my fork (42).. any ideas? 0927d254

it seems like this pr doesn't have the exact same content as my commit?

i definitely see 42 changed files with +2214/-1481 on here! (you may be viewing an earlier round perhaps?)!

commit message is perfect, i do have some minor code change comments, but i am happy to handle that outside of this PR.

ah yes you're right, i didn't realize i was still on an earlier round! great, lmk if you need anything else :)

appview/ogcard/bun.lock:1

Not really related to this PR, but somehow Bun's lockfile is not collapsed by default. Maybe a bug in go-enry?

yup it is a known issue in enry!

pull request successfully merged
eti.tf submitted #3
23 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
appview/ogcard: improve fixtures.ts mock text
appview/ogcard: remove comment from logo.tsx
appview/ogcard: add knip dependency
appview/ogcard: commit bun.lock
appview/ogcard/components: add more font constants and use them
appview/ogcard/icons: remove unused exports
appview/ogcard: remove unused export from validation.ts
appview/ogcard: simplify wrangler.jsonc
appview/ogcard: fix language circles not being draw properly
appview/ogcard: show comments and reaction count only when larger than 0
appview/ogcard: switch repo card "updated at" to "created at"
appview/ogcard: replace remnant s with s
appview/ogcard: fix type issues
appview/ogcard: add TypeScript declaration for wasm module imports
appview/ogcard: pin satori to exact version to ensure wasm compatibility
appview/ogcard: add global_navigator compatibility flag for cf workers
appview/ogcard: load satori wasm module directly instead of from cdn
appview/ogcard: switch to wasm resvg and bundled fonts for cf Workers
appview/ogcard: extract satori/resvg runtime into separate package
appview/ogcard: format code with prettier
expand 0 comments
5 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
appview/ogcard: improve fixtures.ts mock text
appview/ogcard: remove comment from logo.tsx
expand 11 comments

appview/ogcard/src/index.tsx:85 I think that if we 500 out, we should not show the stack to the public, just as a security measure

appview/ogcard/wrangler.jsonc:6 is this subbed out at runtime?

appview/ogcard/.gitignore:2 bun huh? :P I think it would be nice to commit the lockfile in fact

appview/ogcard/src/components/shared/language-circles.tsx:9 Another unused constant, or maybe ctrl+f on this PR page doesn't catch the full picture

appview/ogcard/src/components/shared/footer-stats.tsx:19 would be cool if we did conditional rendering here for the 0 reactions case so that people don't get sad that their repo has 0 reactions advertised so blatantly heh

appview/repo/opengraph.go:83 we don't have an updatedAt on git repos, do we...

appview/repo/opengraph.go:92 doesn't this just render the actual timestamp itself?

appview/ogcard/src/lib/render.ts:17 I'm not super familiar with cf workers but fetching inside a worker doesn't seem super nice. could we inline it? I also noticed that package.json has this at ^0.25.0 which would make a mismatch pretty quickly

my only comment: can we squash the to 1 (and rebase on latest master perhaps)?

would be ripe if you could update the new commit to adhere to the commit guidelines, it would also need DCO.

don't pay attention to #3... it was uh... nothing feel free to take a look at the latest squashed commit, let me know your thoughts!

3 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
expand 2 comments

appview/ogcard/src/components/shared/logo.tsx:1

nit: this has been customized already, yes? :P

good catch ๐Ÿ˜ธ

eti.tf submitted #0
3 commits
expand
refactor: move og card rendering to external worker service
update packages to their latest version
wire up opengraph handlers to use ogcard HTTP client
expand 2 comments

could you rebase on latest master?

might need a rebase!