Monorepo for Tangled tangled.org

spindled/engine: store workflow logs in s3 #1189

closed opened by jobala.tngl.sh targeting master from jobala.tngl.sh/tangled: s3-logs

Overview#

This pr implements the first part of this proposal and introduces two new environment variables.

  • UploadWorkflowLogs, is set to true then the workflow's logs will be uploaded to s3
  • LogsBucket, specifies the logs bucket Stores workflow logs in s3
Labels
enhancement
assignee

None yet.

Participants 2
AT URI
at://did:plc:qcqdzn5ohjxyp2ilrunon6kn/sh.tangled.repo.pull/3mhjk74eevk22
+860 -8811
Diff #1
-12
.air/blog.toml
··· 1 - root = "." 2 - tmp_dir = "out" 3 - 4 - [build] 5 - cmd = "go build -o out/blog.out cmd/blog/main.go" 6 - bin = "out/blog.out" 7 - args_bin = ["serve"] 8 - 9 - include_ext = ["go", "html", "md"] 10 - include_dir = ["cmd/blog", "blog"] 11 - exclude_dir = ["nix", "tmp", "out"] 12 - stop_on_error = true
-3
.gitignore
··· 21 21 # Created if following hacking.md 22 22 genjwks.out 23 23 /nix/vm-data 24 - blog/build/ 25 - build/ 26 - .wrangler/
-33
.tangled/workflows/deploy-blog.yml
··· 1 - engine: nixery 2 - when: 3 - - event: push 4 - branch: master 5 - 6 - dependencies: 7 - nixpkgs: 8 - - go 9 - - gcc 10 - - nodejs 11 - - tailwindcss 12 - 13 - steps: 14 - - name: patch static dir 15 - command: | 16 - mkdir -p appview/pages/static 17 - touch appview/pages/static/x 18 - 19 - - name: generate css 20 - command: | 21 - tailwindcss -i input.css -o appview/pages/static/tw.css 22 - 23 - - name: build blog cmd 24 - command: | 25 - go build -o blog.out ./cmd/blog 26 - 27 - - name: build static site 28 - command: | 29 - ./blog.out build 30 - 31 - - name: deploy 32 - command: | 33 - npx --yes wrangler pages deploy --branch master --project-name tangled-blog ./build
-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 - }
+16 -21
appview/config/config.go
··· 46 46 PLCURL string `env:"URL, default=https://plc.directory"` 47 47 } 48 48 49 - type KnotMirrorConfig struct { 50 - Url string `env:"URL, default=https://mirror.tangled.network"` 51 - } 52 - 53 49 type JetstreamConfig struct { 54 50 Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 55 51 } ··· 154 150 } 155 151 156 152 type Config struct { 157 - Core CoreConfig `env:",prefix=TANGLED_"` 158 - Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` 159 - Knotstream ConsumerConfig `env:",prefix=TANGLED_KNOTSTREAM_"` 160 - Spindlestream ConsumerConfig `env:",prefix=TANGLED_SPINDLESTREAM_"` 161 - Resend ResendConfig `env:",prefix=TANGLED_RESEND_"` 162 - Posthog PosthogConfig `env:",prefix=TANGLED_POSTHOG_"` 163 - Camo CamoConfig `env:",prefix=TANGLED_CAMO_"` 164 - Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 165 - OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 166 - Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 167 - Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 168 - Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 169 - Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 170 - Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 171 - Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` 172 - Sites SitesConfig `env:",prefix=TANGLED_SITES_"` 173 - KnotMirror KnotMirrorConfig `env:",prefix=TANGLED_KNOTMIRROR_"` 153 + Core CoreConfig `env:",prefix=TANGLED_"` 154 + Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` 155 + Knotstream ConsumerConfig `env:",prefix=TANGLED_KNOTSTREAM_"` 156 + Spindlestream ConsumerConfig `env:",prefix=TANGLED_SPINDLESTREAM_"` 157 + Resend ResendConfig `env:",prefix=TANGLED_RESEND_"` 158 + Posthog PosthogConfig `env:",prefix=TANGLED_POSTHOG_"` 159 + Camo CamoConfig `env:",prefix=TANGLED_CAMO_"` 160 + Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 161 + OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 162 + Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 163 + Plc PlcConfig `env:",prefix=TANGLED_PLC_"` 164 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 165 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 166 + Label LabelConfig `env:",prefix=TANGLED_LABEL_"` 167 + Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` 168 + Sites SitesConfig `env:",prefix=TANGLED_SITES_"` 174 169 } 175 170 176 171 func LoadConfig(ctx context.Context) (*Config, error) {
+4 -7
appview/filetree/filetree.go
··· 10 10 Name string 11 11 Path string 12 12 IsDirectory bool 13 - Level int 14 13 Children map[string]*FileTreeNode 15 14 } 16 15 17 - // newNode creates a new node 18 - func newNode(name, path string, isDir bool, level int) *FileTreeNode { 16 + // NewNode creates a new node 17 + func newNode(name, path string, isDir bool) *FileTreeNode { 19 18 return &FileTreeNode{ 20 19 Name: name, 21 20 Path: path, 22 21 IsDirectory: isDir, 23 - Level: level, 24 22 Children: make(map[string]*FileTreeNode), 25 23 } 26 24 } 27 25 28 26 func FileTree(files []string) *FileTreeNode { 29 - rootNode := newNode("", "", true, 0) 27 + rootNode := newNode("", "", true) 30 28 31 29 sort.Strings(files) 32 30 ··· 51 49 } 52 50 53 51 isDir := i < len(parts)-1 54 - level := i + 1 55 52 56 53 if _, exists := currentNode.Children[part]; !exists { 57 - currentNode.Children[part] = newNode(part, currentPath, isDir, level) 54 + currentNode.Children[part] = newNode(part, currentPath, isDir) 58 55 } 59 56 60 57 currentNode = currentNode.Children[part]
+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" ··· 1098 1098 // this is used to rollback changes made to the PDS 1099 1099 // 1100 1100 // it is a no-op if the provided ATURI is empty 1101 - func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 1101 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1102 1102 if aturi == "" { 1103 1103 return nil 1104 1104 }
+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)
+25 -34
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"}) ··· 298 295 299 296 lexer := lexers.Get(filepath.Base(path)) 300 297 if lexer == nil { 301 - if firstLine, _, ok := strings.Cut(content, "\n"); ok && strings.HasPrefix(firstLine, "#!") { 302 - // extract interpreter from shebang (handles "#!/usr/bin/env nu", "#!/usr/bin/nu", etc.) 303 - fields := strings.Fields(firstLine[2:]) 304 - if len(fields) > 0 { 305 - interp := filepath.Base(fields[len(fields)-1]) 306 - lexer = lexers.Get(interp) 307 - } 308 - } 309 - } 310 - if lexer == nil { 311 - lexer = lexers.Analyse(content) 312 - } 313 - if lexer == nil { 314 298 lexer = lexers.Fallback 315 299 } 316 300 ··· 408 392 "placeholderAvatar": func(size string) template.HTML { 409 393 sizeClass := "size-6" 410 394 iconSize := "size-4" 411 - switch size { 412 - case "tiny": 395 + if size == "tiny" { 413 396 sizeClass = "size-6" 414 397 iconSize = "size-4" 415 - case "small": 398 + } else if size == "small" { 416 399 sizeClass = "size-8" 417 400 iconSize = "size-5" 418 - default: 401 + } else { 419 402 sizeClass = "size-12" 420 403 iconSize = "size-8" 421 404 } ··· 499 482 } 500 483 } 501 484 485 + func (p *Pages) resolveDid(did string) string { 486 + identity, err := p.resolver.ResolveIdent(context.Background(), did) 487 + 488 + if err != nil { 489 + return did 490 + } 491 + 492 + if identity.Handle.IsInvalidHandle() { 493 + return "handle.invalid" 494 + } 495 + 496 + return identity.Handle.String() 497 + } 498 + 502 499 func (p *Pages) AvatarUrl(actor, size string) string { 503 500 actor = strings.TrimPrefix(actor, "@") 504 501 ··· 511 508 } 512 509 513 510 secret := p.avatar.SharedSecret 514 - if secret == "" { 515 - return "" 516 - } 517 511 h := hmac.New(sha256.New, []byte(secret)) 518 512 h.Write([]byte(did)) 519 513 signature := hex.EncodeToString(h.Sum(nil)) 520 514 521 515 // Get avatar CID for cache busting 516 + profile, err := db.GetProfile(p.db, did) 522 517 version := "" 523 - if p.db != nil { 524 - profile, err := db.GetProfile(p.db, did) 525 - if err == nil && profile != nil && profile.Avatar != "" { 526 - // Use first 8 chars of avatar CID as version 527 - if len(profile.Avatar) > 8 { 528 - version = profile.Avatar[:8] 529 - } else { 530 - version = profile.Avatar 531 - } 518 + if err == nil && profile != nil && profile.Avatar != "" { 519 + // Use first 8 chars of avatar CID as version 520 + if len(profile.Avatar) > 8 { 521 + version = profile.Avatar[:8] 522 + } else { 523 + version = profile.Avatar 532 524 } 533 525 } 534 526 ··· 542 534 if version != "" { 543 535 return fmt.Sprintf("%s?v=%s", baseUrl, version) 544 536 } 545 - 546 537 return baseUrl 547 538 } 548 539
-64
appview/pages/markup/extension/dashes.go
··· 1 - package extension 2 - 3 - import ( 4 - "github.com/yuin/goldmark" 5 - gast "github.com/yuin/goldmark/ast" 6 - "github.com/yuin/goldmark/parser" 7 - "github.com/yuin/goldmark/text" 8 - "github.com/yuin/goldmark/util" 9 - ) 10 - 11 - type dashParser struct{} 12 - 13 - func (p *dashParser) Trigger() []byte { 14 - return []byte{'-'} 15 - } 16 - 17 - func (p *dashParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node { 18 - line, _ := block.PeekLine() 19 - if len(line) < 2 || line[0] != '-' || line[1] != '-' { 20 - return nil 21 - } 22 - node := gast.NewString([]byte("\u2014")) 23 - node.SetCode(true) 24 - block.Advance(2) 25 - return node 26 - } 27 - 28 - type digitDashParser struct{} 29 - 30 - func (p *digitDashParser) Trigger() []byte { 31 - return []byte{'-'} 32 - } 33 - 34 - func (p *digitDashParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node { 35 - line, _ := block.PeekLine() 36 - if len(line) < 2 { 37 - return nil 38 - } 39 - before := block.PrecendingCharacter() 40 - if before < '0' || before > '9' { 41 - return nil 42 - } 43 - if line[1] < '0' || line[1] > '9' { 44 - return nil 45 - } 46 - node := gast.NewString([]byte("\u2013")) 47 - node.SetCode(true) 48 - block.Advance(1) 49 - return node 50 - } 51 - 52 - type dashExt struct{} 53 - 54 - // Dashes replaces "--" with an em-dash (โ€”) and a hyphen between two digits 55 - // with an en-dash (โ€“). Implemented as an inline parser so it operates on the 56 - // raw byte stream, unaffected by hard-wrapped source lines. 57 - var Dashes goldmark.Extender = &dashExt{} 58 - 59 - func (e *dashExt) Extend(m goldmark.Markdown) { 60 - m.Parser().AddOptions(parser.WithInlineParsers( 61 - util.Prioritized(&dashParser{}, 9990), 62 - util.Prioritized(&digitDashParser{}, 9991), 63 - )) 64 - }
+22 -29
appview/pages/markup/markdown.go
··· 13 13 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 14 14 "github.com/alecthomas/chroma/v2/styles" 15 15 "github.com/yuin/goldmark" 16 - emoji "github.com/yuin/goldmark-emoji" 16 + "github.com/yuin/goldmark-emoji" 17 17 highlighting "github.com/yuin/goldmark-highlighting/v2" 18 18 "github.com/yuin/goldmark/ast" 19 19 "github.com/yuin/goldmark/extension" ··· 53 53 Files fs.FS 54 54 } 55 55 56 - func NewMarkdown(hostname string, extra ...goldmark.Extender) goldmark.Markdown { 57 - exts := []goldmark.Extender{ 58 - extension.GFM, 59 - &mermaid.Extender{ 60 - RenderMode: mermaid.RenderModeClient, 61 - NoScript: true, 62 - }, 63 - highlighting.NewHighlighting( 64 - highlighting.WithFormatOptions( 65 - chromahtml.Standalone(false), 66 - chromahtml.WithClasses(true), 56 + func NewMarkdown(hostname string) goldmark.Markdown { 57 + md := goldmark.New( 58 + goldmark.WithExtensions( 59 + extension.GFM, 60 + &mermaid.Extender{ 61 + RenderMode: mermaid.RenderModeClient, 62 + NoScript: true, 63 + }, 64 + highlighting.NewHighlighting( 65 + highlighting.WithFormatOptions( 66 + chromahtml.Standalone(false), 67 + chromahtml.WithClasses(true), 68 + ), 69 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 70 + ), 71 + extension.NewFootnote( 72 + extension.WithFootnoteIDPrefix([]byte("footnote")), 67 73 ), 68 - highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 69 - ), 70 - extension.NewFootnote( 71 - extension.WithFootnoteIDPrefix([]byte("footnote")), 74 + callout.CalloutExtention, 75 + textension.AtExt, 76 + textension.NewTangledLinkExt(hostname), 77 + emoji.Emoji, 72 78 ), 73 - callout.CalloutExtention, 74 - textension.AtExt, 75 - textension.NewTangledLinkExt(hostname), 76 - emoji.Emoji, 77 - } 78 - exts = append(exts, extra...) 79 - md := goldmark.New( 80 - goldmark.WithExtensions(exts...), 81 79 goldmark.WithParserOptions( 82 80 parser.WithAutoHeadingID(), 83 81 ), 84 82 goldmark.WithRendererOptions(html.WithUnsafe()), 85 83 ) 86 84 return md 87 - } 88 - 89 - // NewMarkdownWith is an alias for NewMarkdown with extra extensions. 90 - func NewMarkdownWith(hostname string, extra ...goldmark.Extender) goldmark.Markdown { 91 - return NewMarkdown(hostname, extra...) 92 85 } 93 86 94 87 func (rctx *RenderContext) RenderMarkdown(source string) string {
+1 -60
appview/pages/pages.go
··· 88 88 return "templates/" + s + ".html" 89 89 } 90 90 91 - // FuncMap returns the template function map for use by external template consumers. 92 - func (p *Pages) FuncMap() template.FuncMap { 93 - return p.funcMap() 94 - } 95 - 96 - // FragmentPaths returns all fragment template paths from the embedded FS. 97 - func (p *Pages) FragmentPaths() ([]string, error) { 98 - return p.fragmentPaths() 99 - } 100 - 101 - // EmbedFS returns the embedded filesystem containing templates and static assets. 102 - func (p *Pages) EmbedFS() fs.FS { 103 - return p.embedFS 104 - } 105 - 106 - // ParseWith parses the base layout together with all appview fragments and 107 - // an additional template from extraFS identified by extraPath (relative to 108 - // extraFS root). The returned template is ready to ExecuteTemplate with 109 - // "layouts/base" -- primarily for use with the blog. 110 - func (p *Pages) ParseWith(extraFS fs.FS, extraPath string) (*template.Template, error) { 111 - fragmentPaths, err := p.fragmentPaths() 112 - if err != nil { 113 - return nil, err 114 - } 115 - 116 - funcs := p.funcMap() 117 - tpl, err := template.New("layouts/base"). 118 - Funcs(funcs). 119 - ParseFS(p.embedFS, append(fragmentPaths, p.nameToPath("layouts/base"))...) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - err = fs.WalkDir(extraFS, ".", func(path string, d fs.DirEntry, err error) error { 125 - if err != nil { 126 - return err 127 - } 128 - if d.IsDir() || !strings.HasSuffix(path, ".html") { 129 - return nil 130 - } 131 - if path != extraPath && !strings.Contains(path, "fragments/") { 132 - return nil 133 - } 134 - data, err := fs.ReadFile(extraFS, path) 135 - if err != nil { 136 - return err 137 - } 138 - if _, err = tpl.New(path).Parse(string(data)); err != nil { 139 - return err 140 - } 141 - return nil 142 - }) 143 - if err != nil { 144 - return nil, err 145 - } 146 - 147 - return tpl, nil 148 - } 149 - 150 91 func (p *Pages) fragmentPaths() ([]string, error) { 151 92 var fragmentPaths []string 152 93 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { ··· 854 795 RepoInfo repoinfo.RepoInfo 855 796 Active string 856 797 BreadCrumbs [][]string 857 - Path string 798 + TreePath string 858 799 Raw bool 859 800 HTMLReadme template.HTML 860 801 EmailToDid map[string]string
+70 -223
appview/pages/templates/fragments/line-quote-button.html
··· 3 3 id="line-quote-btn" 4 4 type="button" 5 5 aria-label="Quote line in comment" 6 - class="hidden fixed z-50 p-0.5 rounded bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer shadow-sm transition-opacity opacity-0 flex flex-col items-start" 6 + class="hidden fixed z-50 p-0.5 rounded bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-600 cursor-pointer shadow-sm transition-opacity opacity-0" 7 7 style="pointer-events: none;" 8 8 > 9 9 {{ i "message-square-quote" "w-3.5 h-3.5" }} 10 - <span id="line-quote-btn-end" class="hidden mt-auto rotate-180 opacity-50"> 11 - {{ i "message-square-quote" "w-3.5 h-3.5" }} 12 - </span> 13 10 </button> 14 11 <script> 15 12 (() => { 16 13 const btn = document.getElementById('line-quote-btn'); 17 14 if (!btn) return; 18 - const btnEnd = document.getElementById('line-quote-btn-end'); 15 + 16 + let currentAnchor = null; 17 + let currentFileName = null; 19 18 20 - const textarea = () => 19 + const findTextarea = () => 21 20 document.getElementById('pull-comment-textarea') 22 21 || document.getElementById('comment-textarea'); 23 22 24 - const lineOf = (el) => 25 - el?.closest?.('span[id*="-O"]') 23 + const findLineEl = (el) => 24 + el?.closest?.('.line') 25 + || el?.closest?.('span[id*="-O"]') 26 26 || el?.closest?.('span[id*="-N"]'); 27 27 28 - const anchorOf = (el) => { 29 - const link = el.querySelector('a[href^="#"]'); 30 - return link ? link.getAttribute('href').slice(1) : el.id || null; 28 + const getAnchor = (lineEl) => { 29 + const link = lineEl.querySelector('a[href^="#"]'); 30 + return link ? link.getAttribute('href').slice(1) : lineEl.id || null; 31 31 }; 32 32 33 - const fileOf = (el) => { 34 - const d = el.closest('details[id^="file-"]'); 35 - return d ? d.id.replace(/^file-/, '') : null; 36 - }; 37 - 38 - const lineNumOf = (el) => anchorOf(el)?.match(/(\d+)(?:-[ON]?\d+)?$/)?.[1]; 39 - 40 - const columnOf = (el) => el.closest('.flex-col'); 41 - 42 - const linesInColumn = (col) => 43 - Array.from(col.querySelectorAll('span[id*="-O"], span[id*="-N"]')) 44 - .filter(s => s.querySelector('a[href^="#"]')); 45 - 46 - let dragLines = null; 47 - 48 - const rangeBetween = (a, b) => { 49 - const col = columnOf(a); 50 - if (!col || col !== columnOf(b)) return []; 51 - const all = dragLines || linesInColumn(col); 52 - const ai = all.indexOf(a); 53 - const bi = all.indexOf(b); 54 - if (ai === -1 || bi === -1) return []; 55 - return all.slice(Math.min(ai, bi), Math.max(ai, bi) + 1); 33 + const getFileName = (lineEl) => { 34 + const details = lineEl.closest('details[id^="file-"]'); 35 + if (details) return details.id.replace(/^file-/, ''); 36 + const bc = document.getElementById('breadcrumbs'); 37 + if (!bc) return null; 38 + const els = bc.querySelectorAll('.text-bold'); 39 + return els.length > 0 ? els[els.length - 1].textContent.trim() : null; 56 40 }; 57 41 58 - const clearHl = (cls) => 59 - document.querySelectorAll(`.${cls}`).forEach(el => el.classList.remove(cls)); 60 - 61 - const applyHl = (a, b, cls) => { 62 - clearHl(cls); 63 - const sel = rangeBetween(a, b); 64 - sel.forEach(el => el.classList.add(cls)); 65 - return sel; 66 - }; 42 + const show = (lineEl) => { 43 + if (!findTextarea()) return; 44 + const anchor = getAnchor(lineEl); 45 + if (!anchor) return; 67 46 68 - const highlightFromHash = () => { 69 - clearHl('line-range-hl'); 70 - const hash = decodeURIComponent(window.location.hash.slice(1)); 71 - if (!hash) return; 72 - const parts = hash.split('~'); 73 - const startEl = document.getElementById(parts[0]); 47 + currentAnchor = anchor; 48 + currentFileName = getFileName(lineEl); 74 49 75 - if (!startEl) { 76 - const params = new URLSearchParams(window.location.search); 77 - const hasCombined = parts.some(p => /-O\d+-N\d+$/.test(p)); 78 - if (hasCombined && params.get('diff') !== 'unified') { 79 - params.set('diff', 'unified'); 80 - window.location.replace( 81 - `${window.location.pathname}?${params}${window.location.hash}` 82 - ); 83 - } 84 - return; 85 - } 86 - 87 - const endEl = parts.length === 2 ? document.getElementById(parts[1]) : startEl; 88 - if (!endEl) return; 89 - 90 - const details = startEl.closest('details'); 91 - if (details) details.open = true; 92 - 93 - applyHl(startEl, endEl, 'line-range-hl'); 94 - requestAnimationFrame(() => 95 - startEl.scrollIntoView({ behavior: 'smooth', block: 'center' })); 96 - }; 97 - 98 - if (document.readyState === 'loading') { 99 - document.addEventListener('DOMContentLoaded', highlightFromHash); 100 - } else { 101 - highlightFromHash(); 102 - } 103 - window.addEventListener('hashchange', highlightFromHash); 104 - 105 - let dragging = false; 106 - let dragAnchor = null; 107 - let dragCurrent = null; 108 - let hoverTarget = null; 109 - 110 - const commentBtn = () => 111 - document.querySelector('[hx-get$="/comment"]:not(form *)'); 112 - 113 - const openCommentForm = () => { 114 - const ta = textarea(); 115 - if (ta) return Promise.resolve(ta); 116 - const trigger = commentBtn(); 117 - if (!trigger) return Promise.resolve(null); 118 - trigger.click(); 119 - return new Promise(resolve => { 120 - const handler = () => { 121 - const ta = textarea(); 122 - if (!ta) return; 123 - document.body.removeEventListener('htmx:afterSettle', handler); 124 - clearTimeout(timer); 125 - resolve(ta); 126 - }; 127 - const timer = setTimeout(() => { 128 - document.body.removeEventListener('htmx:afterSettle', handler); 129 - resolve(null); 130 - }, 5000); 131 - document.body.addEventListener('htmx:afterSettle', handler); 132 - }); 133 - }; 134 - 135 - const showBtn = (lineEl) => { 136 - if ((!textarea() && !commentBtn()) || !anchorOf(lineEl)) return; 137 50 const rect = lineEl.getBoundingClientRect(); 138 51 Object.assign(btn.style, { 139 52 top: `${rect.top + rect.height / 2 - btn.offsetHeight / 2}px`, 140 53 left: `${rect.left + 4}px`, 141 - height: '', 142 54 opacity: '1', 143 55 pointerEvents: 'auto', 144 56 }); 145 57 btn.classList.remove('hidden'); 146 58 }; 147 59 148 - const hideBtn = () => { 149 - if (dragging) return; 150 - Object.assign(btn.style, { opacity: '0', pointerEvents: 'none', height: '' }); 151 - btnEnd.classList.add('hidden'); 60 + const hide = () => { 61 + Object.assign(btn.style, { opacity: '0', pointerEvents: 'none' }); 152 62 setTimeout(() => { if (btn.style.opacity === '0') btn.classList.add('hidden'); }, 150); 153 63 }; 154 64 155 - const stretchBtn = (a, b) => { 156 - const aRect = a.getBoundingClientRect(); 157 - const bRect = b.getBoundingClientRect(); 158 - const top = Math.min(aRect.top, bRect.top); 159 - const bottom = Math.max(aRect.bottom, bRect.bottom); 160 - const multiLine = a !== b; 161 - Object.assign(btn.style, { 162 - top: `${top}px`, 163 - left: `${aRect.left + 4}px`, 164 - height: `${bottom - top}px`, 165 - }); 166 - if (multiLine) { btnEnd.classList.remove('hidden'); } 167 - else { btnEnd.classList.add('hidden'); } 168 - }; 65 + let hoverTarget = null; 169 66 170 67 document.addEventListener('mouseover', (e) => { 171 - if (dragging || e.target === btn || btn.contains(e.target)) return; 172 - const el = lineOf(e.target); 173 - if (el && el !== hoverTarget) { hoverTarget = el; showBtn(el); } 68 + const lineEl = findLineEl(e.target); 69 + if (lineEl && lineEl !== hoverTarget) { 70 + hoverTarget = lineEl; 71 + show(lineEl); 72 + } 174 73 }); 175 74 176 75 document.addEventListener('mouseout', (e) => { 177 - if (dragging) return; 178 - const el = lineOf(e.target); 179 - if (!el || lineOf(e.relatedTarget) === el || e.relatedTarget === btn || btn.contains(e.relatedTarget)) return; 76 + const lineEl = findLineEl(e.target); 77 + if (!lineEl) return; 78 + if (findLineEl(e.relatedTarget) === lineEl) return; 79 + if (e.relatedTarget === btn || btn.contains(e.relatedTarget)) return; 180 80 hoverTarget = null; 181 - hideBtn(); 81 + hide(); 182 82 }); 183 83 184 84 btn.addEventListener('mouseleave', (e) => { 185 - if (!dragging && !lineOf(e.relatedTarget)) { hoverTarget = null; hideBtn(); } 85 + if (!findLineEl(e.relatedTarget)) { 86 + hoverTarget = null; 87 + hide(); 88 + } 186 89 }); 187 90 188 - btn.addEventListener('mousedown', (e) => { 189 - if (e.button !== 0 || !hoverTarget) return; 91 + btn.addEventListener('click', (e) => { 190 92 e.preventDefault(); 191 - dragging = true; 192 - dragAnchor = dragCurrent = hoverTarget; 193 - const col = columnOf(hoverTarget); 194 - dragLines = col ? linesInColumn(col) : null; 195 - applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 196 - btn.style.pointerEvents = 'none'; 197 - document.body.style.userSelect = 'none'; 198 - }); 199 - 200 - document.addEventListener('mousemove', (e) => { 201 - if (!dragging) return; 202 - const el = lineOf(document.elementFromPoint(e.clientX, e.clientY)); 203 - if (!el || el === dragCurrent) return; 204 - if (columnOf(el) !== columnOf(dragAnchor)) return; 205 - dragCurrent = el; 206 - applyHl(dragAnchor, dragCurrent, 'line-quote-hl'); 207 - stretchBtn(dragAnchor, dragCurrent); 208 - }); 209 - 210 - const insertIntoTextarea = (ta, selected) => { 211 - const first = selected[0]; 212 - const last = selected[selected.length - 1]; 213 - const fNum = lineNumOf(first); 214 - const firstAnchor = anchorOf(first); 215 - 216 - if (!fNum || !firstAnchor) return; 217 - 218 - const file = fileOf(first); 219 - const lNum = lineNumOf(last); 220 - const lastAnchor = anchorOf(last); 93 + e.stopPropagation(); 94 + const textarea = findTextarea(); 95 + if (!textarea || !currentAnchor) return; 221 96 222 - const label = selected.length === 1 223 - ? (file ? `${file}:${fNum}` : `L${fNum}`) 224 - : (file ? `${file}:${fNum}-${lNum}` : `L${fNum}-${lNum}`); 225 - 226 - const fragment = selected.length === 1 227 - ? firstAnchor 228 - : `${firstAnchor}~${lastAnchor}`; 97 + const lineNum = currentAnchor.match(/[ON]?(\d+)(?:-[ON]?\d+)?$/)?.[1]; 98 + if (!lineNum) return; 229 99 230 - const linkBase = document.getElementById('round-link-base')?.value 231 - || (window.location.pathname + window.location.search); 232 - const md = `[\`${label}\`](${linkBase}#${fragment})`; 100 + const label = currentFileName ? `${currentFileName}:${lineNum}` : `L${lineNum}`; 101 + const md = `[\`${label}\`](${window.location.pathname}#${currentAnchor})`; 233 102 234 - const { selectionStart: s, selectionEnd: end, value } = ta; 235 - const before = value.slice(0, s); 103 + const { selectionStart: start, selectionEnd: end, value } = textarea; 104 + const before = value.slice(0, start); 236 105 const after = value.slice(end); 237 - let pre = '', suf = ''; 238 - if (s === end && before.length > 0) { 239 - const cur = before.slice(before.lastIndexOf('\n') + 1); 240 - if (cur.length > 0) { 106 + 107 + let prefix = ''; 108 + let suffix = ''; 109 + if (start === end && before.length > 0) { 110 + const currentLine = before.slice(before.lastIndexOf('\n') + 1); 111 + if (currentLine.length > 0) { 241 112 const nextNl = after.indexOf('\n'); 242 - const rest = nextNl === -1 ? after : after.slice(0, nextNl); 243 - if (rest.trim().length === 0) { pre = '\n'; } 244 - else { pre = before.endsWith(' ') ? '' : ' '; suf = after.startsWith(' ') ? '' : ' '; } 113 + const restOfLine = nextNl === -1 ? after : after.slice(0, nextNl); 114 + if (restOfLine.trim().length === 0) { 115 + prefix = '\n'; 116 + } else { 117 + prefix = before.endsWith(' ') ? '' : ' '; 118 + suffix = after.startsWith(' ') ? '' : ' '; 119 + } 245 120 } 246 121 } 247 - ta.value = before + pre + md + suf + after; 248 - ta.selectionStart = ta.selectionEnd = s + pre.length + md.length + suf.length; 249 - ta.focus(); 250 - ta.dispatchEvent(new Event('input', { bubbles: true })); 251 - }; 252 122 253 - document.addEventListener('mouseup', () => { 254 - if (!dragging) return; 255 - dragging = false; 256 - document.body.style.userSelect = ''; 257 - 258 - const selected = rangeBetween(dragAnchor, dragCurrent); 259 - if (selected.length > 0) { 260 - openCommentForm().then(ta => { 261 - if (ta) insertIntoTextarea(ta, selected); 262 - }); 263 - } 264 - 265 - clearHl('line-quote-hl'); 266 - dragLines = null; 267 - dragAnchor = dragCurrent = hoverTarget = null; 268 - hideBtn(); 123 + textarea.value = before + prefix + md + suffix + after; 124 + const pos = start + prefix.length + md.length + suffix.length; 125 + textarea.selectionStart = textarea.selectionEnd = pos; 126 + textarea.focus(); 127 + textarea.dispatchEvent(new Event('input', { bubbles: true })); 128 + textarea.dispatchEvent(new Event('keyup', { bubbles: true })); 269 129 }); 270 - 271 - btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); }); 272 - 273 - const cancelDrag = () => { 274 - if (!dragging) return; 275 - dragging = false; 276 - document.body.style.userSelect = ''; 277 - clearHl('line-quote-hl'); 278 - dragLines = null; 279 - dragAnchor = dragCurrent = hoverTarget = null; 280 - hideBtn(); 281 - }; 282 - window.addEventListener('blur', cancelDrag); 283 130 })(); 284 131 </script> 285 132 {{ end }}
+1 -1
appview/pages/templates/layouts/fragments/footer.html
··· 1 - {{ define "layouts/fragments/footerFull" }} 1 + {{ define "layouts/fragments/footer" }} 2 2 <div class="w-full p-8 bg-white dark:bg-gray-800"> 3 3 <div class="mx-auto px-4"> 4 4 <div class="flex flex-col text-gray-600 dark:text-gray-400 gap-8">
-20
appview/pages/templates/layouts/fragments/footerMinimal.html
··· 1 - {{ define "layouts/fragments/footer" }} 2 - <footer class="w-full px-6 py-4 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700"> 3 - <div class="max-w-screen-lg 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 - <div class="flex items-center justify-center gap-x-2 order-last sm:order-first w-full sm:w-auto"> 5 - <a href="/" hx-boost="true" class="no-underline hover:no-underline flex items-center"> 6 - {{ template "fragments/dolly/logo" (dict "Classes" "size-5 text-gray-500 dark:text-gray-400") }} 7 - </a> 8 - <span>&copy; 2026 Tangled Labs Oy.</span> 9 - </div> 10 - <a href="https://blog.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">blog</a> 11 - <a href="https://docs.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">docs</a> 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 - <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 - <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 - <a href="/terms" hx-boost="true" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">terms</a> 17 - <a href="/privacy" hx-boost="true" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">privacy</a> 18 - </div> 19 - </footer> 20 - {{ end }}
+5 -1
appview/pages/templates/repo/commit.html
··· 127 127 </div> 128 128 {{ end }} 129 129 130 - 130 + {{ define "footerLayout" }} 131 + <footer class="col-span-full mt-12"> 132 + {{ template "layouts/fragments/footer" . }} 133 + </footer> 134 + {{ end }} 131 135 132 136 {{ define "contentAfter" }} 133 137 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
+5 -1
appview/pages/templates/repo/compare/compare.html
··· 26 26 </div> 27 27 {{ end }} 28 28 29 - 29 + {{ define "footerLayout" }} 30 + <footer class="px-1 col-span-full mt-12"> 31 + {{ template "layouts/fragments/footer" . }} 32 + </footer> 33 + {{ end }} 30 34 31 35 {{ define "contentAfter" }} 32 36 {{ template "repo/fragments/diff" (list .Diff .DiffOpts) }}
+35 -53
appview/pages/templates/repo/fragments/diff.html
··· 8 8 #filesToggle:not(:checked) ~ div div#resize-files { display: none; } 9 9 </style> 10 10 11 - <div id="diff-area"> 12 - {{ template "diffTopbar" . }} 13 - {{ block "diffLayout" . }} {{ end }} 14 - {{ template "fragments/resizable" }} 15 - {{ template "activeFileHighlight" }} 16 - {{ template "fragments/line-quote-button" }} 17 - </div> 11 + {{ template "diffTopbar" . }} 12 + {{ block "diffLayout" . }} {{ end }} 13 + {{ template "fragments/resizable" }} 14 + {{ template "activeFileHighlight" }} 15 + {{ template "fragments/line-quote-button" }} 18 16 {{ end }} 19 17 20 18 {{ define "diffTopbar" }} ··· 88 86 {{ $id := index . 0 }} 89 87 {{ $target := index . 1 }} 90 88 {{ $direction := index . 2 }} 91 - <div id="{{ $id }}" 89 + <div id="{{ $id }}" 92 90 data-resizer="vertical" 93 91 data-target="{{ $target }}" 94 92 data-direction="{{ $direction }}" ··· 142 140 {{ $file := index . 1 }} 143 141 {{ $isSplit := index . 2 }} 144 142 {{ $isGenerated := false }} 145 - {{ $isDeleted := false }} 146 143 {{ with $file }} 147 144 {{ $n := .Names }} 148 - {{ $isDeleted = and (eq $n.New "") (ne $n.Old "") }} 149 145 {{ if $n.New }} 150 146 {{ $isGenerated = isGenerated $n.New }} 151 147 {{ else if $n.Old }} 152 148 {{ $isGenerated = isGenerated $n.Old }} 153 149 {{ end }} 154 - <details {{ if and (not $isGenerated) (not $isDeleted) }}open{{ end }} id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 150 + <details {{ if not $isGenerated }}open{{ end }} id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 155 151 <summary class="list-none cursor-pointer sticky top-12 group-open:border-b border-gray-200 dark:border-gray-700"> 156 152 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 157 153 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> ··· 167 163 {{ else }} 168 164 {{ $n.Old }} 169 165 {{ end }} 170 - {{ if $isDeleted }} 171 - <span class="text-gray-400 dark:text-gray-500" title="Deleted files are collapsed by default"> 172 - {{ i "circle-question-mark" "size-4" }} 173 - </span> 174 - {{ else if $isGenerated }} 166 + {{ if $isGenerated }} 175 167 <span class="text-gray-400 dark:text-gray-500" title="Generated files are collapsed by default"> 176 168 {{ i "circle-question-mark" "size-4" }} 177 169 </span> ··· 225 217 </span> 226 218 </label> 227 219 <script> 228 - (() => { 220 + document.addEventListener('DOMContentLoaded', function() { 229 221 const checkbox = document.getElementById('collapseToggle'); 230 - const diffArea = document.getElementById('diff-area'); 222 + const details = document.querySelectorAll('details[id^="file-"]'); 231 223 232 - checkbox.addEventListener('change', () => { 233 - document.querySelectorAll('details[id^="file-"]').forEach(detail => { 224 + checkbox.addEventListener('change', function() { 225 + details.forEach(detail => { 234 226 detail.open = checkbox.checked; 235 227 }); 236 228 }); 237 229 238 - if (window.__collapseToggleHandler) { 239 - diffArea.removeEventListener('toggle', window.__collapseToggleHandler, true); 240 - } 241 - 242 - const handler = (e) => { 243 - if (!e.target.matches('details[id^="file-"]')) return; 244 - const details = document.querySelectorAll('details[id^="file-"]'); 245 - const allOpen = Array.from(details).every(d => d.open); 246 - const allClosed = Array.from(details).every(d => !d.open); 247 - 248 - if (allOpen) checkbox.checked = true; 249 - else if (allClosed) checkbox.checked = false; 250 - }; 230 + details.forEach(detail => { 231 + detail.addEventListener('toggle', function() { 232 + const allOpen = Array.from(details).every(d => d.open); 233 + const allClosed = Array.from(details).every(d => !d.open); 251 234 252 - window.__collapseToggleHandler = handler; 253 - diffArea.addEventListener('toggle', handler, true); 254 - })(); 235 + if (allOpen) { 236 + checkbox.checked = true; 237 + } else if (allClosed) { 238 + checkbox.checked = false; 239 + } 240 + }); 241 + }); 242 + }); 255 243 </script> 256 244 {{ end }} 257 245 258 246 {{ define "activeFileHighlight" }} 259 247 <script> 260 - (() => { 261 - if (window.__activeFileScrollHandler) { 262 - document.removeEventListener('scroll', window.__activeFileScrollHandler); 263 - } 264 - 248 + document.addEventListener('DOMContentLoaded', function() { 249 + const diffFiles = document.querySelectorAll('details[id^="file-"]'); 265 250 const filetreeLinks = document.querySelectorAll('.filetree-link'); 266 - if (filetreeLinks.length === 0) return; 251 + if (diffFiles.length === 0 || filetreeLinks.length === 0) return; 267 252 268 253 const linkMap = new Map(); 269 254 filetreeLinks.forEach(link => { ··· 272 257 }); 273 258 274 259 let currentActive = null; 275 - const setActive = (link) => { 260 + function setActive(link) { 276 261 if (link && link !== currentActive) { 277 262 if (currentActive) currentActive.classList.remove('font-bold'); 278 263 link.classList.add('font-bold'); 279 264 currentActive = link; 280 265 } 281 - }; 266 + } 282 267 283 268 filetreeLinks.forEach(link => { 284 269 link.addEventListener('click', () => setActive(link)); ··· 287 272 const topbar = document.querySelector('.sticky.top-0.z-30'); 288 273 const headerHeight = topbar ? topbar.offsetHeight : 0; 289 274 290 - const updateActiveFile = () => { 291 - const diffFiles = document.querySelectorAll('details[id^="file-"]'); 292 - Array.from(diffFiles).some(file => { 275 + function updateActiveFile() { 276 + for (const file of diffFiles) { 293 277 const rect = file.getBoundingClientRect(); 294 278 if (rect.top <= headerHeight && rect.bottom > headerHeight) { 295 279 setActive(linkMap.get(file.id)); 296 - return true; 280 + return; 297 281 } 298 - return false; 299 - }); 300 - }; 282 + } 283 + } 301 284 302 - window.__activeFileScrollHandler = updateActiveFile; 303 285 document.addEventListener('scroll', updateActiveFile); 304 286 updateActiveFile(); 305 - })(); 287 + }); 306 288 </script> 307 289 {{ end }}
+19 -27
appview/pages/templates/repo/fragments/diffOpts.html
··· 4 4 {{ $active = "split" }} 5 5 {{ end }} 6 6 7 - {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 8 - {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 7 + {{ $unified := 8 + (dict 9 + "Key" "unified" 10 + "Value" "unified" 11 + "Icon" "square-split-vertical" 12 + "Meta" "") }} 13 + {{ $split := 14 + (dict 15 + "Key" "split" 16 + "Value" "split" 17 + "Icon" "square-split-horizontal" 18 + "Meta" "") }} 19 + {{ $values := list $unified $split }} 9 20 10 - <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden" 11 - hx-on::before-request="const t=event.target.closest('button'); if(!t||t.classList.contains('shadow-sm'))return event.preventDefault(); this.querySelectorAll('button').forEach(b => { const active=b===t; b.classList.toggle('bg-white',active); b.classList.toggle('dark:bg-gray-700',active); b.classList.toggle('shadow-sm',active); b.classList.toggle('bg-gray-100',!active); b.classList.toggle('dark:bg-gray-800',!active); b.classList.toggle('shadow-inner',!active); })"> 12 - <button 13 - hx-get="?diff=unified" 14 - hx-target="#diff-files" 15 - hx-select="#diff-files" 16 - hx-swap="outerHTML" 17 - hx-push-url="true" 18 - class="group p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if eq $active "unified" }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 19 - {{ i "square-split-vertical" "size-4 inline group-[.htmx-request]:hidden" }} 20 - {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 21 - unified 22 - </button> 23 - <button 24 - hx-get="?diff=split" 25 - hx-target="#diff-files" 26 - hx-select="#diff-files" 27 - hx-swap="outerHTML" 28 - hx-push-url="true" 29 - class="group p-2 whitespace-nowrap flex justify-center items-center gap-2 text-sm w-full hover:no-underline text-center {{ if eq $active "split" }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 30 - {{ i "square-split-horizontal" "size-4 inline group-[.htmx-request]:hidden" }} 31 - {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }} 32 - split 33 - </button> 34 - </div> 21 + {{ template "fragments/tabSelector" 22 + (dict 23 + "Name" "diff" 24 + "Values" $values 25 + "Active" $active) }} 35 26 {{ end }} 27 +
+3 -12
appview/pages/templates/repo/fragments/fileTree.html
··· 1 1 {{ define "repo/fragments/fileTree" }} 2 - {{/* tailwind safelist: 3 - group/level-1 group/level-2 group/level-3 group/level-4 group/level-5 group/level-6 4 - group/level-7 group/level-8 group/level-9 group/level-10 group/level-11 group/level-12 5 - group-open/level-1:hidden group-open/level-2:hidden group-open/level-3:hidden group-open/level-4:hidden group-open/level-5:hidden group-open/level-6:hidden 6 - group-open/level-7:hidden group-open/level-8:hidden group-open/level-9:hidden group-open/level-10:hidden group-open/level-11:hidden group-open/level-12:hidden 7 - group-open/level-1:block group-open/level-2:block group-open/level-3:block group-open/level-4:block group-open/level-5:block group-open/level-6:block 8 - group-open/level-7:block group-open/level-8:block group-open/level-9:block group-open/level-10:block group-open/level-11:block group-open/level-12:block 9 - */}} 10 2 {{ if and .Name .IsDirectory }} 11 - <details open class="group/level-{{ .Level }}"> 3 + <details open> 12 4 <summary class="cursor-pointer list-none pt-1"> 13 - <span class="tree-directory inline-flex items-center gap-2"> 14 - {{ i "folder" (printf "flex-shrink-0 size-4 group-open/level-%d:hidden" .Level)}} 15 - {{ i "folder-open" (printf "flex-shrink-0 size-4 hidden group-open/level-%d:block" .Level)}} 5 + <span class="tree-directory inline-flex items-center gap-2 "> 6 + {{ i "folder" "flex-shrink-0 size-4 fill-current" }} 16 7 <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 17 8 </span> 18 9 </summary>
+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
+3 -3
appview/pages/templates/repo/tree.html
··· 59 59 {{ range .Files }} 60 60 <div class="grid grid-cols-12 gap-4 items-center py-1"> 61 61 <div class="col-span-8 md:col-span-4"> 62 - {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.Path .Name }} 62 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }} 63 63 {{ $icon := "folder" }} 64 64 {{ $iconStyle := "size-4 fill-current" }} 65 65 66 66 {{ if .IsSubmodule }} 67 - {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.Path .Name }} 67 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 68 68 {{ $icon = "folder-input" }} 69 69 {{ $iconStyle = "size-4" }} 70 70 {{ end }} 71 71 72 72 {{ if .IsFile }} 73 - {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.Path .Name }} 73 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 74 74 {{ $icon = "file" }} 75 75 {{ $iconStyle = "size-4" }} 76 76 {{ end }}
+1 -1
appview/pages/templates/timeline/home.html
··· 48 48 49 49 {{ block "footerLayout" . }} 50 50 <footer class="z-10"> 51 - {{ template "layouts/fragments/footerFull" . }} 51 + {{ template "layouts/fragments/footer" . }} 52 52 </footer> 53 53 {{ end }} 54 54
+82 -16
appview/pulls/pulls.go
··· 411 411 return nil 412 412 } 413 413 414 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 415 - resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String()) 414 + scheme := "http" 415 + if !s.config.Core.Dev { 416 + scheme = "https" 417 + } 418 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 419 + xrpcc := &indigoxrpc.Client{ 420 + Host: host, 421 + } 422 + 423 + resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 416 424 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 417 425 return nil 418 426 } ··· 428 436 return pages.Unknown 429 437 } 430 438 431 - var sourceRepo syntax.ATURI 439 + var knot, ownerDid, repoName string 440 + 432 441 if pull.PullSource.RepoAt != nil { 433 442 // fork-based pulls 434 - sourceRepo = *pull.PullSource.RepoAt 443 + sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 444 + if err != nil { 445 + log.Println("failed to get source repo", err) 446 + return pages.Unknown 447 + } 448 + 449 + knot = sourceRepo.Knot 450 + ownerDid = sourceRepo.Did 451 + repoName = sourceRepo.Name 435 452 } else { 436 453 // pulls within the same repo 437 - sourceRepo = repo.RepoAt() 454 + knot = repo.Knot 455 + ownerDid = repo.Did 456 + repoName = repo.Name 457 + } 458 + 459 + scheme := "http" 460 + if !s.config.Core.Dev { 461 + scheme = "https" 462 + } 463 + host := fmt.Sprintf("%s://%s", scheme, knot) 464 + xrpcc := &indigoxrpc.Client{ 465 + Host: host, 438 466 } 439 467 440 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 441 - branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepo.String()) 468 + didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 469 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 442 470 if err != nil { 443 471 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 444 472 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 876 904 877 905 switch r.Method { 878 906 case http.MethodGet: 879 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 907 + scheme := "http" 908 + if !s.config.Core.Dev { 909 + scheme = "https" 910 + } 911 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 912 + xrpcc := &indigoxrpc.Client{ 913 + Host: host, 914 + } 880 915 881 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 916 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 917 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 882 918 if err != nil { 883 919 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 884 920 log.Println("failed to call XRPC repo.branches", xrpcerr) ··· 1499 1535 return 1500 1536 } 1501 1537 1502 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1538 + scheme := "http" 1539 + if !s.config.Core.Dev { 1540 + scheme = "https" 1541 + } 1542 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1543 + xrpcc := &indigoxrpc.Client{ 1544 + Host: host, 1545 + } 1503 1546 1504 - xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1547 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1548 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1505 1549 if err != nil { 1550 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1551 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1552 + s.pages.Error503(w) 1553 + return 1554 + } 1506 1555 log.Println("failed to fetch branches", err) 1507 - s.pages.Error503(w) 1508 1556 return 1509 1557 } 1510 1558 ··· 1559 1607 return 1560 1608 } 1561 1609 1562 - xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1563 - 1564 1610 forkVal := r.URL.Query().Get("fork") 1565 1611 repoString := strings.SplitN(forkVal, "/", 2) 1566 1612 forkOwnerDid := repoString[0] ··· 1576 1622 return 1577 1623 } 1578 1624 1579 - sourceXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, repo.RepoAt().String()) 1625 + sourceScheme := "http" 1626 + if !s.config.Core.Dev { 1627 + sourceScheme = "https" 1628 + } 1629 + sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1630 + sourceXrpcc := &indigoxrpc.Client{ 1631 + Host: sourceHost, 1632 + } 1633 + 1634 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1635 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1580 1636 if err != nil { 1581 1637 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1582 1638 log.Println("failed to call XRPC repo.branches for source", xrpcerr) ··· 1595 1651 return 1596 1652 } 1597 1653 1598 - targetXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1654 + targetScheme := "http" 1655 + if !s.config.Core.Dev { 1656 + targetScheme = "https" 1657 + } 1658 + targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1659 + targetXrpcc := &indigoxrpc.Client{ 1660 + Host: targetHost, 1661 + } 1662 + 1663 + targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1664 + targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1599 1665 if err != nil { 1600 1666 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1601 1667 log.Println("failed to call XRPC repo.branches for target", xrpcerr)
+27 -20
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) ··· 44 54 } 45 55 defer resp.Body.Close() 46 56 47 - // force application/gzip here 48 - w.Header().Set("Content-Type", "application/gzip") 49 - 50 - filename := "" 51 - if cd := resp.Header.Get("Content-Disposition"); strings.HasPrefix(cd, "attachment;") { 52 - filename = cd // knot has already set the attachment CD 57 + // pass through headers from upstream response 58 + if contentDisposition := resp.Header.Get("Content-Disposition"); contentDisposition != "" { 59 + w.Header().Set("Content-Disposition", contentDisposition) 60 + } 61 + if contentType := resp.Header.Get("Content-Type"); contentType != "" { 62 + w.Header().Set("Content-Type", contentType) 53 63 } 54 - if filename == "" { 55 - filename = fmt.Sprintf("attachment; filename=\"%s-%s.tar.gz\"", f.Name, ref) 64 + if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { 65 + w.Header().Set("Content-Length", contentLength) 56 66 } 57 - w.Header().Set("Content-Disposition", filename) 58 - w.Header().Set("X-Content-Type-Options", "nosniff") 59 - 60 67 if link := resp.Header.Get("Link"); link != "" { 61 68 if resolvedRef, err := extractImmutableLink(link); err == nil { 62 69 newLink := fmt.Sprintf("<%s/%s/archive/%s.tar.gz>; rel=\"immutable\"",
+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 }
+2 -2
appview/repo/repo.go
··· 32 32 "tangled.org/core/xrpc/serviceauth" 33 33 34 34 comatproto "github.com/bluesky-social/indigo/api/atproto" 35 - "github.com/bluesky-social/indigo/atproto/atclient" 35 + atpclient "github.com/bluesky-social/indigo/atproto/client" 36 36 "github.com/bluesky-social/indigo/atproto/syntax" 37 37 lexutil "github.com/bluesky-social/indigo/lex/util" 38 38 securejoin "github.com/cyphar/filepath-securejoin" ··· 1204 1204 // this is used to rollback changes made to the PDS 1205 1205 // 1206 1206 // it is a no-op if the provided ATURI is empty 1207 - func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 1207 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 1208 1208 if aturi == "" { 1209 1209 return nil 1210 1210 }
+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)
+11 -4
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) ··· 121 128 rp.pages.RepoTree(w, pages.RepoTreeParams{ 122 129 LoggedInUser: user, 123 130 BreadCrumbs: breadcrumbs, 124 - Path: treePath, 131 + TreePath: treePath, 125 132 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 126 133 EmailToDid: emailToDidMap, 127 134 LastCommitInfo: lastCommitInfo,
+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 }
+16 -48
appview/state/git_http.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 + "maps" 6 7 "net/http" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/identity" ··· 10 11 "tangled.org/core/appview/models" 11 12 ) 12 13 13 - // allowedResponseHeaders is the set of headers we will forward from the knot 14 - // back to the client. everything else is stripped. 15 - var allowedResponseHeaders = map[string]bool{ 16 - "Content-Encoding": true, 17 - "Transfer-Encoding": true, 18 - "Cache-Control": true, 19 - "Expires": true, 20 - "Pragma": true, 21 - } 22 - 23 - func copyAllowedHeaders(dst, src http.Header) { 24 - for k, vv := range src { 25 - if allowedResponseHeaders[http.CanonicalHeaderKey(k)] { 26 - for _, v := range vv { 27 - dst.Add(k, v) 28 - } 29 - } 30 - } 31 - } 32 - 33 - func setGitHeaders(w http.ResponseWriter, contentType string) { 34 - w.Header().Set("Content-Type", contentType) 35 - w.Header().Set("Content-Disposition", "attachment") 36 - w.Header().Set("X-Content-Type-Options", "nosniff") 37 - } 38 - 39 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 40 15 user := r.Context().Value("resolvedId").(identity.Identity) 41 16 repo := r.Context().Value("repo").(*models.Repo) ··· 45 20 scheme = "http" 46 21 } 47 22 48 - // check for the 'service' url param 49 - service := r.URL.Query().Get("service") 50 - var contentType string 51 - switch service { 52 - case "git-receive-pack": 53 - contentType = "application/x-git-receive-pack-advertisement" 54 - default: 55 - // git-upload-pack is the default service for git-clone / git-fetch. 56 - contentType = "application/x-git-upload-pack-advertisement" 57 - } 58 - 59 23 targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 60 - s.proxyRequest(w, r, targetURL, contentType) 24 + s.proxyRequest(w, r, targetURL) 25 + 61 26 } 62 27 63 28 func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { ··· 74 39 } 75 40 76 41 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 77 - s.proxyRequest(w, r, targetURL, "application/x-git-upload-archive-result") 42 + s.proxyRequest(w, r, targetURL) 78 43 } 79 44 80 45 func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { ··· 91 56 } 92 57 93 58 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 94 - s.proxyRequest(w, r, targetURL, "application/x-git-upload-pack-result") 59 + s.proxyRequest(w, r, targetURL) 95 60 } 96 61 97 62 func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) { ··· 108 73 } 109 74 110 75 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 111 - s.proxyRequest(w, r, targetURL, "application/x-git-receive-pack-result") 76 + s.proxyRequest(w, r, targetURL) 112 77 } 113 78 114 - func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string, contentType string) { 79 + func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 115 80 client := &http.Client{} 116 81 82 + // Create new request 117 83 proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 118 84 if err != nil { 119 85 http.Error(w, err.Error(), http.StatusInternalServerError) 120 86 return 121 87 } 122 88 123 - proxyReq.Header = r.Header.Clone() 89 + // Copy original headers 90 + proxyReq.Header = r.Header 124 91 125 92 repoOwnerHandle := chi.URLParam(r, "user") 126 - proxyReq.Header.Set("x-tangled-repo-owner-handle", repoOwnerHandle) 93 + proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 127 94 95 + // Execute request 128 96 resp, err := client.Do(proxyReq) 129 97 if err != nil { 130 98 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 132 100 } 133 101 defer resp.Body.Close() 134 102 135 - // selectively copy only allowed headers 136 - copyAllowedHeaders(w.Header(), resp.Header) 137 - 138 - setGitHeaders(w, contentType) 103 + // Copy response headers 104 + maps.Copy(w.Header(), resp.Header) 139 105 106 + // Set response status code 140 107 w.WriteHeader(resp.StatusCode) 141 108 109 + // Copy response body 142 110 if _, err := io.Copy(w, resp.Body); err != nil { 143 111 http.Error(w, err.Error(), http.StatusInternalServerError) 144 112 return
-1
appview/state/router.go
··· 34 34 35 35 router.Get("/pwa-manifest.json", s.WebAppManifest) 36 36 router.Get("/robots.txt", s.RobotsTxt) 37 - router.Get("/.well-known/security.txt", s.SecurityTxt) 38 37 39 38 userRouter := s.UserRouter(&middleware) 40 39 standardRouter := s.StandardRouter(&middleware)
+2 -14
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" ··· 224 224 func (s *State) Close() error { 225 225 // other close up logic goes here 226 226 return s.db.Close() 227 - } 228 - 229 - func (s *State) SecurityTxt(w http.ResponseWriter, r *http.Request) { 230 - w.Header().Set("Content-Type", "text/plain") 231 - w.Header().Set("Cache-Control", "public, max-age=86400") // one day 232 - 233 - securityTxt := `Contact: mailto:security@tangled.org 234 - Preferred-Languages: en 235 - Canonical: https://tangled.org/.well-known/security.txt 236 - Expires: 2030-01-01T21:59:00.000Z 237 - ` 238 - w.Write([]byte(securityTxt)) 239 227 } 240 228 241 229 func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { ··· 588 576 // this is used to rollback changes made to the PDS 589 577 // 590 578 // it is a no-op if the provided ATURI is empty 591 - func rollbackRecord(ctx context.Context, aturi string, client *atclient.APIClient) error { 579 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 592 580 if aturi == "" { 593 581 return nil 594 582 }
-172
blog/blog.go
··· 1 - package blog 2 - 3 - import ( 4 - "bytes" 5 - "cmp" 6 - "html/template" 7 - "io" 8 - "io/fs" 9 - "os" 10 - "slices" 11 - "strings" 12 - "time" 13 - 14 - "github.com/adrg/frontmatter" 15 - "github.com/gorilla/feeds" 16 - 17 - "tangled.org/core/appview/pages" 18 - "tangled.org/core/appview/pages/markup" 19 - textension "tangled.org/core/appview/pages/markup/extension" 20 - ) 21 - 22 - type Author struct { 23 - Name string `yaml:"name"` 24 - Email string `yaml:"email"` 25 - Handle string `yaml:"handle"` 26 - } 27 - 28 - type PostMeta struct { 29 - Slug string `yaml:"slug"` 30 - Title string `yaml:"title"` 31 - Subtitle string `yaml:"subtitle"` 32 - Date string `yaml:"date"` 33 - Authors []Author `yaml:"authors"` 34 - Image string `yaml:"image"` 35 - Draft bool `yaml:"draft"` 36 - } 37 - 38 - type Post struct { 39 - Meta PostMeta 40 - Body template.HTML 41 - } 42 - 43 - func (p Post) ParsedDate() time.Time { 44 - t, _ := time.Parse("2006-01-02", p.Meta.Date) 45 - return t 46 - } 47 - 48 - type indexParams struct { 49 - LoggedInUser any 50 - Posts []Post 51 - Featured []Post 52 - } 53 - 54 - type postParams struct { 55 - LoggedInUser any 56 - Post Post 57 - } 58 - 59 - // Posts parses and returns all non-draft posts sorted newest-first. 60 - func Posts(postsDir string) ([]Post, error) { 61 - return parsePosts(postsDir, false) 62 - } 63 - 64 - // AllPosts parses and returns all posts including drafts, sorted newest-first. 65 - func AllPosts(postsDir string) ([]Post, error) { 66 - return parsePosts(postsDir, true) 67 - } 68 - 69 - func parsePosts(postsDir string, includeDrafts bool) ([]Post, error) { 70 - fsys := os.DirFS(postsDir) 71 - 72 - entries, err := fs.ReadDir(fsys, ".") 73 - if err != nil { 74 - return nil, err 75 - } 76 - 77 - rctx := &markup.RenderContext{ 78 - RendererType: markup.RendererTypeDefault, 79 - } 80 - var posts []Post 81 - for _, entry := range entries { 82 - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { 83 - continue 84 - } 85 - 86 - data, err := fs.ReadFile(fsys, entry.Name()) 87 - if err != nil { 88 - return nil, err 89 - } 90 - 91 - var meta PostMeta 92 - rest, err := frontmatter.Parse(bytes.NewReader(data), &meta) 93 - if err != nil { 94 - return nil, err 95 - } 96 - 97 - if meta.Draft && !includeDrafts { 98 - continue 99 - } 100 - 101 - htmlStr := rctx.RenderMarkdownWith(string(rest), markup.NewMarkdownWith("", textension.Dashes)) 102 - 103 - posts = append(posts, Post{ 104 - Meta: meta, 105 - Body: template.HTML(htmlStr), 106 - }) 107 - } 108 - 109 - slices.SortFunc(posts, func(a, b Post) int { 110 - return cmp.Compare(b.Meta.Date, a.Meta.Date) 111 - }) 112 - 113 - return posts, nil 114 - } 115 - 116 - func AtomFeed(posts []Post, baseURL string) (string, error) { 117 - feed := &feeds.Feed{ 118 - Title: "the tangled blog", 119 - Link: &feeds.Link{Href: baseURL}, 120 - Author: &feeds.Author{Name: "Tangled"}, 121 - Created: time.Now(), 122 - } 123 - 124 - for _, p := range posts { 125 - postURL := strings.TrimRight(baseURL, "/") + "/" + p.Meta.Slug 126 - 127 - var authorName strings.Builder 128 - for i, a := range p.Meta.Authors { 129 - if i > 0 { 130 - authorName.WriteString(" & ") 131 - } 132 - authorName.WriteString(a.Name) 133 - } 134 - 135 - feed.Items = append(feed.Items, &feeds.Item{ 136 - Title: p.Meta.Title, 137 - Link: &feeds.Link{Href: postURL}, 138 - Description: p.Meta.Subtitle, 139 - Author: &feeds.Author{Name: authorName.String()}, 140 - Created: p.ParsedDate(), 141 - }) 142 - } 143 - 144 - return feed.ToAtom() 145 - } 146 - 147 - // RenderIndex renders the blog index page to w. 148 - func RenderIndex(p *pages.Pages, templatesDir string, posts []Post, w io.Writer) error { 149 - tpl, err := p.ParseWith(os.DirFS(templatesDir), "index.html") 150 - if err != nil { 151 - return err 152 - } 153 - var featured []Post 154 - for _, post := range posts { 155 - if post.Meta.Image != "" { 156 - featured = append(featured, post) 157 - if len(featured) == 3 { 158 - break 159 - } 160 - } 161 - } 162 - return tpl.ExecuteTemplate(w, "layouts/base", indexParams{Posts: posts, Featured: featured}) 163 - } 164 - 165 - // RenderPost renders a single blog post page to w. 166 - func RenderPost(p *pages.Pages, templatesDir string, post Post, w io.Writer) error { 167 - tpl, err := p.ParseWith(os.DirFS(templatesDir), "post.html") 168 - if err != nil { 169 - return err 170 - } 171 - return tpl.ExecuteTemplate(w, "layouts/base", postParams{Post: post}) 172 - }
-12
blog/config.yaml
··· 1 - preBuild: 2 - - tailwindcss -i ../input.css -o static/tw.css 3 - title: the tangled blog 4 - # note the trailing slash! 5 - url: "https://blog.tangled.org/" 6 - description: "" 7 - author: 8 - name: "Anirudh Oppiliappan" 9 - email: "anirudh@tangled.sh" 10 - defaultTemplate: text.html 11 - extraTemplateDirs: 12 - - ../appview/pages/templates
-160
blog/posts/6-months.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: 6-months 5 - title: 6 months of Tangled 6 - subtitle: a quick recap, and notes on the future 7 - date: 2025-10-21 8 - image: https://assets.tangled.network/blog/6-months.png 9 - authors: 10 - - name: Anirudh 11 - email: anirudh@tangled.org 12 - handle: anirudh.fi 13 - - name: Akshay 14 - email: akshay@tangled.org 15 - handle: oppi.li 16 - draft: false 17 - --- 18 - 19 - Hello Tanglers! It's been over 6 months since we first announced 20 - Tangled, so we figured we'd do a quick retrospective of what we built so 21 - far and what's next. 22 - 23 - If you're new here, here's a quick overview: Tangled is a git hosting 24 - and collaboration platform built on top of the [AT 25 - Protocol](https://atproto.com). You can read a bit more about our 26 - architecture [here](/intro). 27 - 28 - ## new logo and mascot: dolly! 29 - 30 - Tangled finally has a logo! Designed by Akshay himself, Dolly is in 31 - reference to the first ever *cloned* mammal. For a full set of brand assets and guidelines, see our new [branding page](https://tangled.org/brand). 32 - 33 - ![logo with text](https://assets.tangled.network/blog/logo_with_text.jpeg) 34 - 35 - With that, let's recap the major platform improvements so far! 36 - 37 - ## pull requests: doubling down on jujutsu 38 - 39 - One of the first major features we built was our [pull requests 40 - system](/pulls), which follows a unique round-based submission & review 41 - approach. This was really fun to innovate on -- it remains one of 42 - Tangled's core differentiators, and one we plan to keep improving. 43 - 44 - In the same vein, we're the first ever code forge to support [stacking 45 - pull requests](/stacking) using Jujutsu! We're big fans of the tool and 46 - we use it everyday as we hack on 47 - [tangled.org/core](https://tangled.org/@tangled.org/core). 48 - 49 - Ultimately, we think PR-based collaboration should evolve beyond the 50 - traditional model, and we're excited to keep experimenting with new 51 - ideas that make code review and contribution easier! 52 - 53 - ## spindle 54 - 55 - CI was our most requested feature, and we spent a *lot* of time debating 56 - how to approach it. We considered integrating with existing platforms, 57 - but none were good fits. So we gave in to NIH and [built spindle 58 - ourselves](/ci)! This allowed us to go in on Nix using Nixery to build 59 - CI images on the fly and cache them. 60 - 61 - Spindle is still early but designed to be extensible and is AT-native. 62 - The current Docker/Nixery-based engine is limiting -- we plan to switch 63 - to micro VMs down the line to run full-fledged NixOS (and other base 64 - images). Meanwhile, if you've got ideas for other spindle backends 65 - (Kubernetes?!), we'd love to [hear from you](https://chat.tangled.org). 66 - 67 - ## XRPC APIs 68 - 69 - We introduced a complete migration of the knotserver to an 70 - [XRPC](https://atproto.com/specs/xrpc) API. Alongside this, we also 71 - decoupled the knot from the appview by getting rid of the registration 72 - secret, which was centralizing. Knots (and spindles) simply declare 73 - their owner, and any appview can verify ownership. Once we stabilize the 74 - [lexicon definitions](lexicons) for these XRPC calls, building clients 75 - for knots, or alternate implementations should become much simpler. 76 - 77 - [lexicons]: https://tangled.sh/@tangled.sh/core/tree/master/lexicons 78 - 79 - ## issues rework 80 - 81 - Issues got a major rework (and facelift) too! They are now threaded: 82 - top-level comments with replies. This makes Q/A style discussions much 83 - easier to follow! 84 - 85 - ![issue thread](https://assets.tangled.network/blog/issue-threading.webp) 86 - 87 - ## hosted PDS 88 - 89 - A complaint we often recieved was the need for a Bluesky account to use 90 - Tangled; and besides, we realised that the overlap between Bluesky users 91 - and possible Tangled users only goes so far -- we aim to be a generic 92 - code forge after all, AT just happens to be an implementation 93 - detail. 94 - 95 - To address this, we spun up the tngl.sh PDS hosted right here in 96 - Finland. The only way to get an account on this PDS is by [signing 97 - up](https://tangled.sh/signup). There's a lot we can do to improve this 98 - experience as a generic PDS host, but we're still working out details 99 - around that. 100 - 101 - ## labels 102 - 103 - You can easily categorize issues and pulls via labels! There is plenty 104 - of customization available: 105 - 106 - - labels can be basic, or they can have a key and value set, for example: 107 - `wontfix` or `priority/high` 108 - - labels can be constrained to a set of values: `priority: [high medium low]` 109 - - there can be multiple labels of a given type: `reviewed-by: @oppi.li`, 110 - `reviewed-by: @anirudh.fi` 111 - 112 - The options are endless! You can access them via your repo's settings page. 113 - 114 - <div class="flex justify-center items-center gap-2"> 115 - <figure class="w-full m-0 flex flex-col items-center"> 116 - <a href="https://assets.tangled.network/blog/labels_vignette.webp"> 117 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/labels_vignette.webp" alt="A set of labels applied to an issue."> 118 - </a> 119 - <figcaption class="text-center">A set of labels applied to an issue.</figcaption> 120 - </figure> 121 - 122 - <figure class="w-1/3 m-0 flex flex-col items-center"> 123 - <a href="https://assets.tangled.network/blog/new_label_modal.png"> 124 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/new_label_modal.png" alt="Create custom key-value type labels."> 125 - </a> 126 - <figcaption class="text-center">Create custom key-value type labels.</figcaption> 127 - </figure> 128 - </div> 129 - 130 - 131 - ## notifications 132 - 133 - In-app notifications now exist! You get notifications for a variety of events now: 134 - 135 - * new issues/pulls on your repos (also for collaborators) 136 - * comments on your issues/pulls (also for collaborators) 137 - * close/reopen (or merge) of issues/pulls 138 - * new stars 139 - * new follows 140 - 141 - All of this can be fine-tuned in [/settings/notifications](https://tangled.org/settings/notifications). 142 - 143 - ![notifications](https://assets.tangled.network/blog/notifications.png) 144 - 145 - 146 - ## the future 147 - 148 - We're working on a *lot* of exciting new things and possibly some big 149 - announcements to come. Be on the lookout for: 150 - 151 - * email notifications 152 - * preliminary support for issue and PR search 153 - * total "atprotation" [^1] -- the last two holdouts here are repo and pull records 154 - * total federation -- i.e. supporting third-party appviews by making it 155 - reproducible 156 - * achieve complete independence from Bluesky PBC by hosting our own relay 157 - 158 - That's all for now; we'll see you in the atmosphere! Meanwhile, if you'd like to contribute to projects on Tangled, make sure to check out the [good first issues page](https://tangled.org/goodfirstissues) to get started! 159 - 160 - [^1]: atprotation implies a two-way sync between the PDS and appview. Currently, pull requests and repositories are not ingested -- so writing/updating either records on your PDS will not show up on the appview.
-214
blog/posts/ci.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: ci 5 - title: introducing spindle 6 - subtitle: tangled's new CI runner is now generally available 7 - date: 2025-08-06 8 - authors: 9 - - name: Anirudh 10 - email: anirudh@tangled.sh 11 - handle: anirudh.fi 12 - - name: Akshay 13 - email: akshay@tangled.sh 14 - handle: oppi.li 15 - --- 16 - 17 - Since launching Tangled, continuous integration has 18 - consistently topped our feature request list. Today, CI is 19 - no longer a wishlist item, but a fully-featured reality. 20 - 21 - Meet **spindle**: Tangled's new CI runner built atop Nix and 22 - AT Protocol. In typical Tangled fashion we've been 23 - dogfooding spindle for a while now; this very blog post 24 - you're reading was [built and published using 25 - spindle](https://tangled.sh/@tangled.sh/site/pipelines/452/workflow/deploy.yaml). 26 - 27 - Tangled is a new social-enabled Git collaboration platform, 28 - [read our intro](/intro) for more about the project. 29 - 30 - ![spindle architecture](https://assets.tangled.network/blog/spindle-arch.png) 31 - 32 - ## how spindle works 33 - 34 - Spindle is designed around simplicity and the decentralized 35 - nature of the AT Protocol. In ingests "pipeline" records and 36 - emits job status updates. 37 - 38 - When you push code or open a pull request, the knot hosting 39 - your repository emits a pipeline event 40 - (`sh.tangled.pipeline`). Running as a dedicated service, 41 - spindle subscribes to these events via websocket connections 42 - to your knot. 43 - 44 - Once triggered, spindle reads your pipeline manifest, spins 45 - up the necessary execution environment (covered below), and 46 - runs your defined workflow steps. Throughout execution, it 47 - streams real-time logs and status updates 48 - (`sh.tangled.pipeline.status`) back through websockets, 49 - which the Tangled appview subscribes to for live updates. 50 - 51 - Over at the appview, these updates are ingested and stored, 52 - and logs are streamed live. 53 - 54 - ## spindle pipelines 55 - 56 - The pipeline manifest is defined in YAML, and should be 57 - relatively familiar to those that have used other CI 58 - solutions. Here's a minimal example: 59 - 60 - ```yaml 61 - # test.yaml 62 - 63 - when: 64 - - event: ["push", "pull_request"] 65 - branch: ["master"] 66 - 67 - dependencies: 68 - nixpkgs: 69 - - go 70 - 71 - steps: 72 - - name: run all tests 73 - environment: 74 - CGO_ENABLED: 1 75 - command: | 76 - go test -v ./... 77 - ``` 78 - 79 - You can read the [full manifest spec 80 - here](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/pipeline.md), 81 - but the `dependencies` block is the real interesting bit. 82 - Dependencies for your workflow, like Go, Node.js, Python 83 - etc. can be pulled in from nixpkgs. 84 - [Nixpkgs](https://github.com/nixos/nixpkgs/) -- for the 85 - uninitiated -- is a vast collection of packages for the Nix 86 - package manager. Fortunately, you needn't know nor care 87 - about Nix to use it! Just head to https://search.nixos.org 88 - to find your package of choice (I'll bet 1โ‚ฌ that it's 89 - there[^1]), toss it in the list and run your build. The 90 - Nix-savvy of you lot will be happy to know that you can use 91 - custom registries too. 92 - 93 - [^1]: I mean, if it isn't there, it's nowhere. 94 - 95 - Workflow manifests are intentionally simple. We do not want 96 - to include a "marketplace" of workflows or complex job 97 - orchestration. The bulk of the work should be offloaded to a 98 - build system, and CI should be used simply for finishing 99 - touches. That being said, this is still the first revision 100 - for CI, there is a lot more on the roadmap! 101 - 102 - Let's take a look at how spindle executes workflow steps. 103 - 104 - ## workflow execution 105 - 106 - At present, the spindle "engine" supports just the Docker 107 - backend[^2]. Podman is known to work with the Docker socket 108 - feature enabled. Each step is run in a separate container, 109 - with the `/tangled/workspace` and `/nix` volumes persisted 110 - across steps. 111 - 112 - [^2]: Support for additional backends like Firecracker are 113 - planned. Contributions welcome! 114 - 115 - The container image is built using 116 - [Nixery](https://nixery.dev). Nixery is a nifty little tool 117 - that takes a path-separated set of Nix packages and returns 118 - an OCI image with each package in a separate layer. Try this 119 - in your terminal if you've got Docker installed: 120 - 121 - ``` 122 - docker run nixery.dev/bash/hello-go hello-go 123 - ``` 124 - 125 - This should output `Hello, world!`. This is running the 126 - [hello-go](https://search.nixos.org/packages?channel=25.05&show=hello-go) 127 - package from nixpkgs. 128 - 129 - Nixery is super handy since we can construct these images 130 - for CI environments on the fly, with all dependencies baked 131 - in, and the best part: caching for commonly used packages is 132 - free thanks to Docker (pre-existing layers get reused). We 133 - run a Nixery instance of our own at 134 - https://nixery.tangled.sh but you may override that if you 135 - choose to. 136 - 137 - ## debugging CI 138 - 139 - We understand that debugging CI can be the worst. There are 140 - two parts to this problem: 141 - 142 - - CI services often bring their own workflow definition 143 - formats and it can sometimes be difficult to know why the 144 - workflow won't run or why the workflow definition is 145 - incorrect 146 - - The CI job itself fails, but this has more to do with the 147 - build system of choice 148 - 149 - To mend the first problem: we are making use of git 150 - [push-options](https://git-scm.com/docs/git-push#Documentation/git-push.txt--ooption). 151 - When you push to a repository with an option like so: 152 - 153 - ``` 154 - git push origin master -o verbose-ci 155 - ``` 156 - 157 - The server runs a basic set of analysis rules on your 158 - workflow file, and reports any errors: 159 - 160 - ``` 161 - ฮป git push origin main -o verbose-ci 162 - . 163 - . 164 - . 165 - . 166 - remote: error: failed to parse workflow(s): 167 - remote: - at .tangled/workflows/fmt.yml: yaml: line 14: did not find expected key 168 - remote: 169 - remote: warning(s) on pipeline: 170 - remote: - at build.yml: workflow skipped: did not match trigger push 171 - ``` 172 - 173 - The analysis performed at the moment is quite basic (expect 174 - it to get better over time), but it is already quite useful 175 - to help debug workflows that don't trigger! 176 - 177 - ## pipeline secrets 178 - 179 - Secrets are a bit tricky since atproto has no notion of 180 - private data. Secrets are instead written directly from the 181 - appview to the spindle instance using [service 182 - auth](https://docs.bsky.app/docs/api/com-atproto-server-get-service-auth). 183 - In essence, the appview makes a signed request using the 184 - logged-in user's DID key; spindle verifies this signature by 185 - fetching the public key from the DID document. 186 - 187 - ![pipeline secrets](https://assets.tangled.network/blog/pipeline-secrets.png) 188 - 189 - The secrets themselves are stored in a secret manager. By 190 - default, this is the same sqlite database that spindle uses. 191 - This is *fine* for self-hosters. The hosted, flagship 192 - instance at https://spindle.tangled.sh however uses 193 - [OpenBao](https://openbao.org), an OSS fork of HashiCorp 194 - Vault. 195 - 196 - ## get started now 197 - 198 - You can run your own spindle instance pretty easily: the 199 - [spindle self-hosting 200 - guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md) 201 - should have you covered. Once done, head to your 202 - repository's settings tab and set it up! Doesn't work? Feel 203 - free to pop into [Discord](https://chat.tangled.sh) to get 204 - help -- we have a nice little crew that's always around to 205 - help. 206 - 207 - All Tangled users have access to our hosted spindle 208 - instance, free of charge[^3]. You don't have any more 209 - excuses to not migrate to Tangled now -- [get 210 - started](https://tangled.sh/login) with your AT Protocol 211 - account today. 212 - 213 - [^3]: We can't promise we won't charge for it at some point 214 - but there will always be a free tier.
-241
blog/posts/docs.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: docs 5 - title: we rolled our own documentation site 6 - subtitle: you don't need mintlify 7 - date: 2026-01-12 8 - authors: 9 - - name: Akshay 10 - email: akshay@tangled.org 11 - handle: oppi.li 12 - draft: false 13 - --- 14 - 15 - We recently organized our documentation and put it up on 16 - https://docs.tangled.org, using just pandoc. For several 17 - reasons, using pandoc to roll your own static sites is more 18 - than sufficient for small projects. 19 - 20 - ![docs.tangled.org](https://assets.tangled.network/blog/docs_homepage.png) 21 - 22 - ## requirements 23 - 24 - - Lives in [our 25 - monorepo](https://tangled.org/tangled.org/core). 26 - - No JS: a collection of pages containing just text 27 - should not require JS to view! 28 - - Searchability: in practice, documentation engines that 29 - come bundled with a search-engine have always been lack 30 - lustre. I tend to Ctrl+F or use an actual search engine in 31 - most scenarios. 32 - - Low complexity: building, testing, deploying should be 33 - easy. 34 - - Easy to style 35 - 36 - ## evaluating the ecosystem 37 - 38 - I took the time to evaluate several documentation engine 39 - solutions: 40 - 41 - - [Mintlify](https://www.mintlify.com/): It is quite obvious 42 - from their homepage that mintlify is performing an AI 43 - pivot for the sake of doing so. 44 - - [Docusaurus](https://docusaurus.io/): The generated 45 - documentation site is quite nice, but the value of pages 46 - being served as a full-blown React SPA is questionable. 47 - - [MkDocs](https://www.mkdocs.org/): Works great with JS 48 - disabled, however the table of contents needs to be 49 - maintained via `mkdocs.yml`, which can be quite tedious. 50 - - [MdBook](https://rust-lang.github.io/mdBook/index.html): 51 - As above, you need a `SUMMARY.md` file to control the 52 - table-of-contents. 53 - 54 - MkDocs and MdBook are still on my radar however, in case we 55 - need a bigger feature set. 56 - 57 - ## using pandoc 58 - 59 - [pandoc](https://pandoc.org/) is a wonderfully customizable 60 - markup converter. It provides a "chunkedhtml" output format, 61 - which is perfect for generating documentation sites. Without 62 - any customization, 63 - [this](https://pandoc.org/demo/example33/) is the generated 64 - output, for this [markdown file 65 - input](https://pandoc.org/demo/MANUAL.txt). 66 - 67 - - You get an autogenerated TOC based on the document layout 68 - - Each section is turned into a page of its own 69 - 70 - Massaging pandoc to work for us was quite straightforward: 71 - 72 - - I first combined all our individual markdown files into 73 - [one big 74 - `DOCS.md`](https://tangled.org/tangled.org/core/blob/master/docs/DOCS.md) 75 - file. 76 - - Modified the [default 77 - template](https://github.com/jgm/pandoc-templates/blob/master/default.chunkedhtml) 78 - to put the TOC on every page, to form a "sidebar", see 79 - [`docs/template.html`](https://tangled.org/tangled.org/core/blob/master/docs/template.html) 80 - - Inserted tailwind `prose` classes where necessary, such 81 - that markdown content is rendered the same way between 82 - `tangled.org` and `docs.tangled.org` 83 - 84 - Generating the docs is done with one pandoc command: 85 - 86 - ```bash 87 - pandoc docs/DOCS.md \ 88 - -o out/ \ 89 - -t chunkedhtml \ 90 - --variable toc \ 91 - --toc-depth=2 \ 92 - --css=docs/stylesheet.css \ 93 - --chunk-template="%i.html" \ 94 - --highlight-style=docs/highlight.theme \ 95 - --template=docs/template.html 96 - ``` 97 - 98 - ## avoiding javascript 99 - 100 - The "sidebar" style table-of-contents needs to be collapsed 101 - on mobile displays. Most of the engines I evaluated seem to 102 - require JS to collapse and expand the sidebar, with MkDocs 103 - being the outlier, it uses a checkbox with the 104 - [`:checked`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:checked) 105 - pseudo-class trick to avoid JS. 106 - 107 - The other ways to do this are: 108 - 109 - - Use `<details` and `<summary>`: this is definitely a 110 - "hack", clicking outside the sidebar does not collapse it. 111 - Using Ctrl+F or "Find in page" still works through the 112 - details tag though. 113 - - Use the new `popover` API: this seems like the perfect fit 114 - for a "sidebar" component. 115 - 116 - The bar at the top includes a button to trigger the popover: 117 - 118 - ```html 119 - <button popovertarget="toc-popover">Table of Contents</button> 120 - ``` 121 - 122 - And a `fixed` position div includes the TOC itself: 123 - 124 - ```html 125 - <div id="toc-popover" popover class="fixed top-0"> 126 - <ul> 127 - Quick Start 128 - <li>...</li> 129 - <li>...</li> 130 - <li>...</li> 131 - </ul> 132 - </div> 133 - ``` 134 - 135 - The TOC is scrollable independently and can be collapsed by 136 - clicking anywhere on the screen outside the sidebar. 137 - Searching for content in the page via "Find in page" does 138 - not show any results that are present in the popover 139 - however. The collapsible TOC is only available on smaller 140 - viewports, the TOC is not hidden on larger viewports. 141 - 142 - ## search 143 - 144 - There is no native search on the site for now. Taking 145 - inspiration from [https://htmx.org](https://htmx.org)'s search bar, our search 146 - bar also simply redirects to Google: 147 - 148 - ```html 149 - <form action="https://google.com/search"> 150 - <input type="hidden" name="q" value="+[inurl:https://docs.tangled.org]"> 151 - ... 152 - </form> 153 - ``` 154 - 155 - I mentioned earlier that Ctrl+F has typically worked better 156 - for me than, say, the search engine provided by Docusaurus. 157 - To that end, the same docs have been exported to a ["single 158 - page" format](https://docs.tangled.org/single-page.html), by 159 - just removing the `chunkedhtml` related options: 160 - 161 - ```diff 162 - pandoc docs/DOCS.md \ 163 - -o out/ \ 164 - - -t chunkedhtml \ 165 - --variable toc \ 166 - --toc-depth=2 \ 167 - --css=docs/stylesheet.css \ 168 - - --chunk-template="%i.html" \ 169 - --highlight-style=docs/highlight.theme \ 170 - --template=docs/template.html 171 - ``` 172 - 173 - With all the content on a single page, it is trivial to 174 - search through the entire site with the browser. If the docs 175 - do outgrow this, I will consider other options! 176 - 177 - ## building and deploying 178 - 179 - We use [nix](https://nixos.org) and 180 - [colmena](https://colmena.cli.rs/) to build and deploy all 181 - Tangled services. A nix derivation to [build the 182 - documentation](https://tangled.org/tangled.org/core/blob/master/nix/pkgs/docs.nix) 183 - site is written very easily with the `runCommandLocal` 184 - helper: 185 - 186 - ```nix 187 - runCommandLocal "docs" {} '' 188 - . 189 - . 190 - . 191 - ${pandoc}/bin/pandoc ${src}/docs/DOCS.md ... 192 - . 193 - . 194 - . 195 - '' 196 - ``` 197 - 198 - The NixOS machine is configured to serve the site [via 199 - nginx](https://tangled.org/tangled.org/infra/blob/master/hosts/nixery/services/nginx.nix#L7): 200 - 201 - ```nix 202 - services.nginx = { 203 - enable = true; 204 - virtualHosts = { 205 - "docs.tangled.org" = { 206 - root = "${tangled-pkgs.docs}"; 207 - locations."/" = { 208 - tryFiles = "$uri $uri/ =404"; 209 - index = "index.html"; 210 - }; 211 - }; 212 - }; 213 - }; 214 - ``` 215 - 216 - And deployed using `colmena`: 217 - 218 - ```bash 219 - nix run nixpkgs#colmena -- apply 220 - ``` 221 - 222 - To update the site, I first run: 223 - 224 - ```bash 225 - nix flake update tangled 226 - ``` 227 - 228 - Which bumps the `tangled` flake input, and thus 229 - `tangled-pkgs.docs`. The above `colmena` invocation applies 230 - the changes to the machine serving the site. 231 - 232 - ## notes 233 - 234 - Going homegrown has made it a lot easier to style the 235 - documentation site to match the main site. Unfortunately 236 - there are still a few discrepancies between pandoc's 237 - markdown rendering and 238 - [goldmark's](https://pkg.go.dev/github.com/yuin/goldmark/) 239 - markdown rendering (which is what we use in Tangled). We may 240 - yet roll our own SSG, 241 - [TigerStyle](https://tigerbeetle.com/blog/2025-02-27-why-we-designed-tigerbeetles-docs-from-scratch/)!
-64
blog/posts/intro.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: intro 5 - title: introducing tangled 6 - subtitle: a git collaboration platform, built on atproto 7 - date: 2025-03-02 8 - authors: 9 - - name: Anirudh 10 - email: anirudh@tangled.sh 11 - handle: anirudh.fi 12 - --- 13 - 14 - 15 - [Tangled](https://tangled.sh) is a new social-enabled Git collaboration 16 - platform, built on top of the [AT Protocol](https://atproto.com). We 17 - envision a place where developers have complete ownership of their code, 18 - open source communities can freely self-govern and most importantly, 19 - coding can be social and fun again. 20 - 21 - There are several models for decentralized code collaboration platforms, 22 - ranging from ActivityPub's (Forgejo) federated model, to Radicle's 23 - entirely P2P model. Our approach attempts to be the best of both worlds 24 - by adopting atproto -- a protocol for building decentralized social 25 - applications with a central identity. 26 - 27 - ![tangled architecture](https://assets.tangled.network/blog/arch.svg) 28 - 29 - Our approach to this is the idea of "knots". Knots are lightweight, 30 - headless servers that enable users to host Git repositories with ease. 31 - Knots are designed for either single or multi-tenant use which is 32 - perfect for self-hosting on a Raspberry Pi at home, or larger 33 - "community" servers. By default, Tangled provides managed knots where 34 - you can host your repositories for free. 35 - 36 - The [App View][appview] at [tangled.sh](https://tangled.sh) acts as a 37 - consolidated "view" into the whole network, allowing users to access, 38 - clone and contribute to repositories hosted across different knots -- 39 - completely seamlessly. 40 - 41 - Tangled is still in its infancy, and we're building out several of its 42 - core features as we [dogfood it ourselves][dogfood]. We developed these 43 - three tenets to guide our decisions: 44 - 45 - 1. Ownership of data 46 - 2. Low barrier to entry 47 - 3. No compromise on user-experience 48 - 49 - Collaborating on code isn't easy, and the tools and workflows we use 50 - should feel natural and stay out of the way. Tangled's architecture 51 - enables common workflows to work as you'd expect, all while remaining 52 - decentralized. 53 - 54 - We believe that atproto has greatly simplfied one of the hardest parts 55 - of social media: having your friends on it. Today, we're rolling out 56 - invite-only access to Tangled -- join us on IRC at `#tangled` on 57 - [libera.chat](https://libera.chat) and we'll get you set up. 58 - 59 - **Update**: Tangled is open to public, simply login at 60 - [tangled.sh/login](https://tangled.sh/login)! Have fun! 61 - 62 - [pds]: https://atproto.com/guides/glossary#pds-personal-data-server 63 - [appview]: https://docs.bsky.app/docs/advanced-guides/federation-architecture#app-views 64 - [dogfood]: https://tangled.sh/@tangled.sh/core
-195
blog/posts/pulls.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: pulls 5 - title: the lifecycle of a pull request 6 - subtitle: we shipped a bunch of PR features recently; here's how we built it 7 - date: 2025-04-16 8 - image: https://assets.tangled.network/blog/hidden-ref.png 9 - authors: 10 - - name: Anirudh 11 - email: anirudh@tangled.sh 12 - handle: anirudh.fi 13 - - name: Akshay 14 - email: akshay@tangled.sh 15 - handle: oppi.li 16 - draft: false 17 - --- 18 - 19 - We've spent the last couple of weeks building out a pull 20 - request system for Tangled, and today we want to lift the 21 - hood and show you how it works. 22 - 23 - If you're new to Tangled, [read our intro](/intro) for the 24 - full story! 25 - 26 - You have three options to contribute to a repository: 27 - 28 - - Paste a patch on the web UI 29 - - Compare two local branches (you'll see this only if you're a 30 - collaborator on the repo) 31 - - Compare across forks 32 - 33 - Whatever you choose, at the core of every PR is the patch. 34 - First, you write some code. Then, you run `git diff` to 35 - produce a patch and make everyone's lives easier, or push to 36 - a branch, and we generate it ourselves by comparing against 37 - the target. 38 - 39 - ## patch generation 40 - 41 - When you create a PR from a branch, we create a "patch" by 42 - calculating the difference between your branch and the 43 - target branch. Consider this scenario: 44 - 45 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 46 - <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/merge-base.png"> 47 - <figcaption class="text-center"><code>A</code> is the merge-base for 48 - <code>feature</code> and <code>main</code>.</figcaption> 49 - </figure> 50 - 51 - Your `feature` branch has advanced 2 commits since you first 52 - branched out, but in the meanwhile, `main` has also advanced 53 - 2 commits. Doing a trivial `git diff feature main` will 54 - produce a confusing patch: 55 - 56 - - the patch will apply the changes from `X` and `Y` 57 - - the patch will **revert** the changes from `B` and `C` 58 - 59 - We obviously do not want the second part! To only show the 60 - changes added by `feature`, we have to identify the 61 - "merge-base": the nearest common ancestor of `feature` and 62 - `main`. 63 - 64 - 65 - In this case, `A` is the nearest common ancestor, and 66 - subsequently, the patch calculated will contain just `X` and 67 - `Y`. 68 - 69 - ### ref comparisons across forks 70 - 71 - The plumbing described above is easy to do across two 72 - branches, but what about forks? And what if they live on 73 - different servers altogether (as they can in Tangled!)? 74 - 75 - Here's the concept: since we already have all the necessary 76 - components to compare two local refs, why not simply 77 - "localize" the remote ref? 78 - 79 - In simpler terms, we instruct Git to fetch the target branch 80 - from the original repository and store it in your fork under 81 - a special name. This approach allows us to compare your 82 - changes against the most current version of the branch 83 - you're trying to contribute to, all while remaining within 84 - your fork. 85 - 86 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 87 - <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/hidden-ref.png"> 88 - <figcaption class="text-center">Hidden tracking ref.</figcaption> 89 - </figure> 90 - 91 - We call this a "hidden tracking ref." When you create a pull 92 - request from a fork, we establish a refspec that tracks the 93 - remote branch, which we then use to generate a diff. A 94 - refspec is essentially a rule that tells Git how to map 95 - references between a remote and your local repository during 96 - fetch or push operations. 97 - 98 - For example, if your fork has a feature branch called 99 - `feature-1`, and you want to make a pull request to the 100 - `main` branch of the original repository, we fetch the 101 - remote `main` into a local hidden ref using a refspec like 102 - this: 103 - 104 - ``` 105 - +refs/heads/main:refs/hidden/feature-1/main 106 - ``` 107 - 108 - Since we already have a remote (`origin`, by default) to the 109 - original repository (remember, we cloned it earlier), we can 110 - use `fetch` with this refspec to bring the remote `main` 111 - branch into our local hidden ref. Each pull request gets its 112 - own hidden ref, hence the `refs/hidden/:localRef/:remoteRef` 113 - format. We keep this ref updated whenever you push new 114 - commits to your feature branch, ensuring that comparisons -- 115 - and any potential merge conflicts -- are always based on the 116 - latest state of the target branch. 117 - 118 - And just like earlier, we produce the patch by diffing your 119 - feature branch with the hidden tracking ref. Also, the entire pull 120 - request is stored as [an atproto record][atproto-record] and updated 121 - each time the patch changes. 122 - 123 - [atproto-record]: https://pdsls.dev/at://did:plc:qfpnj4og54vl56wngdriaxug/sh.tangled.repo.pull/3lmwniim2i722 124 - 125 - Neat, now that we have a patch; we can move on the hard 126 - part: code review. 127 - 128 - 129 - ## your patch does the rounds 130 - 131 - Tangled uses a "round-based" review format. Your initial 132 - submission starts "round 0". Once your submission receives 133 - scrutiny, you can address reviews and resubmit your patch. 134 - This resubmission starts "round 1". You keep whittling on 135 - your patch till it is good enough, and eventually merged (or 136 - closed if you are unlucky). 137 - 138 - <figure class="max-w-[700px] m-auto flex flex-col items-center justify-center"> 139 - <img class="h-auto max-w-full" src="https://assets.tangled.network/blog/patch-pr-main.png"> 140 - <figcaption class="text-center">A new pull request with a couple 141 - rounds of reviews.</figcaption> 142 - </figure> 143 - 144 - Rounds are a far superior to standard branch-based 145 - approaches: 146 - 147 - - Submissions are immutable: how many times have your 148 - reviews gone out-of-date because the author pushed commits 149 - _during_ your review? 150 - - Reviews are attached to submissions: at a glance, it is 151 - easy to tell which comment applies to which "version" of 152 - the pull-request 153 - - The author can choose when to resubmit! They can commit as 154 - much as they want to their branch, but a new round begins 155 - when they choose to hit "resubmit" 156 - - It is possible to "interdiff" and observe changes made 157 - across submissions (this is coming very soon to Tangled!) 158 - 159 - This [post by Mitchell 160 - Hashimoto](https://mitchellh.com/writing/github-changesets) 161 - goes into further detail on what can be achieved with 162 - round-based reviews. 163 - 164 - ## future plans 165 - 166 - To close off this post, we wanted to share some of our 167 - future plans for pull requests: 168 - 169 - * `format-patch` support: both for pasting in the UI and 170 - internally. This allows us to show commits in the PR page, 171 - and offer different merge strategies to choose from 172 - (squash, rebase, ...). 173 - **Update 2025-08-12**: We have format-patch support! 174 - 175 - * Gerrit-style `refs/for/main`: we're still hashing out the 176 - details but being able to push commits to a ref to 177 - "auto-create" a PR would be super handy! 178 - 179 - * Change ID support: This will allow us to group changes 180 - together and track them across multiple commits, and to 181 - provide "history" for each change. This works great with [Jujutsu][jj]. 182 - **Update 2025-08-12**: This has now landed: https://blog.tangled.org/stacking 183 - 184 - Join us on [Discord](https://chat.tangled.sh) or 185 - `#tangled` on libera.chat (the two are bridged, so we will 186 - never miss a message!). We are always available to help 187 - setup knots, listen to feedback on features, or even 188 - shepherd contributions! 189 - 190 - **Update 2025-08-12**: We move fast, and we now have jujutsu support, and an 191 - early in-house CI: https://blog.tangled.org/ci. You no longer need a Bluesky 192 - account to sign-up; head to https://tangled.sh/signup and sign up with your 193 - email! 194 - 195 - [jj]: https://jj-vcs.github.io/jj/latest/
-74
blog/posts/seed.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: seed 5 - title: announcing our โ‚ฌ3,8M seed round 6 - subtitle: and more on what's next 7 - date: 2026-03-02 8 - image: https://assets.tangled.network/blog/seed.png 9 - authors: 10 - - name: Anirudh 11 - email: anirudh@tangled.org 12 - handle: anirudh.fi 13 - --- 14 - 15 - ![seed](https://assets.tangled.network/seed.png) 16 - 17 - Today, we're announcing our โ‚ฌ3,8M ($4.5M) financing round led by 18 - [byFounders](https://byfounders.vc), with participation from [Bain 19 - Capital Crypto](https://baincapitalcrypto.com/), 20 - [Antler](https://antler.co), Thomas Dohmke (former GitHub CEO), Avery 21 - Pennarun (CEO of Tailscale), among other incredible angels. 22 - 23 - For the past year, we've been building Tangled from the ground up -- 24 - starting from first principles and asking ourselves what code 25 - collaboration should really look like. We made deliberate, 26 - [future-facing technology](https://anirudh.fi/future) choices. We chose 27 - to build on top of the AT Protocol as it helped us realize a federated, 28 - open network where users can own their code and social data. We shipped 29 - stacked PRs to enable more efficient contribution and review workflows. 30 - What started off as a side project, grew to over 7k+ users, who've 31 - created over 5k+ repositories. 32 - 33 - Our vision for Tangled has always been big: we want to build the best 34 - code forge ever, and become foundational infrastructure for the next 35 - generation of open source. Whatever that looks like: hundreds of devs 36 - building artisanal libraries, or one dev and a hundred agents building a 37 - micro-SaaS. 38 - 39 - And finding the right investors to help us acheive this vision wasn't 40 - something we took lightly. We spent months getting to know potential 41 - partners -- among which, byFounders stood out immediately. Like us, 42 - they're community-driven at their core, and their commitment to 43 - transparency runs deep -- you can see the very term sheet we signed on 44 - their website! With these shared fundamental values, we knew byFounders 45 - were the right people to have in our corner and we're incredibly excited 46 - to work with them. 47 - 48 - ## what's next 49 - 50 - We're heads down building. For 2026, expect to see: 51 - 52 - * a fully revamped CI (spindle v2!) built on micro VMs to allow for 53 - faster builds and more choice of build environments. Oh, and a proper 54 - Nix CI -- we know you want it. 55 - * protocol-level improvements across the board that'll unlock nifty 56 - things like repo migrations across knots, organizations, and more! 57 - * a customizable "mission control" dashboard for your active PRs, 58 - issues, and anything else you might want to track. 59 - * a migration tool to help you move off GitHub 60 - * all things search: code search, repo search, etc. 61 - * platform and infrastructure performance improvements & more global 62 - presence 63 - * Tangled CLI! 64 - 65 - If all this sounds exciting to you: we're growing our team! Shoot us 66 - [an email](mailto:team@tangled.org) telling us a bit about yourself and 67 - any past work that might be relevant, and what part of the roadmap 68 - interests you most. We can hire from (almost) anywhere. 69 - 70 - New to Tangled? [Get started here](https://docs.tangled.org/). Oh, and 71 - come hang on [Discord](https://chat.tangled.org)! 72 - 73 - A sincere thank you to everyone that helped us get here -- we're giddy 74 - about what's to come.
-351
blog/posts/stacking.md
··· 1 - --- 2 - atroot: true 3 - template: 4 - slug: stacking 5 - title: jujutsu on tangled 6 - subtitle: tangled now supports jujutsu change-ids! 7 - date: 2025-06-02 8 - image: https://assets.tangled.network/blog/interdiff_difference.jpeg 9 - authors: 10 - - name: Akshay 11 - email: akshay@tangled.sh 12 - handle: oppi.li 13 - draft: false 14 - --- 15 - 16 - Jujutsu is built around structuring your work into 17 - meaningful commits. Naturally, during code-review, you'd 18 - expect reviewers to be able to comment on individual 19 - commits, and also see the evolution of a commit over time, 20 - as reviews are addressed. We set out to natively support 21 - this model of code-review on Tangled. 22 - 23 - Tangled is a new social-enabled Git collaboration platform, 24 - [read our intro](/intro) for more about the project. 25 - 26 - For starters, I would like to contrast the two schools of 27 - code-review, the "diff-soup" model and the interdiff model. 28 - 29 - ## the diff-soup model 30 - 31 - When you create a PR on traditional code forges (GitHub 32 - specifically), the UX implicitly encourages you to address 33 - your code review by *adding commits* on top of your original 34 - set of changes: 35 - 36 - - GitHub's "Apply Suggestion" button directly commits the 37 - suggestion into your PR 38 - - GitHub only shows you the diff of all files at once by 39 - default 40 - - It is difficult to know what changed across force pushes 41 - 42 - Consider a hypothetical PR that adds 3 commits: 43 - 44 - ``` 45 - [c] implement new feature across the board (HEAD) 46 - | 47 - [b] introduce new feature 48 - | 49 - [a] some small refactor 50 - ``` 51 - 52 - And when only newly added commits are easy to review, this 53 - is what ends up happening: 54 - 55 - ``` 56 - [f] formatting & linting (HEAD) 57 - | 58 - [e] update name of new feature 59 - | 60 - [d] fix bug in refactor 61 - | 62 - [c] implement new feature across the board 63 - | 64 - [b] introduce new feature 65 - | 66 - [a] some small refactor 67 - ``` 68 - 69 - It is impossible to tell what addresses what at a glance, 70 - there is an implicit relation between each change: 71 - 72 - ``` 73 - [f] formatting & linting 74 - | 75 - [e] update name of new feature -------------. 76 - | | 77 - [d] fix bug in refactor -----------. | 78 - | | | 79 - [c] implement new feature across the board | 80 - | | | 81 - [b] introduce new feature <-----------------' 82 - | | 83 - [a] some small refactor <----------' 84 - ``` 85 - 86 - This has the downside of clobbering the output of `git 87 - blame` (if there is a bug in the new feature, you will first 88 - land on `e`, and upon digging further, you will land on 89 - `b`). This becomes incredibly tricky to navigate if reviews 90 - go on through multiple cycles. 91 - 92 - 93 - ## the interdiff model 94 - 95 - With jujutsu however, you have the tools at hand to 96 - fearlessly edit, split, squash and rework old commits (you 97 - can absolutely achieve this with git and interactive 98 - rebasing, but it is certainly not trivial). 99 - 100 - Let's try that again: 101 - 102 - ``` 103 - [c] implement new feature across the board (HEAD) 104 - | 105 - [b] introduce new feature 106 - | 107 - [a] some small refactor 108 - ``` 109 - 110 - To fix the bug in the refactor: 111 - 112 - ``` 113 - $ jj edit a 114 - Working copy (@) now at: [a] some small refactor 115 - 116 - $ # hack hack hack 117 - 118 - $ jj log -r a:: 119 - Rebased 2 descendant commits onto updated working copy 120 - [c] implement new feature across the board (HEAD) 121 - | 122 - [b] introduce new feature 123 - | 124 - [a] some small refactor 125 - ``` 126 - 127 - Jujutsu automatically rebases the descendants without having 128 - to lift a finger. Brilliant! You can repeat the same 129 - exercise for all review comments, and effectively, your 130 - PR will have evolved like so: 131 - 132 - ``` 133 - a -> b -> c initial attempt 134 - | | | 135 - v v v 136 - a' -> b' -> c' after first cycle of reviews 137 - ``` 138 - 139 - ## the catch 140 - 141 - If you use `git rebase`, you will know that it modifies 142 - history and therefore changes the commit SHA. How then, 143 - should one tell the difference between the "old" and "new" 144 - state of affairs? 145 - 146 - Tools like `git-range-diff` make use of a variety of 147 - text-based heuristics to roughly match `a` to `a'` and `b` 148 - to `b'` etc. 149 - 150 - Jujutsu however, works around this by assigning stable 151 - "change id"s to each change (which internally point to a git 152 - commit, if you use the git backing). If you edit a commit, 153 - its SHA changes, but its change-id remains the same. 154 - 155 - And this is the essence of our new stacked PRs feature! 156 - 157 - ## interdiff code review on tangled 158 - 159 - To really explain how this works, let's start with a [new 160 - codebase](https://tangled.sh/@oppi.li/stacking-demo/): 161 - 162 - ``` 163 - $ jj git init --colocate 164 - 165 - # -- initialize codebase -- 166 - 167 - $ jj log 168 - @ n set: introduce Set type main HEAD 1h 169 - ``` 170 - 171 - I have kicked things off by creating a new go module that 172 - adds a `HashSet` data structure. My first changeset 173 - introduces some basic set operations: 174 - 175 - ``` 176 - $ jj log 177 - @ so set: introduce set difference HEAD 178 - โ”œ sq set: introduce set intersection 179 - โ”œ mk set: introduce set union 180 - โ”œ my set: introduce basic set operations 181 - ~ 182 - 183 - $ jj git push -c @ 184 - Changes to push to origin: 185 - Add bookmark push-soqmukrvport to fc06362295bd 186 - ``` 187 - 188 - When submitting a pull request, select "Submit as stacked PRs": 189 - 190 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 191 - <a href="https://assets.tangled.network/blog/submit_stacked.jpeg"> 192 - <img class="my-1 h-auto max-w-full" src="https://assets.tangled.network/blog/submit_stacked.jpeg"> 193 - </a> 194 - <figcaption class="text-center">Submitting Stacked PRs</figcaption> 195 - </figure> 196 - 197 - This submits each change as an individual pull request: 198 - 199 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 200 - <a href="https://assets.tangled.network/blog/top_of_stack.jpeg"> 201 - <img class="my-1 h-auto max-w-full" src="https://assets.tangled.network/blog/top_of_stack.jpeg"> 202 - </a> 203 - <figcaption class="text-center">The "stack" is similar to Gerrit's relation chain</figcaption> 204 - </figure> 205 - 206 - After a while, I receive a couple of review comments, not on 207 - my entire submission, but rather, on each *individual 208 - change*. Additionally, the reviewer is happy with my first 209 - change, and has gone ahead and merged that: 210 - 211 - <div class="flex justify-center items-start gap-2"> 212 - <figure class="w-1/3 m-0 flex flex-col items-center"> 213 - <a href="https://assets.tangled.network/blog/basic_merged.jpeg"> 214 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/basic_merged.jpeg" alt="The first change has been merged"> 215 - </a> 216 - <figcaption class="text-center">The first change has been merged</figcaption> 217 - </figure> 218 - 219 - <figure class="w-1/3 m-0 flex flex-col items-center"> 220 - <a href="https://assets.tangled.network/blog/review_union.jpeg"> 221 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/review_union.jpeg" alt="A review on the set union implementation"> 222 - </a> 223 - <figcaption class="text-center">A review on the set union implementation</figcaption> 224 - </figure> 225 - 226 - <figure class="w-1/3 m-0 flex flex-col items-center"> 227 - <a href="https://assets.tangled.network/blog/review_difference.jpeg"> 228 - <img class="my-1 w-full h-auto cursor-pointer" src="https://assets.tangled.network/blog/review_difference.jpeg" alt="A review on the set difference implementation"> 229 - </a> 230 - <figcaption class="text-center">A review on the set difference implementation</figcaption> 231 - </figure> 232 - </div> 233 - 234 - Let us address the first review: 235 - 236 - > can you use the new `maps.Copy` api here? 237 - 238 - ``` 239 - $ jj log 240 - @ so set: introduce set difference push-soqmukrvport 241 - โ”œ sq set: introduce set intersection 242 - โ”œ mk set: introduce set union 243 - โ”œ my set: introduce basic set operations 244 - ~ 245 - 246 - # let's edit the implementation of `Union` 247 - $ jj edit mk 248 - 249 - # hack, hack, hack 250 - 251 - $ jj log 252 - Rebased 2 descendant commits onto updated working copy 253 - โ”œ so set: introduce set difference push-soqmukrvport* 254 - โ”œ sq set: introduce set intersection 255 - @ mk set: introduce set union 256 - โ”œ my set: introduce basic set operations 257 - ~ 258 - ``` 259 - 260 - Next, let us address the bug: 261 - 262 - > there is a logic bug here, the condition should be negated. 263 - 264 - ``` 265 - # let's edit the implementation of `Difference` 266 - $ jj edit so 267 - 268 - # hack, hack, hack 269 - ``` 270 - 271 - We are done addressing reviews: 272 - ``` 273 - $ jj git push 274 - Changes to push to origin: 275 - Move sideways bookmark push-soqmukrvport from fc06362295bd to dfe2750f6d40 276 - ``` 277 - 278 - Upon resubmitting the PR for review, Tangled is able to 279 - accurately trace the commit across rewrites, using jujutsu 280 - change-ids, and map it to the corresponding PR: 281 - 282 - <div class="flex justify-center items-start gap-2"> 283 - <figure class="w-1/2 m-0 flex flex-col items-center"> 284 - <a href="https://assets.tangled.network/blog/round_2_union.jpeg"> 285 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/round_2_union.jpeg" alt="PR #2 advances to the next round"> 286 - </a> 287 - <figcaption class="text-center">PR #2 advances to the next round</figcaption> 288 - </figure> 289 - 290 - <figure class="w-1/2 m-0 flex flex-col items-center"> 291 - <a href="https://assets.tangled.network/blog/round_2_difference.jpeg"> 292 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/round_2_difference.jpeg" alt="PR #4 advances to the next round"> 293 - </a> 294 - <figcaption class="text-center">PR #4 advances to the next round</figcaption> 295 - </figure> 296 - </div> 297 - 298 - Of note here are a few things: 299 - 300 - - The initial submission is still visible under `round #0` 301 - - By resubmitting, the round has simply advanced to `round 302 - #1` 303 - - There is a helpful "interdiff" button to look at the 304 - difference between the two submissions 305 - 306 - The individual diffs are still available, but most 307 - importantly, the reviewer can view the *evolution* of a 308 - change by hitting the interdiff button: 309 - 310 - <div class="flex justify-center items-start gap-2"> 311 - <figure class="w-1/2 m-0 flex flex-col items-center"> 312 - <a href="https://assets.tangled.network/blog/diff_1_difference.jpeg"> 313 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/diff_1_difference.jpeg" alt="Diff from round #0"> 314 - </a> 315 - <figcaption class="text-center">Diff from round #0</figcaption> 316 - </figure> 317 - 318 - <figure class="w-1/2 m-0 flex flex-col items-center"> 319 - <a href="https://assets.tangled.network/blog/diff_2_difference.jpeg"> 320 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/diff_2_difference.jpeg" alt="Diff from round #1"> 321 - </a> 322 - <figcaption class="text-center">Diff from round #1</figcaption> 323 - </figure> 324 - </div> 325 - 326 - <figure class="max-w-[550px] m-auto flex flex-col items-center justify-center"> 327 - <a href="https://assets.tangled.network/blog/interdiff_difference.jpeg"> 328 - <img class="my-1 w-full h-auto" src="https://assets.tangled.network/blog/interdiff_difference.jpeg" alt="Interdiff between round #0 and #1"> 329 - </a> 330 - <figcaption class="text-center">Interdiff between round #1 and #0</figcaption> 331 - </figure> 332 - 333 - Indeed, the logic bug has been addressed! 334 - 335 - ## start stacking today 336 - 337 - If you are a jujutsu user, you can enable this flag on more 338 - recent versions of jujutsu: 339 - 340 - ``` 341 - ฮป jj --version 342 - jj 0.29.0-8c7ca30074767257d75e3842581b61e764d022cf 343 - 344 - # -- in your config.toml file -- 345 - [git] 346 - write-change-id-header = true 347 - ``` 348 - 349 - This feature writes `change-id` headers directly into the 350 - git commit object, and is visible to code forges upon push, 351 - and allows you to stack your PRs on Tangled.
-17
blog/templates/fragments/footer.html
··· 1 - {{ define "blog/fragments/footer" }} 2 - <footer class="mt-12 w-full px-6 py-4"> 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 - <div class="flex items-center justify-center gap-x-2 order-last sm:order-first w-full sm:w-auto"> 5 - <a href="https://tangled.org" class="no-underline hover:no-underline flex items-center"> 6 - {{ template "fragments/dolly/logo" (dict "Classes" "size-5 text-gray-500 dark:text-gray-400") }} 7 - </a> 8 - <span>&copy; 2026 Tangled Labs Oy.</span> 9 - </div> 10 - <a href="https://docs.tangled.org" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">docs</a> 11 - <a href="https://tangled.org/tangled.org/core" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">source</a> 12 - <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> 13 - <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> 14 - <a href="/feed.xml" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline">feed</a> 15 - </div> 16 - </footer> 17 - {{ end }}
-103
blog/templates/index.html
··· 1 - {{ define "title" }}the tangled blog{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta name="description" content="The Tangled blog." /> 5 - <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 6 - {{ end }} 7 - 8 - <!-- overrides the default slate bg --> 9 - {{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} 10 - 11 - {{ define "topbarLayout" }} 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> 30 - </header> 31 - {{ end }} 32 - 33 - {{ define "content" }} 34 - <div class="max-w-screen-lg mx-auto w-full px-4 py-10"> 35 - 36 - <header class="mb-10 text-center"> 37 - <h1 class="text-3xl font-bold dark:text-white mb-2">the tangled blog</h1> 38 - <p class="text-gray-500 dark:text-gray-400">all the ropes and scaffolding</p> 39 - </header> 40 - 41 - {{ if .Featured }} 42 - <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-14"> 43 - {{ range .Featured }} 44 - <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"> 46 - <img src="{{ .Meta.Image }}" alt="{{ .Meta.Title }}" class="w-full h-full object-cover group-hover:scale-[1.02] transition-transform duration-300" /> 47 - </div> 48 - <div class="flex flex-col flex-1 px-5 py-4"> 49 - <div class="text-xs text-gray-400 dark:text-gray-500 mb-2"> 50 - {{ $date := .ParsedDate }}{{ $date | shortTimeFmt}} 51 - {{ if .Meta.Draft }}<span class="text-red-500">[draft]</span>{{ end }} 52 - </div> 53 - <h2 class="font-bold text-gray-900 dark:text-white text-base leading-snug mb-1">{{ .Meta.Title }}</h2> 54 - <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 }} 58 - <div class="inline-flex items-center -space-x-2"> 59 - {{ 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 }} 68 - {{ end }} 69 - </div> 70 - </div> 71 - </div> 72 - </a> 73 - {{ end }} 74 - </div> 75 - {{ end }} 76 - 77 - <div class="grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 78 - {{ range .Posts }} 79 - <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 - <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 - <div class="inline-flex items-center -space-x-2 shrink-0"> 88 - {{ range .Meta.Authors }} 89 - <img src="{{ tinyAvatar .Handle }}" class="size-5 rounded-full border border-gray-300 dark:border-gray-700" alt="{{ .Name }}" title="{{ .Name }}" /> 90 - {{ end }} 91 - </div> 92 - <div class="text-sm text-gray-400 dark:text-gray-500 shrink-0"> 93 - {{ $date := .ParsedDate }}{{ $date | shortTimeFmt }} 94 - </div> 95 - </div> 96 - </a> 97 - {{ end }} 98 - </div> 99 - 100 - </div> 101 - {{ end }} 102 - 103 - {{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }}
-71
blog/templates/post.html
··· 1 - {{ define "title" }}{{ .Post.Meta.Title }} โ€” tangled blog{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta name="description" content="{{ .Post.Meta.Subtitle }}"/> 5 - <meta property="og:title" content="{{ .Post.Meta.Title }}" /> 6 - <meta property="og:description" content="{{ .Post.Meta.Subtitle }}" /> 7 - <meta property="og:url" content="https://blog.tangled.org/{{ .Post.Meta.Slug }}" /> 8 - {{ if .Post.Meta.Image }} 9 - <meta property="og:image" content="https://blog.tangled.org{{ .Post.Meta.Image }}" /> 10 - <meta property="og:image:width" content="1200" /> 11 - <meta property="og:image:height" content="630" /> 12 - <meta name="twitter:card" content="summary_large_image" /> 13 - <meta name="twitter:image" content="https://blog.tangled.org{{ .Post.Meta.Image }}" /> 14 - {{ end }} 15 - <meta name="twitter:title" content="{{ .Post.Meta.Title }}" /> 16 - <meta name="twitter:description" content="{{ .Post.Meta.Subtitle }}" /> 17 - <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 18 - {{ end }} 19 - 20 - {{ define "bodyClasses" }}!bg-white dark:!bg-gray-900{{ end }} 21 - 22 - {{ define "topbarLayout" }} 23 - <header class="max-w-screen-xl mx-auto w-full" style="z-index: 20;"> 24 - {{ template "layouts/fragments/topbar" . }} 25 - </header> 26 - {{ end }} 27 - 28 - {{ define "footerLayout" }}{{ template "blog/fragments/footer" . }}{{ end }} 29 - 30 - {{ define "content" }} 31 - <div class="max-w-[90ch] mx-auto w-full px-4 py-8"> 32 - <div class="prose dark:prose-invert w-full max-w-none"> 33 - 34 - <header class="not-prose mb-4"> 35 - {{ $authors := .Post.Meta.Authors }} 36 - <p class="mb-1 text-sm text-gray-600 dark:text-gray-400"> 37 - {{ $date := .Post.ParsedDate }} 38 - {{ $date | shortTimeFmt }} 39 - </p> 40 - 41 - <h1 class="mb-0 text-2xl font-bold dark:text-white"> 42 - {{ .Post.Meta.Title }} 43 - {{ if .Post.Meta.Draft }}<span class="text-red-500 text-base font-normal">[draft]</span>{{ end }} 44 - </h1> 45 - <p class="italic mt-1 mb-3 text-lg text-gray-600 dark:text-gray-400">{{ .Post.Meta.Subtitle }}</p> 46 - 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 - <div class="inline-flex items-center -space-x-2"> 51 - {{ 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 }} 53 - {{ end }} 54 - </div> 55 - {{ end }} 56 - <div class="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300"> 57 - {{ range $i, $a := $authors }} 58 - {{ if gt $i 0 }}<span class="text-gray-400">&amp;</span>{{ end }} 59 - <a href="https://tangled.org/@{{ $a.Handle }}" class="hover:underline">{{ $a.Handle }}</a> 60 - {{ end }} 61 - </div> 62 - </div> 63 - </header> 64 - 65 - <article> 66 - {{ .Post.Body }} 67 - </article> 68 - 69 - </div> 70 - </div> 71 - {{ end }}
-91
blog/templates/text.html
··· 1 - {{ define "fragments/logotypeSmall" }} 2 - <span class="flex items-center gap-2"> 3 - {{ template "fragments/dolly/logo" "size-8 text-black dark:text-white" }} 4 - <span class="font-bold text-xl not-italic">tangled</span> 5 - <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1">alpha</span> 6 - </span> 7 - {{ end }} 8 - 9 - <!doctype html> 10 - <html lang="en"> 11 - <head> 12 - <meta charset="UTF-8" /> 13 - <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 14 - <meta name="description" content="{{ index .Meta "subtitle" }}"/> 15 - <meta property="og:site_name" content="Tangled" /> 16 - <meta property="og:type" content="website" /> 17 - <meta property="og:title" content="{{ index .Meta "title" }}" /> 18 - <meta property="og:description" content="{{ index .Meta "subtitle" }}" /> 19 - <meta property="og:url" content="https://blog.tangled.org/{{ index .Meta "slug" }}" /> 20 - <meta property="og:image" content="https://blog.tangled.org{{ index .Meta "image" }}" /> 21 - <meta property="og:image:width" content="1200" /> 22 - <meta property="og:image:height" content="630" /> 23 - <meta name="twitter:card" content="summary_large_image" /> 24 - <meta name="twitter:title" content="{{ index .Meta "title" }}" /> 25 - <meta name="twitter:description" content="{{ index .Meta "subtitle" }}" /> 26 - <meta name="twitter:image" content="https://blog.tangled.org{{ index .Meta "image" }}" /> 27 - <link rel="alternate" type="application/atom+xml" title="Atom" href="/feed.xml" /> 28 - <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/> 29 - <link rel="icon" href="/static/logos/dolly.svg" sizes="any" type="image/svg+xml"/> 30 - <link rel="apple-touch-icon" href="/static/logos/dolly.png"/> 31 - <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 32 - <link rel="stylesheet" href="/static/tw.css" type="text/css" /> 33 - <title>{{ index .Meta "title" }}</title> 34 - </head> 35 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white min-h-screen flex flex-col gap-4"> 36 - 37 - <header class="w-full drop-shadow-sm bg-white dark:bg-gray-800"> 38 - <nav class="mx-auto px-6 py-2"> 39 - <div class="flex justify-between items-center"> 40 - <a href="/" class="text-2xl no-underline hover:no-underline flex items-center gap-2"> 41 - {{ template "fragments/logotypeSmall" }} 42 - </a> 43 - </div> 44 - </nav> 45 - </header> 46 - 47 - <div class="flex-grow flex flex-col max-w-[75ch] mx-auto w-full px-1"> 48 - <main class="prose dark:prose-invert w-full max-w-none"> 49 - 50 - <header class="not-prose"> 51 - <p class="px-6 mb-0 text-sm text-gray-600 dark:text-gray-400"> 52 - {{ $dateStr := index .Meta "date" }} 53 - {{ $date := parsedate $dateStr }} 54 - {{ $date.Format | shortTimeFmt }} 55 - 56 - <span class="mx-2 select-none">&middot;</span> 57 - 58 - by 59 - {{ $authors := index .Meta "authors" }} 60 - {{ if eq (len $authors) 2 }} 61 - <a href="https://bsky.app/profile/{{ (index $authors 0).handle }}" class="no-underline">{{ (index $authors 0).name }}</a> 62 - &amp; 63 - <a href="https://bsky.app/profile/{{ (index $authors 1).handle }}" class="no-underline">{{ (index $authors 1).name }}</a> 64 - {{ else }} 65 - {{ range $authors }} 66 - <a href="https://bsky.app/profile/{{ .handle }}" class="no-underline">{{ .name }}</a> 67 - {{ end }} 68 - {{ end }} 69 - </p> 70 - 71 - {{ if index .Meta "draft" }} 72 - <h1 class="px-6 mb-0 text-2xl font-bold">{{ index .Meta "title" }} <span class="text-red-500">[draft]</span></h1> 73 - {{ else }} 74 - <h1 class="px-6 mb-0 text-2xl font-bold">{{ index .Meta "title" }}</h1> 75 - {{ end }} 76 - <p class="italic px-6 mt-1 mb-4 text-lg text-gray-600 dark:text-gray-400">{{ index .Meta "subtitle" }}</p> 77 - </header> 78 - 79 - <article class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 80 - {{ .Body }} 81 - </article> 82 - 83 - </main> 84 - </div> 85 - 86 - <footer class="mt-12"> 87 - {{ template "layouts/fragments/footer" . }} 88 - </footer> 89 - 90 - </body> 91 - </html>
-220
cmd/blog/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "io" 7 - "io/fs" 8 - "log/slog" 9 - "net/http" 10 - "os" 11 - "path/filepath" 12 - 13 - "tangled.org/core/appview/config" 14 - "tangled.org/core/appview/pages" 15 - "tangled.org/core/blog" 16 - "tangled.org/core/idresolver" 17 - tlog "tangled.org/core/log" 18 - ) 19 - 20 - const ( 21 - postsDir = "blog/posts" 22 - templatesDir = "blog/templates" 23 - ) 24 - 25 - func main() { 26 - if len(os.Args) < 2 { 27 - fmt.Fprintln(os.Stderr, "usage: blog <build|serve> [flags]") 28 - os.Exit(1) 29 - } 30 - 31 - ctx := context.Background() 32 - logger := tlog.New("blog") 33 - 34 - switch os.Args[1] { 35 - case "build": 36 - if err := runBuild(ctx, logger); err != nil { 37 - logger.Error("build failed", "err", err) 38 - os.Exit(1) 39 - } 40 - case "serve": 41 - addr := "0.0.0.0:3001" 42 - if len(os.Args) >= 3 { 43 - addr = os.Args[2] 44 - } 45 - if err := runServe(ctx, logger, addr); err != nil { 46 - logger.Error("serve failed", "err", err) 47 - os.Exit(1) 48 - } 49 - default: 50 - fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1]) 51 - os.Exit(1) 52 - } 53 - } 54 - 55 - func makePages(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*pages.Pages, error) { 56 - resolver := idresolver.DefaultResolver(cfg.Plc.PLCURL) 57 - return pages.NewPages(cfg, resolver, nil, logger), nil 58 - } 59 - 60 - func runBuild(ctx context.Context, logger *slog.Logger) error { 61 - cfg, err := config.LoadConfig(ctx) 62 - if err != nil { 63 - return fmt.Errorf("failed to load config: %w", err) 64 - } 65 - 66 - p, err := makePages(ctx, cfg, logger) 67 - if err != nil { 68 - return fmt.Errorf("creating pages: %w", err) 69 - } 70 - 71 - posts, err := blog.Posts(postsDir) 72 - if err != nil { 73 - return fmt.Errorf("parsing posts: %w", err) 74 - } 75 - 76 - outDir := "build" 77 - if err := os.MkdirAll(outDir, 0755); err != nil { 78 - return err 79 - } 80 - 81 - // index 82 - if err := renderToFile(outDir, "index.html", func(w io.Writer) error { 83 - return blog.RenderIndex(p, templatesDir, posts, w) 84 - }); err != nil { 85 - return fmt.Errorf("rendering index: %w", err) 86 - } 87 - 88 - for _, post := range posts { 89 - postDir := filepath.Join(outDir, post.Meta.Slug) 90 - if err := os.MkdirAll(postDir, 0755); err != nil { 91 - return err 92 - } 93 - if err := renderToFile(postDir, "index.html", func(w io.Writer) error { 94 - return blog.RenderPost(p, templatesDir, post, w) 95 - }); err != nil { 96 - return fmt.Errorf("rendering post %s: %w", post.Meta.Slug, err) 97 - } 98 - } 99 - 100 - // atom feed 101 - baseURL := "https://blog.tangled.org" 102 - atom, err := blog.AtomFeed(posts, baseURL) 103 - if err != nil { 104 - return fmt.Errorf("generating atom feed: %w", err) 105 - } 106 - if err := os.WriteFile(filepath.Join(outDir, "feed.xml"), []byte(atom), 0644); err != nil { 107 - return fmt.Errorf("writing feed: %w", err) 108 - } 109 - 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 - logger.Info("build complete", "dir", outDir) 121 - 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 - } 141 - 142 - func runServe(ctx context.Context, logger *slog.Logger, addr string) error { 143 - cfg, err := config.LoadConfig(ctx) 144 - if err != nil { 145 - return fmt.Errorf("failed to load config: %w", err) 146 - } 147 - 148 - p, err := makePages(ctx, cfg, logger) 149 - if err != nil { 150 - return fmt.Errorf("creating pages: %w", err) 151 - } 152 - 153 - mux := http.NewServeMux() 154 - 155 - // index 156 - mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { 157 - if r.URL.Path != "/" { 158 - http.NotFound(w, r) 159 - return 160 - } 161 - posts, err := blog.AllPosts(postsDir) 162 - if err != nil { 163 - http.Error(w, err.Error(), http.StatusInternalServerError) 164 - return 165 - } 166 - if err := blog.RenderIndex(p, templatesDir, posts, w); err != nil { 167 - logger.Error("render index", "err", err) 168 - } 169 - }) 170 - 171 - // individual posts directly at /<slug> 172 - mux.HandleFunc("GET /{slug}", func(w http.ResponseWriter, r *http.Request) { 173 - slug := r.PathValue("slug") 174 - posts, err := blog.AllPosts(postsDir) 175 - if err != nil { 176 - http.Error(w, err.Error(), http.StatusInternalServerError) 177 - return 178 - } 179 - for _, post := range posts { 180 - if post.Meta.Slug == slug { 181 - if err := blog.RenderPost(p, templatesDir, post, w); err != nil { 182 - logger.Error("render post", "err", err) 183 - } 184 - return 185 - } 186 - } 187 - http.NotFound(w, r) 188 - }) 189 - 190 - // atom feed at /feed.xml 191 - mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { 192 - posts, err := blog.Posts(postsDir) 193 - if err != nil { 194 - http.Error(w, err.Error(), http.StatusInternalServerError) 195 - return 196 - } 197 - atom, err := blog.AtomFeed(posts, "https://blog.tangled.org") 198 - if err != nil { 199 - http.Error(w, err.Error(), http.StatusInternalServerError) 200 - return 201 - } 202 - w.Header().Set("Content-Type", "application/atom+xml") 203 - fmt.Fprint(w, atom) 204 - }) 205 - 206 - // appview static files (tw.css, fonts, icons, logos) 207 - mux.Handle("GET /static/", p.Static()) 208 - 209 - logger.Info("serving", "addr", addr) 210 - return http.ListenAndServe(addr, mux) 211 - } 212 - 213 - func renderToFile(dir, name string, fn func(io.Writer) error) error { 214 - f, err := os.Create(filepath.Join(dir, name)) 215 - if err != nil { 216 - return err 217 - } 218 - defer f.Close() 219 - return fn(f) 220 - }
-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 -1
docs/DOCS.md
··· 827 827 MY_ENV_VAR: "MY_ENV_VALUE" 828 828 ``` 829 829 830 - By default, the following environment variables are set: 830 + By default, the following environment variables set: 831 831 832 832 - `CI` - Always set to `true` to indicate a CI environment 833 833 - `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline
+1 -24
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 ··· 258 254 type = "app"; 259 255 program = ''${air-watcher "spindle" ""}/bin/run''; 260 256 }; 261 - watch-blog = { 262 - type = "app"; 263 - program = toString (pkgs.writeShellScript "watch-blog" '' 264 - echo "copying static files to appview/pages/static..." 265 - mkdir -p appview/pages/static 266 - ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership,mode ${packages'.appview-static-files}/* appview/pages/static 267 - ${air-watcher "blog" "serve"}/bin/run 268 - ''); 269 - }; 270 257 watch-tailwind = { 271 258 type = "app"; 272 259 program = ''${tailwind-watcher}/bin/run''; ··· 352 339 imports = [./nix/modules/appview.nix]; 353 340 354 341 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 342 }; 366 343 nixosModules.knot = { 367 344 lib,
+24 -28
go.mod
··· 7 7 github.com/alecthomas/assert/v2 v2.11.0 8 8 github.com/alecthomas/chroma/v2 v2.23.1 9 9 github.com/avast/retry-go/v4 v4.6.1 10 - github.com/aws/aws-sdk-go-v2 v1.41.1 11 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 10 + github.com/aws/aws-sdk-go-v2 v1.41.4 11 + github.com/aws/aws-sdk-go-v2/config v1.32.12 12 + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 12 13 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 13 14 github.com/blevesearch/bleve/v2 v2.5.3 14 15 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 16 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 17 + github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 17 18 github.com/bmatcuk/doublestar/v4 v4.9.1 18 19 github.com/carlmjohnson/versioninfo v0.22.5 19 20 github.com/casbin/casbin/v2 v2.103.0 ··· 35 36 github.com/hiddeco/sshsig v0.2.0 36 37 github.com/hpcloud/tail v1.0.0 37 38 github.com/ipfs/go-cid v0.5.0 38 - github.com/jackc/pgx/v5 v5.8.0 39 39 github.com/mattn/go-sqlite3 v1.14.24 40 40 github.com/microcosm-cc/bluemonday v1.0.27 41 41 github.com/openbao/openbao/api/v2 v2.3.0 42 42 github.com/posthog/posthog-go v1.5.5 43 - github.com/prometheus/client_golang v1.23.2 44 43 github.com/redis/go-redis/v9 v9.7.3 45 44 github.com/resend/resend-go/v2 v2.15.0 46 45 github.com/sethvargo/go-envconfig v1.1.0 47 46 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 48 47 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 48 + github.com/stretchr/testify v1.10.0 49 + github.com/urfave/cli/v3 v3.3.3 51 50 github.com/whyrusleeping/cbor-gen v0.3.1 52 51 github.com/yuin/goldmark v1.7.13 53 52 github.com/yuin/goldmark-emoji v1.0.6 54 53 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 55 54 gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 56 55 go.abhg.dev/goldmark/mermaid v0.6.0 57 - golang.org/x/crypto v0.41.0 56 + golang.org/x/crypto v0.40.0 58 57 golang.org/x/image v0.31.0 59 - golang.org/x/net v0.43.0 58 + golang.org/x/net v0.42.0 60 59 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 61 60 gopkg.in/yaml.v3 v3.0.1 62 61 ) 63 62 64 63 require ( 65 64 dario.cat/mergo v1.0.1 // indirect 66 - github.com/BurntSushi/toml v0.3.1 // indirect 67 65 github.com/Microsoft/go-winio v0.6.2 // indirect 68 66 github.com/ProtonMail/go-crypto v1.3.0 // indirect 69 67 github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect 70 - github.com/adrg/frontmatter v0.2.0 // indirect 71 68 github.com/alecthomas/repr v0.5.2 // indirect 72 69 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 73 70 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 74 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect 75 - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect 71 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect 72 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect 73 + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect 74 + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect 76 75 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect 77 - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 76 + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect 78 77 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect 79 - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect 78 + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect 80 79 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect 81 - github.com/aws/smithy-go v1.24.0 // indirect 80 + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect 81 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect 82 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect 83 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect 84 + github.com/aws/smithy-go v1.24.2 // indirect 82 85 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 83 86 github.com/aymerick/douceur v0.2.0 // indirect 84 87 github.com/beorn7/perks v1.0.1 // indirect ··· 118 121 github.com/dlclark/regexp2 v1.11.5 // indirect 119 122 github.com/docker/go-connections v0.5.0 // indirect 120 123 github.com/docker/go-units v0.5.0 // indirect 121 - github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 122 124 github.com/emirpasic/gods v1.18.1 // indirect 123 125 github.com/felixge/httpsnoop v1.0.4 // indirect 124 126 github.com/fsnotify/fsnotify v1.6.0 // indirect ··· 163 165 github.com/ipfs/go-log v1.0.5 // indirect 164 166 github.com/ipfs/go-log/v2 v2.6.0 // indirect 165 167 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 168 github.com/json-iterator/go v1.1.12 // indirect 170 169 github.com/kevinburke/ssh_config v1.2.0 // indirect 171 170 github.com/klauspost/compress v1.18.0 // indirect ··· 198 197 github.com/pkg/errors v0.9.1 // indirect 199 198 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 200 199 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 200 + github.com/prometheus/client_golang v1.22.0 // indirect 201 201 github.com/prometheus/client_model v0.6.2 // indirect 202 - github.com/prometheus/common v0.66.1 // indirect 202 + github.com/prometheus/common v0.64.0 // indirect 203 203 github.com/prometheus/procfs v0.16.1 // indirect 204 204 github.com/rivo/uniseg v0.4.7 // indirect 205 205 github.com/ryanuber/go-glob v1.0.0 // indirect ··· 226 226 go.uber.org/atomic v1.11.0 // indirect 227 227 go.uber.org/multierr v1.11.0 // indirect 228 228 go.uber.org/zap v1.27.0 // indirect 229 - go.yaml.in/yaml/v2 v2.4.2 // indirect 230 229 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 231 230 golang.org/x/sync v0.17.0 // indirect 232 - golang.org/x/sys v0.35.0 // indirect 231 + golang.org/x/sys v0.34.0 // indirect 233 232 golang.org/x/text v0.29.0 // indirect 234 233 golang.org/x/time v0.12.0 // indirect 235 234 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 236 235 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 237 236 google.golang.org/grpc v1.73.0 // indirect 238 - google.golang.org/protobuf v1.36.8 // indirect 237 + google.golang.org/protobuf v1.36.6 // indirect 239 238 gopkg.in/fsnotify.v1 v1.4.7 // indirect 240 239 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 241 240 gopkg.in/warnings.v0 v0.1.2 // indirect 242 - gopkg.in/yaml.v2 v2.4.0 // indirect 243 241 gotest.tools/v3 v3.5.2 // indirect 244 242 lukechampine.com/blake3 v1.4.1 // indirect 245 243 ) ··· 251 249 replace github.com/bluekeyes/go-gitdiff => tangled.sh/oppi.li/go-gitdiff v0.8.2 252 250 253 251 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 252 257 253 // from bluesky-social/indigo 258 254 replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4
+50 -54
go.sum
··· 4 4 github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 5 5 github.com/Blank-Xu/sql-adapter v1.1.1 h1:+g7QXU9sl/qT6Po97teMpf3GjAO0X9aFaqgSePXvYko= 6 6 github.com/Blank-Xu/sql-adapter v1.1.1/go.mod h1:o2g8EZhZ3TudnYEGDkoU+3jCTCgDgx1o/Ig5ajKkaLY= 7 - github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 8 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 9 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 10 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= ··· 12 11 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 13 12 github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg= 14 13 github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= 15 - github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= 16 - github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= 17 14 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 18 15 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 19 16 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= ··· 25 22 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 26 23 github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 27 24 github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 28 - github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= 29 - github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= 25 + github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= 26 + github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= 30 27 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= 31 28 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= 32 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= 33 - github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= 34 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= 35 - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= 36 - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= 37 - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= 29 + github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= 30 + github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= 31 + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= 32 + github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= 33 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= 34 + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= 35 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= 36 + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= 37 + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= 38 + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= 39 + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= 40 + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= 38 41 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= 39 42 github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= 40 - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= 41 - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= 43 + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= 44 + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= 42 45 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= 43 46 github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= 44 - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= 45 - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= 47 + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= 48 + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= 46 49 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= 47 50 github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= 48 51 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= 49 52 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= 50 - github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 51 - github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 53 + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= 54 + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= 55 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= 56 + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= 57 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= 58 + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= 59 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= 60 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= 61 + github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= 62 + github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 52 63 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 53 64 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 54 65 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= ··· 94 105 github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= 95 106 github.com/blevesearch/zapx/v16 v16.2.4 h1:tGgfvleXTAkwsD5mEzgM3zCS/7pgocTCnO1oyAUjlww= 96 107 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= 108 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 109 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 110 + github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 111 + github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 99 112 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 100 113 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 101 114 github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 102 115 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 116 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 106 117 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 107 118 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= ··· 178 189 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 179 190 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 180 191 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 192 github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 184 193 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 185 194 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= ··· 350 359 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 351 360 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 352 361 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 362 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 362 363 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 363 364 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 379 380 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 380 381 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 381 382 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 383 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 385 384 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 386 385 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= ··· 482 481 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 483 482 github.com/posthog/posthog-go v1.5.5 h1:2o3j7IrHbTIfxRtj4MPaXKeimuTYg49onNzNBZbwksM= 484 483 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= 484 + github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 485 + github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 487 486 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 488 487 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= 488 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 489 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 491 490 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 492 491 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 493 492 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= ··· 533 532 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 534 533 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 535 534 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= 535 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 536 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 538 537 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 539 538 github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 540 539 github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= ··· 547 546 github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 548 547 github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 549 548 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= 549 + github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 550 + github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 552 551 github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= 553 552 github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= 554 553 github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= ··· 618 617 go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 619 618 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 620 619 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 620 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 624 621 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 625 622 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= ··· 627 624 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 628 625 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 629 626 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= 627 + golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 628 + golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 632 629 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 633 630 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 634 631 golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= ··· 663 660 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 664 661 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 665 662 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= 663 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 664 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 668 665 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 669 666 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 670 667 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 704 701 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 705 702 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 706 703 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= 704 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 705 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 709 706 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 710 707 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 711 708 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= ··· 715 712 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 716 713 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 717 714 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= 715 + golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 716 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 720 717 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 721 718 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 722 719 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 769 766 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 770 767 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 771 768 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= 769 + google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 770 + google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 774 771 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 775 772 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 776 773 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= ··· 787 784 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 788 785 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 789 786 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 790 - gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 791 787 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 792 788 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 793 789 gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+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 {
-12
input.css
··· 373 373 @apply bg-amber-400/30 dark:bg-amber-500/20; 374 374 } 375 375 376 - .line-quote-hl, .line-range-hl { 377 - @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 378 - } 379 - 380 - :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] { 381 - @apply !bg-yellow-200/30 dark:!bg-yellow-700/30; 382 - } 383 - 384 - :is(.line-quote-hl, .line-range-hl) > .min-w-\[3\.5rem\] a { 385 - @apply !text-black dark:!text-white; 386 - } 387 - 388 376 /* LineNumbersTable */ 389 377 .chroma .lnt { 390 378 white-space: pre;
-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)
+34 -24
knotserver/git.go
··· 5 5 "fmt" 6 6 "io" 7 7 "net/http" 8 - "os" 8 + "path/filepath" 9 9 "strings" 10 10 11 + securejoin "github.com/cyphar/filepath-securejoin" 11 12 "github.com/go-chi/chi/v5" 12 13 "tangled.org/core/knotserver/git/service" 13 14 ) 14 15 15 16 func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 + did := chi.URLParam(r, "did") 16 18 name := chi.URLParam(r, "name") 17 - repoPath, ok := repoPathFromcontext(r.Context()) 18 - if !ok { 19 - w.WriteHeader(http.StatusInternalServerError) 20 - w.Write([]byte("Failed to find repository path")) 19 + repoName, err := securejoin.SecureJoin(did, name) 20 + if err != nil { 21 + gitError(w, "repository not found", http.StatusNotFound) 22 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 23 + return 24 + } 25 + 26 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repoName) 27 + if err != nil { 28 + gitError(w, "repository not found", http.StatusNotFound) 29 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 21 30 return 22 31 } 23 32 ··· 48 57 } 49 58 50 59 func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) { 51 - repo, ok := repoPathFromcontext(r.Context()) 52 - if !ok { 53 - w.WriteHeader(http.StatusInternalServerError) 54 - w.Write([]byte("Failed to find repository path")) 60 + did := chi.URLParam(r, "did") 61 + name := chi.URLParam(r, "name") 62 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 63 + if err != nil { 64 + gitError(w, err.Error(), http.StatusInternalServerError) 65 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 55 66 return 56 67 } 57 68 ··· 93 104 } 94 105 95 106 func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 96 - repo, ok := repoPathFromcontext(r.Context()) 97 - if !ok { 98 - w.WriteHeader(http.StatusInternalServerError) 99 - w.Write([]byte("Failed to find repository path")) 107 + did := chi.URLParam(r, "did") 108 + name := chi.URLParam(r, "name") 109 + repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 110 + if err != nil { 111 + gitError(w, err.Error(), http.StatusInternalServerError) 112 + h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 100 113 return 101 114 } 102 115 ··· 140 153 } 141 154 142 155 func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 156 + did := chi.URLParam(r, "did") 143 157 name := chi.URLParam(r, "name") 158 + _, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 159 + if err != nil { 160 + gitError(w, err.Error(), http.StatusForbidden) 161 + h.l.Error("git: failed to secure join repo path", "handler", "ReceivePack", "error", err) 162 + return 163 + } 164 + 144 165 h.RejectPush(w, r, name) 145 166 } 146 167 ··· 169 190 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName) 170 191 } 171 192 fmt.Fprintf(w, "\n\n") 172 - } 173 - 174 - func isDir(path string) (bool, error) { 175 - info, err := os.Stat(path) 176 - if err == nil && info.IsDir() { 177 - return true, nil 178 - } 179 - if os.IsNotExist(err) { 180 - return false, nil 181 - } 182 - return false, err 183 193 } 184 194 185 195 func gitError(w http.ResponseWriter, msg string, status int) {
-41
knotserver/router.go
··· 5 5 "fmt" 6 6 "log/slog" 7 7 "net/http" 8 - "path/filepath" 9 8 "strings" 10 9 11 - securejoin "github.com/cyphar/filepath-securejoin" 12 10 "github.com/go-chi/chi/v5" 13 11 "tangled.org/core/idresolver" 14 12 "tangled.org/core/jetstream" ··· 84 82 r.Route("/{did}", func(r chi.Router) { 85 83 r.Use(h.resolveDidRedirect) 86 84 r.Route("/{name}", func(r chi.Router) { 87 - r.Use(h.resolveRepo) 88 - 89 85 // routes for git operations 90 86 r.Get("/info/refs", h.InfoRefs) 91 87 r.Post("/git-upload-archive", h.UploadArchive) ··· 142 138 suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle) 143 139 newPath := fmt.Sprintf("/%s/%s?%s", id.DID.String(), suffix, r.URL.RawQuery) 144 140 http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) 145 - }) 146 - } 147 - 148 - type ctxRepoPathKey struct{} 149 - 150 - func repoPathFromcontext(ctx context.Context) (string, bool) { 151 - v, ok := ctx.Value(ctxRepoPathKey{}).(string) 152 - return v, ok 153 - } 154 - 155 - // resolveRepo is a http middleware that constructs git repo path from given did & name pair. 156 - // It will reject the requests to unknown repos (when dir doesn't exist) 157 - func (h *Knot) resolveRepo(next http.Handler) http.Handler { 158 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 159 - did := chi.URLParam(r, "did") 160 - name := chi.URLParam(r, "name") 161 - repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name)) 162 - if err != nil { 163 - w.WriteHeader(http.StatusNotFound) 164 - w.Write([]byte("Repository not found")) 165 - return 166 - } 167 - 168 - exist, err := isDir(repoPath) 169 - if err != nil { 170 - w.WriteHeader(http.StatusInternalServerError) 171 - w.Write([]byte("Failed to check repository path")) 172 - return 173 - } 174 - if !exist { 175 - w.WriteHeader(http.StatusNotFound) 176 - w.Write([]byte("Repository not found")) 177 - return 178 - } 179 - 180 - ctx := context.WithValue(r.Context(), ctxRepoPathKey{}, repoPath) 181 - next.ServeHTTP(w, r.WithContext(ctx)) 182 141 }) 183 142 } 184 143
-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 - }
+55 -53
nix/gomod2nix.toml
··· 33 33 version = "v4.6.1" 34 34 hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 35 35 [mod."github.com/aws/aws-sdk-go-v2"] 36 - version = "v1.41.1" 37 - hash = "sha256-umafTZB+cuy8+Kzpl2WrlygJO3tR3D2WkTC0eTY5G/g=" 36 + version = "v1.41.4" 37 + hash = "sha256-k9xv4f8YPSzZ1yR3/zuyNDGenZKk0DD4lceL713yXtc=" 38 38 [mod."github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"] 39 39 version = "v1.7.4" 40 40 hash = "sha256-ZY/Jn1p0IgDe8MONhp0RFHZmRgTBZZ5ddqXlNWEo7Ys=" 41 + [mod."github.com/aws/aws-sdk-go-v2/config"] 42 + version = "v1.32.12" 43 + hash = "sha256-aTkdSRe8KPmVZdsunU8j/hZQLhGw1ckKpLN/ryRBZM0=" 41 44 [mod."github.com/aws/aws-sdk-go-v2/credentials"] 42 - version = "v1.19.9" 43 - hash = "sha256-eqM5BmetQ/MrxTwoUCqRpkm5frFAspuq+QWod+5wzrU=" 45 + version = "v1.19.12" 46 + hash = "sha256-xEIT1ARA9RYrQtLZIus71E6niNHIOVM1J7mUnA5AhJQ=" 47 + [mod."github.com/aws/aws-sdk-go-v2/feature/ec2/imds"] 48 + version = "v1.18.20" 49 + hash = "sha256-dCTpdKZheVCSt+R+NnFOnlS0bCt4gPavlDh15Kl/sMQ=" 44 50 [mod."github.com/aws/aws-sdk-go-v2/internal/configsources"] 45 - version = "v1.4.17" 46 - hash = "sha256-W+d0WDYBrVJxdfRSvoe3cvZYgIgUSVyXsVdQlMcIZvc=" 51 + version = "v1.4.20" 52 + hash = "sha256-aATIk4oLd7aaV66ereBdjINLMDwmIHxu+NNsgKWH1t4=" 47 53 [mod."github.com/aws/aws-sdk-go-v2/internal/endpoints/v2"] 48 - version = "v2.7.17" 49 - hash = "sha256-dKJx0+K1DKi2LJKQNwnTofZH4GWLDgY6/ZI2XR2oCGI=" 54 + version = "v2.7.20" 55 + hash = "sha256-G6266uj64sgfDTJ9V1UY1sQs3UmryB0CFgxzmbjjChY=" 56 + [mod."github.com/aws/aws-sdk-go-v2/internal/ini"] 57 + version = "v1.8.6" 58 + hash = "sha256-oIRPqu99vnGINAWKnCEytpv7N0gRWO7S72tb1r8oxvk=" 50 59 [mod."github.com/aws/aws-sdk-go-v2/internal/v4a"] 51 60 version = "v1.4.17" 52 61 hash = "sha256-PKwNn9nFf+7TqocgQprxP9r1Fs4IxwGLGd05MrsxmLg=" 53 62 [mod."github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding"] 54 - version = "v1.13.4" 55 - hash = "sha256-Rm6czqOnOULP080D97WQQSqkBhmN6ei1qZaTa51SRj8=" 63 + version = "v1.13.7" 64 + hash = "sha256-AfYJdpmnW01Bk/jfHATlNU6lddjqcigFkHw/zcT9WO4=" 56 65 [mod."github.com/aws/aws-sdk-go-v2/service/internal/checksum"] 57 66 version = "v1.9.8" 58 67 hash = "sha256-DZhR0aqHrgAFBGSlnsSQ6XeAJ/q504RG/LBWhtQqRVg=" 59 68 [mod."github.com/aws/aws-sdk-go-v2/service/internal/presigned-url"] 60 - version = "v1.13.17" 61 - hash = "sha256-qS7Db9S/KQ24kvJdL8qH4gnoN116J1ezwOnbovCiFEI=" 69 + version = "v1.13.20" 70 + hash = "sha256-a5TifKunIoqKd2uAceYh6F1LvMHMyEQcWvJf0sxKhPM=" 62 71 [mod."github.com/aws/aws-sdk-go-v2/service/internal/s3shared"] 63 72 version = "v1.19.17" 64 73 hash = "sha256-x/Cb4j3HFlg1+U21YCAIhBnfpf1yehJU2Ss/PxamEMI=" 65 74 [mod."github.com/aws/aws-sdk-go-v2/service/s3"] 66 75 version = "v1.96.0" 67 76 hash = "sha256-lzAn2KHIkd742mDwEZqGEaIXlEfvltL6HdP+sEQ/8YA=" 77 + [mod."github.com/aws/aws-sdk-go-v2/service/signin"] 78 + version = "v1.0.8" 79 + hash = "sha256-o4pWg3yMZHxdI94x5Z6qbiRg7gpmzbpJnJWsR1BOc44=" 80 + [mod."github.com/aws/aws-sdk-go-v2/service/sso"] 81 + version = "v1.30.13" 82 + hash = "sha256-V277a0ikm/H0paIeDLtPGEyav2a69Kdb9d5bh+JLAeY=" 83 + [mod."github.com/aws/aws-sdk-go-v2/service/ssooidc"] 84 + version = "v1.35.17" 85 + hash = "sha256-r5V5DoCIR4yzN1Ttg+dIA85GVkWMPgeD6Zu0rWGqNJE=" 86 + [mod."github.com/aws/aws-sdk-go-v2/service/sts"] 87 + version = "v1.41.9" 88 + hash = "sha256-I15uxeoKxDURsZrEVDzCRtVIu/HE756M1Rt7PPpdZ7c=" 68 89 [mod."github.com/aws/smithy-go"] 69 - version = "v1.24.0" 70 - hash = "sha256-ZPFhf2Yv3BQpUn3cN4wSnoO7uBki8oCisZxL6F09nnE=" 90 + version = "v1.24.2" 91 + hash = "sha256-v0y+Lir61fgdCwdVoca5mK+FcGh9OD3cTEwHIfLytcI=" 71 92 [mod."github.com/aymanbagabas/go-osc52/v2"] 72 93 version = "v2.0.1" 73 94 hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" ··· 139 160 hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 140 161 replaced = "tangled.sh/oppi.li/go-gitdiff" 141 162 [mod."github.com/bluesky-social/indigo"] 142 - version = "v0.0.0-20260315101958-fb1dfa36fed2" 143 - hash = "sha256-R5Dmcsi1a5LquA/a30YyjLAh7Mjg17EuTNVCDxyw4JE=" 144 - replaced = "github.com/boltlessengineer/indigo" 163 + version = "v0.0.0-20251003000214-3259b215110e" 164 + hash = "sha256-qi/GrquJznbLnnHVpd7IqoryCESbi6xE4X1SiEM2qlo=" 145 165 [mod."github.com/bluesky-social/jetstream"] 146 - version = "v0.0.0-20260226214936-e0274250f654" 147 - hash = "sha256-VE93NvI3PreteLHnlv7WT6GgH2vSjtoFjMygCmrznfg=" 166 + version = "v0.0.0-20241210005130-ea96859b93d1" 167 + hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 148 168 [mod."github.com/bmatcuk/doublestar/v4"] 149 169 version = "v4.9.1" 150 170 hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE=" ··· 226 246 [mod."github.com/dustin/go-humanize"] 227 247 version = "v1.0.1" 228 248 hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 229 - [mod."github.com/earthboundkid/versioninfo/v2"] 230 - version = "v2.24.1" 231 - hash = "sha256-nbRdiX9WN2y1aiw1CR/DQ6AYqztow8FazndwY3kByHM=" 232 249 [mod."github.com/emirpasic/gods"] 233 250 version = "v1.18.1" 234 251 hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" ··· 398 415 [mod."github.com/ipfs/go-metrics-interface"] 399 416 version = "v0.3.0" 400 417 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 418 [mod."github.com/json-iterator/go"] 414 419 version = "v1.1.12" 415 420 hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" ··· 519 524 version = "v1.5.5" 520 525 hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8=" 521 526 [mod."github.com/prometheus/client_golang"] 522 - version = "v1.23.2" 523 - hash = "sha256-3GD4fBFa1tJu8MS4TNP6r2re2eViUE+kWUaieIOQXCg=" 527 + version = "v1.22.0" 528 + hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ=" 524 529 [mod."github.com/prometheus/client_model"] 525 530 version = "v0.6.2" 526 531 hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 527 532 [mod."github.com/prometheus/common"] 528 - version = "v0.66.1" 529 - hash = "sha256-bqHPaV9IV70itx63wqwgy2PtxMN0sn5ThVxDmiD7+Tk=" 533 + version = "v0.64.0" 534 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 530 535 [mod."github.com/prometheus/procfs"] 531 536 version = "v0.16.1" 532 537 hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" ··· 559 564 version = "v0.0.0-20220730225603-2ab79fcdd4ef" 560 565 hash = "sha256-/XmSE/J+f6FLWXGvljh6uBK71uoCAK3h82XQEQ1Ki68=" 561 566 [mod."github.com/stretchr/testify"] 562 - version = "v1.11.1" 563 - hash = "sha256-sWfjkuKJyDllDEtnM8sb/pdLzPQmUYWYtmeWz/5suUc=" 567 + version = "v1.10.0" 568 + hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 564 569 [mod."github.com/tidwall/gjson"] 565 570 version = "v1.18.0" 566 571 hash = "sha256-CO6hqDu8Y58Po6A01e5iTpwiUBQ5khUZsw7czaJHw0I=" ··· 574 579 version = "v1.2.5" 575 580 hash = "sha256-OYGNolkmL7E1Qs2qrQ3IVpQp5gkcHNU/AB/z2O+Myps=" 576 581 [mod."github.com/urfave/cli/v3"] 577 - version = "v3.4.1" 578 - hash = "sha256-cDMaQrIVMthUhdyI1mKXzDC5/wIK151073lzRl92RnA=" 582 + version = "v3.3.3" 583 + hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" 579 584 [mod."github.com/vmihailenco/go-tinylfu"] 580 585 version = "v0.2.2" 581 586 hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM=" ··· 645 650 [mod."go.uber.org/zap"] 646 651 version = "v1.27.0" 647 652 hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 648 - [mod."go.yaml.in/yaml/v2"] 649 - version = "v2.4.2" 650 - hash = "sha256-oC8RWdf1zbMYCtmR0ATy/kCkhIwPR9UqFZSMOKLVF/A=" 651 653 [mod."golang.org/x/crypto"] 652 - version = "v0.41.0" 653 - hash = "sha256-o5Di0lsFmYnXl7a5MBTqmN9vXMCRpE9ay71C1Ar8jEY=" 654 + version = "v0.40.0" 655 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 654 656 [mod."golang.org/x/exp"] 655 657 version = "v0.0.0-20250620022241-b7579e27df2b" 656 658 hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" ··· 658 660 version = "v0.31.0" 659 661 hash = "sha256-ZFTlu9+4QToPPLA8C5UcG2eq/lQylq81RoG/WtYo9rg=" 660 662 [mod."golang.org/x/net"] 661 - version = "v0.43.0" 662 - hash = "sha256-bf3iQFrsC8BoarVaS0uSspEFAcr1zHp1uziTtBpwV34=" 663 + version = "v0.42.0" 664 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 663 665 [mod."golang.org/x/sync"] 664 666 version = "v0.17.0" 665 667 hash = "sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=" 666 668 [mod."golang.org/x/sys"] 667 - version = "v0.35.0" 668 - hash = "sha256-ZKM8pesQE6NAFZeKQ84oPn5JMhGr8g4TSwLYAsHMGSI=" 669 + version = "v0.34.0" 670 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 669 671 [mod."golang.org/x/text"] 670 672 version = "v0.29.0" 671 673 hash = "sha256-2cWBtJje+Yc+AnSgCANqBlIwnOMZEGkpQ2cFI45VfLI=" ··· 685 687 version = "v1.73.0" 686 688 hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 687 689 [mod."google.golang.org/protobuf"] 688 - version = "v1.36.8" 689 - hash = "sha256-yZN8ZON0b5HjUNUSubHst7zbvnMsOzd81tDPYQRtPgM=" 690 + version = "v1.36.6" 691 + hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" 690 692 [mod."gopkg.in/fsnotify.v1"] 691 693 version = "v1.4.7" 692 694 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 - }
+16
nix/modules/spindle.nix
··· 109 109 default = "5m"; 110 110 description = "Timeout for each step of a pipeline"; 111 111 }; 112 + 113 + logBucket = mkOption { 114 + type = types.str; 115 + default = "tangled-logs"; 116 + description = "S3 bucket for workflow logs"; 117 + }; 118 + 119 + uploadWorkflowLogs = mkOption { 120 + type = types.bool; 121 + default = false; 122 + description = "Enables uploading workflow logs to s3"; 123 + }; 112 124 }; 113 125 }; 114 126 }; ··· 138 150 "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 139 151 "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 140 152 "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 153 + "SPINDLE_NIXERY_PIPELINES_LOG_BUCKET=${cfg.pipelines.logBucket}" 154 + "AWS_ACCESS_KEY_ID=${builtins.getEnv "AWS_ACCESS_KEY_ID"}" 155 + "AWS_SECRET_ACCESS_KEY=${builtins.getEnv "AWS_SECRET_ACCESS_KEY"}" 156 + "AWS_REGION=${builtins.getEnv "AWS_REGION"}" 141 157 ]; 142 158 ExecStart = "${cfg.package}/bin/spindle"; 143 159 Restart = "always";
+1
nix/pkgs/appview-static-files.nix
··· 7 7 ibm-plex-mono-src, 8 8 actor-typeahead-src, 9 9 mermaid-src, 10 + sqlite-lib, 10 11 tailwindcss, 11 12 dolly, 12 13 src,
-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 - }
+5
spindle/config/config.go
··· 41 41 WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 42 42 } 43 43 44 + type S3 struct { 45 + LogBucket string `env:"LOG_BUCKET"` 46 + } 47 + 44 48 type Config struct { 45 49 Server Server `env:",prefix=SPINDLE_SERVER_"` 46 50 NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 51 + S3 S3 `env:",prefix=SPINDLE_S3_"` 47 52 } 48 53 49 54 func Load(ctx context.Context) (*Config, error) {
+27
spindle/engine/engine.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 + "fmt" 6 7 "log/slog" 8 + "path/filepath" 7 9 "sync" 8 10 9 11 securejoin "github.com/cyphar/filepath-securejoin" ··· 49 51 PipelineId: pipelineId, 50 52 Name: w.Name, 51 53 } 54 + 55 + defer func() { 56 + logBucket := cfg.S3.LogBucket 57 + 58 + if logBucket != "" { 59 + logFile := filepath.Join(cfg.Server.LogDir, fmt.Sprintf("%s.log", wid.String())) 60 + if err := uploadWorkflowLogs(ctx, logFile, logBucket); err != nil { 61 + l.Error("error uploading logs", "err", err) 62 + } 63 + } 64 + }() 52 65 53 66 wfLogger, err := models.NewFileWorkflowLogger(cfg.Server.LogDir, wid, secretValues) 54 67 if err != nil { ··· 131 144 wg.Wait() 132 145 l.Info("all workflows completed") 133 146 } 147 + 148 + func uploadWorkflowLogs(ctx context.Context, logfile, bucket string) error { 149 + s3, err := NewS3(bucket) 150 + if err != nil { 151 + return fmt.Errorf("error creating s3 client: %w", err) 152 + } 153 + 154 + name := filepath.Join(logfile) 155 + if err := s3.WriteFile(ctx, name); err != nil { 156 + return fmt.Errorf("error saving logs: %w", err) 157 + } 158 + 159 + return nil 160 + }
+71
spindle/engine/s3.go
··· 1 + package engine 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + 10 + "github.com/aws/aws-sdk-go-v2/aws" 11 + "github.com/aws/aws-sdk-go-v2/config" 12 + "github.com/aws/aws-sdk-go-v2/service/s3" 13 + ) 14 + 15 + type S3 struct { 16 + bucket string 17 + client *s3.Client 18 + } 19 + 20 + const BASE_S3_PATH = "spindle/workflows" 21 + 22 + func NewS3(bucket string) (*S3, error) { 23 + ctx := context.Background() 24 + sdkConfig, err := config.LoadDefaultConfig(ctx) 25 + 26 + if err != nil { 27 + return nil, fmt.Errorf("error loading s3 config: %w", err) 28 + } 29 + s3Client := s3.NewFromConfig(sdkConfig) 30 + 31 + return &S3{ 32 + bucket: bucket, 33 + client: s3Client, 34 + }, nil 35 + } 36 + 37 + func (s *S3) WriteFile(ctx context.Context, path string) error { 38 + s3_key := fmt.Sprintf("%s/%s", BASE_S3_PATH, filepath.Base(path)) 39 + 40 + file, err := os.Open(path) 41 + if err != nil { 42 + return fmt.Errorf("error opening file %s: %w", path, err) 43 + } 44 + defer file.Close() 45 + 46 + _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ 47 + Bucket: &s.bucket, 48 + Key: &s3_key, 49 + Body: file, 50 + }) 51 + 52 + if err != nil { 53 + return fmt.Errorf("error writing to s3: %w", err) 54 + } 55 + 56 + return nil 57 + } 58 + 59 + func (s *S3) ReadFile(ctx context.Context, name string) ([]byte, error) { 60 + res, err := s.client.GetObject(ctx, &s3.GetObjectInput{ 61 + Bucket: &s.bucket, 62 + Key: aws.String(name), 63 + }) 64 + 65 + if err != nil { 66 + return nil, fmt.Errorf("error reading file %s: %w", name, err) 67 + } 68 + defer res.Body.Close() 69 + 70 + return io.ReadAll(res.Body) 71 + }
+70 -83
tailwind.config.js
··· 2 2 const colors = require("tailwindcss/colors"); 3 3 4 4 module.exports = { 5 - content: [ 6 - "./appview/pages/templates/**/*.html", 7 - "./appview/pages/chroma.go", 8 - "./docs/*.html", 9 - "./blog/templates/**/*.html", 10 - "./blog/posts/**/*.md", 11 - ], 12 - darkMode: "media", 13 - theme: { 14 - container: { 15 - padding: "2rem", 16 - center: true, 17 - screens: { 18 - sm: "500px", 19 - md: "600px", 20 - lg: "800px", 21 - xl: "1000px", 22 - "2xl": "1200px", 23 - }, 24 - }, 25 - extend: { 26 - fontFamily: { 27 - sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 28 - mono: [ 29 - "IBMPlexMono", 30 - "ui-monospace", 31 - "SFMono-Regular", 32 - "Menlo", 33 - "Monaco", 34 - "Consolas", 35 - "Liberation Mono", 36 - "Courier New", 37 - "monospace", 38 - ], 39 - }, 40 - typography: { 41 - DEFAULT: { 42 - css: { 43 - maxWidth: "none", 44 - pre: { 45 - "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": 46 - {}, 47 - }, 48 - code: { 49 - "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": 50 - {}, 51 - }, 52 - "code::before": { 53 - content: '""', 54 - }, 55 - "code::after": { 56 - content: '""', 57 - }, 58 - blockquote: { 59 - quotes: "none", 60 - }, 61 - "h1, h2, h3, h4": { 62 - "@apply mt-4 mb-2": {}, 63 - }, 64 - h1: { 65 - "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": 66 - {}, 67 - }, 68 - h2: { 69 - "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": 70 - {}, 71 - }, 72 - h3: { 73 - "@apply mt-2": {}, 74 - }, 75 - img: { 76 - "@apply rounded border border-gray-200 dark:border-gray-700": {}, 77 - }, 78 - }, 79 - }, 80 - }, 81 - gridTemplateColumns: { 82 - 14: "repeat(14, minmax(0, 1fr))", 83 - 28: "repeat(28, minmax(0, 1fr))", 84 - }, 85 - }, 86 - }, 87 - plugins: [require("@tailwindcss/typography")], 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go", "./docs/*.html"], 6 + darkMode: "media", 7 + theme: { 8 + container: { 9 + padding: "2rem", 10 + center: true, 11 + screens: { 12 + sm: "500px", 13 + md: "600px", 14 + lg: "800px", 15 + xl: "1000px", 16 + "2xl": "1200px", 17 + }, 18 + }, 19 + extend: { 20 + fontFamily: { 21 + sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 22 + mono: [ 23 + "IBMPlexMono", 24 + "ui-monospace", 25 + "SFMono-Regular", 26 + "Menlo", 27 + "Monaco", 28 + "Consolas", 29 + "Liberation Mono", 30 + "Courier New", 31 + "monospace", 32 + ], 33 + }, 34 + typography: { 35 + DEFAULT: { 36 + css: { 37 + maxWidth: "none", 38 + pre: { 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 40 + }, 41 + code: { 42 + "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {}, 43 + }, 44 + "code::before": { 45 + content: '""', 46 + }, 47 + "code::after": { 48 + content: '""', 49 + }, 50 + blockquote: { 51 + quotes: "none", 52 + }, 53 + 'h1, h2, h3, h4': { 54 + "@apply mt-4 mb-2": {} 55 + }, 56 + h1: { 57 + "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {} 58 + }, 59 + h2: { 60 + "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {} 61 + }, 62 + h3: { 63 + "@apply mt-2": {} 64 + }, 65 + }, 66 + }, 67 + }, 68 + gridTemplateColumns: { 69 + '14': 'repeat(14, minmax(0, 1fr))', 70 + '28': 'repeat(28, minmax(0, 1fr))', 71 + } 72 + }, 73 + }, 74 + plugins: [require("@tailwindcss/typography")], 88 75 };
-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

2 rounds 3 comments
sign up or login to add to the discussion
2 commits
expand
spindle/engine: store workflow logs in s3
spindle/engine: move s3 implementation
expand 0 comments
closed without merging
1 commit
expand
spindle/engine: store workflow logs in s3
expand 3 comments

nice! couple of comments from my end (note that i have not yet tested this out myself locally):

spindle/config/config.go:42-43: i think the config option should be its own struct, and not in NixeryPipelines. we can have type S3 struct { .. } with just LogBucket as the only option. if LogBucket is the empty string, we can consider s3 to be disabled, and if not, we can consider it to be enabled.

  • spindle/engines/nixery/engine.go:290: spindle is structured to have multiple engines, of which nixery is one engine (and the only one presently). uploading logs here would mean future engines will have to duplicate this step. this logic is better implemented in engine/engine.go

  • spindle/utils/s3.go:1: i personally try to avoid a utils package. this could live at the top level IMO: spindle/s3.go.

uploading logs here would mean future engines will have to duplicate this step. this logic is better implemented in engine/engine.go

Do you mean I should have something like this in nixery instead? Note, code below is pseudocode.

if uploadLogs {
engine.UploadLogs()
}

nvm, I see what you mean.