Monorepo for Tangled tangled.org

knotserver/xrpc: port all handlers to xrpc queries

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

authored by anirudh.fi and committed by oppi.li f21c584e 506d4e52

+27 -1
appview/repo/repo.go
··· 685 return 686 } 687 688 - if strings.Contains(contentType, "text/plain") { 689 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 690 w.Write(body) 691 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 692 w.Header().Set("Content-Type", contentType) 693 w.Write(body) 694 } else { 695 w.WriteHeader(http.StatusUnsupportedMediaType) 696 w.Write([]byte("unsupported content type")) 697 return 698 } 699 } 700 701 // modify the spindle configured for this repo
··· 685 return 686 } 687 688 + // Safely serve content based on type 689 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 690 + // Serve all textual content as text/plain for security 691 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 692 w.Write(body) 693 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 694 + // Serve images and videos with their original content type 695 w.Header().Set("Content-Type", contentType) 696 w.Write(body) 697 } else { 698 + // Block potentially dangerous content types 699 w.WriteHeader(http.StatusUnsupportedMediaType) 700 w.Write([]byte("unsupported content type")) 701 return 702 } 703 + } 704 + 705 + // isTextualMimeType returns true if the MIME type represents textual content 706 + // that should be served as text/plain 707 + func isTextualMimeType(mimeType string) bool { 708 + textualTypes := []string{ 709 + "application/json", 710 + "application/xml", 711 + "application/yaml", 712 + "application/x-yaml", 713 + "application/toml", 714 + "application/javascript", 715 + "application/ecmascript", 716 + "message/", 717 + } 718 + 719 + for _, t := range textualTypes { 720 + if mimeType == t { 721 + return true 722 + } 723 + } 724 + return false 725 } 726 727 // modify the spindle configured for this repo
+40
knotserver/db/pubkeys.go
··· 1 package db 2 3 import ( 4 "time" 5 6 "tangled.sh/tangled.sh/core/api/tangled" ··· 99 100 return keys, nil 101 }
··· 1 package db 2 3 import ( 4 + "strconv" 5 "time" 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 100 101 return keys, nil 102 } 103 + 104 + func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) { 105 + var keys []PublicKey 106 + 107 + offset := 0 108 + if cursor != "" { 109 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 110 + offset = o 111 + } 112 + } 113 + 114 + query := `select key, did, created from public_keys order by created desc limit ? offset ?` 115 + rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results 116 + if err != nil { 117 + return nil, "", err 118 + } 119 + defer rows.Close() 120 + 121 + for rows.Next() { 122 + var publicKey PublicKey 123 + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { 124 + return nil, "", err 125 + } 126 + keys = append(keys, publicKey) 127 + } 128 + 129 + if err := rows.Err(); err != nil { 130 + return nil, "", err 131 + } 132 + 133 + // check if there are more results for pagination 134 + var nextCursor string 135 + if len(keys) > limit { 136 + keys = keys[:limit] // remove the extra item 137 + nextCursor = strconv.Itoa(offset + limit) 138 + } 139 + 140 + return keys, nextCursor, nil 141 + }
+5
knotserver/ingester.go
··· 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 100 l = l.With("did", did) 101 l = l.With("target_repo", record.Target.Repo) 102 l = l.With("target_branch", record.Target.Branch) 103
··· 98 l := log.FromContext(ctx) 99 l = l.With("handler", "processPull") 100 l = l.With("did", did) 101 + 102 + if record.Target == nil { 103 + return fmt.Errorf("ignoring pull record: target repo is nil") 104 + } 105 + 106 l = l.With("target_repo", record.Target.Repo) 107 l = l.With("target_branch", record.Target.Branch) 108
+58
knotserver/xrpc/list_keys.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 13 + cursor := r.URL.Query().Get("cursor") 14 + 15 + limit := 100 // default 16 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 17 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 18 + limit = l 19 + } 20 + } 21 + 22 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 23 + if err != nil { 24 + x.Logger.Error("failed to get public keys", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to retrieve public keys"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 33 + for _, key := range keys { 34 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 35 + Did: key.Did, 36 + Key: key.Key, 37 + CreatedAt: key.CreatedAt, 38 + }) 39 + } 40 + 41 + response := tangled.KnotListKeys_Output{ 42 + Keys: publicKeys, 43 + } 44 + 45 + if nextCursor != "" { 46 + response.Cursor = &nextCursor 47 + } 48 + 49 + w.Header().Set("Content-Type", "application/json") 50 + if err := json.NewEncoder(w).Encode(response); err != nil { 51 + x.Logger.Error("failed to encode response", "error", err) 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("InternalServerError"), 54 + xrpcerr.WithMessage("failed to encode response"), 55 + ), http.StatusInternalServerError) 56 + return 57 + } 58 + }
+80
knotserver/xrpc/repo_archive.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 16 + repo, repoPath, unescapedRef, err := x.parseStandardParams(r) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + format := r.URL.Query().Get("format") 23 + if format == "" { 24 + format = "tar.gz" // default 25 + } 26 + 27 + prefix := r.URL.Query().Get("prefix") 28 + 29 + if format != "tar.gz" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("only tar.gz format is supported"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + gr, err := git.Open(repoPath, unescapedRef) 38 + if err != nil { 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("RefNotFound"), 41 + xrpcerr.WithMessage("repository or ref not found"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + repoParts := strings.Split(repo, "/") 47 + repoName := repoParts[len(repoParts)-1] 48 + 49 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") 50 + 51 + var archivePrefix string 52 + if prefix != "" { 53 + archivePrefix = prefix 54 + } else { 55 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 56 + } 57 + 58 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 59 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 60 + w.Header().Set("Content-Type", "application/gzip") 61 + 62 + gw := gzip.NewWriter(w) 63 + defer gw.Close() 64 + 65 + err = gr.WriteTar(gw, archivePrefix) 66 + if err != nil { 67 + // once we start writing to the body we can't report error anymore 68 + // so we are only left with logging the error 69 + x.Logger.Error("writing tar file", "error", err.Error()) 70 + return 71 + } 72 + 73 + err = gw.Flush() 74 + if err != nil { 75 + // once we start writing to the body we can't report error anymore 76 + // so we are only left with logging the error 77 + x.Logger.Error("flushing", "error", err.Error()) 78 + return 79 + } 80 + }
+150
knotserver/xrpc/repo_blob.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "encoding/json" 7 + "fmt" 8 + "net/http" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/knotserver/git" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 19 + _, repoPath, ref, err := x.parseStandardParams(r) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + treePath := r.URL.Query().Get("path") 26 + if treePath == "" { 27 + writeError(w, xrpcerr.NewXrpcError( 28 + xrpcerr.WithTag("InvalidRequest"), 29 + xrpcerr.WithMessage("missing path parameter"), 30 + ), http.StatusBadRequest) 31 + return 32 + } 33 + 34 + raw := r.URL.Query().Get("raw") == "true" 35 + 36 + gr, err := git.Open(repoPath, ref) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("RefNotFound"), 40 + xrpcerr.WithMessage("repository or ref not found"), 41 + ), http.StatusNotFound) 42 + return 43 + } 44 + 45 + contents, err := gr.RawContent(treePath) 46 + if err != nil { 47 + x.Logger.Error("file content", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("FileNotFound"), 50 + xrpcerr.WithMessage("file not found at the specified path"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + mimeType := http.DetectContentType(contents) 56 + 57 + if filepath.Ext(treePath) == ".svg" { 58 + mimeType = "image/svg+xml" 59 + } 60 + 61 + if raw { 62 + contentHash := sha256.Sum256(contents) 63 + eTag := fmt.Sprintf("\"%x\"", contentHash) 64 + 65 + switch { 66 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 67 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 68 + w.WriteHeader(http.StatusNotModified) 69 + return 70 + } 71 + w.Header().Set("ETag", eTag) 72 + 73 + case strings.HasPrefix(mimeType, "text/"): 74 + w.Header().Set("Cache-Control", "public, no-cache") 75 + // serve all text content as text/plain 76 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 77 + 78 + case isTextualMimeType(mimeType): 79 + // handle textual application types (json, xml, etc.) as text/plain 80 + w.Header().Set("Cache-Control", "public, no-cache") 81 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 82 + 83 + default: 84 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 85 + writeError(w, xrpcerr.NewXrpcError( 86 + xrpcerr.WithTag("InvalidRequest"), 87 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 88 + ), http.StatusForbidden) 89 + return 90 + } 91 + w.Write(contents) 92 + return 93 + } 94 + 95 + isTextual := func(mt string) bool { 96 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 97 + } 98 + 99 + var content string 100 + var encoding string 101 + 102 + isBinary := !isTextual(mimeType) 103 + 104 + if isBinary { 105 + content = base64.StdEncoding.EncodeToString(contents) 106 + encoding = "base64" 107 + } else { 108 + content = string(contents) 109 + encoding = "utf-8" 110 + } 111 + 112 + response := tangled.RepoBlob_Output{ 113 + Ref: ref, 114 + Path: treePath, 115 + Content: content, 116 + Encoding: &encoding, 117 + Size: &[]int64{int64(len(contents))}[0], 118 + IsBinary: &isBinary, 119 + } 120 + 121 + if mimeType != "" { 122 + response.MimeType = &mimeType 123 + } 124 + 125 + w.Header().Set("Content-Type", "application/json") 126 + if err := json.NewEncoder(w).Encode(response); err != nil { 127 + x.Logger.Error("failed to encode response", "error", err) 128 + writeError(w, xrpcerr.NewXrpcError( 129 + xrpcerr.WithTag("InternalServerError"), 130 + xrpcerr.WithMessage("failed to encode response"), 131 + ), http.StatusInternalServerError) 132 + return 133 + } 134 + } 135 + 136 + // isTextualMimeType returns true if the MIME type represents textual content 137 + // that should be served as text/plain for security reasons 138 + func isTextualMimeType(mimeType string) bool { 139 + textualTypes := []string{ 140 + "application/json", 141 + "application/xml", 142 + "application/yaml", 143 + "application/x-yaml", 144 + "application/toml", 145 + "application/javascript", 146 + "application/ecmascript", 147 + } 148 + 149 + return slices.Contains(textualTypes, mimeType) 150 + }
+96
knotserver/xrpc/repo_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RepoNotFound"), 36 + xrpcerr.WithMessage("repository not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + ref, err := gr.Branch(branchName) 42 + if err != nil { 43 + x.Logger.Error("getting branch", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("BranchNotFound"), 46 + xrpcerr.WithMessage("branch not found"), 47 + ), http.StatusNotFound) 48 + return 49 + } 50 + 51 + commit, err := gr.Commit(ref.Hash()) 52 + if err != nil { 53 + x.Logger.Error("getting commit object", "error", err.Error()) 54 + writeError(w, xrpcerr.NewXrpcError( 55 + xrpcerr.WithTag("BranchNotFound"), 56 + xrpcerr.WithMessage("failed to get commit object"), 57 + ), http.StatusInternalServerError) 58 + return 59 + } 60 + 61 + defaultBranch, err := gr.FindMainBranch() 62 + isDefault := false 63 + if err != nil { 64 + x.Logger.Error("getting default branch", "error", err.Error()) 65 + } else if defaultBranch == branchName { 66 + isDefault = true 67 + } 68 + 69 + response := tangled.RepoBranch_Output{ 70 + Name: ref.Name().Short(), 71 + Hash: ref.Hash().String(), 72 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 73 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 74 + IsDefault: &isDefault, 75 + } 76 + 77 + if commit.Message != "" { 78 + response.Message = &commit.Message 79 + } 80 + 81 + response.Author = &tangled.RepoBranch_Signature{ 82 + Name: commit.Author.Name, 83 + Email: commit.Author.Email, 84 + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), 85 + } 86 + 87 + w.Header().Set("Content-Type", "application/json") 88 + if err := json.NewEncoder(w).Encode(response); err != nil { 89 + x.Logger.Error("failed to encode response", "error", err) 90 + writeError(w, xrpcerr.NewXrpcError( 91 + xrpcerr.WithTag("InternalServerError"), 92 + xrpcerr.WithMessage("failed to encode response"), 93 + ), http.StatusInternalServerError) 94 + return 95 + } 96 + }
+70
knotserver/xrpc/repo_branches.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + cursor := r.URL.Query().Get("cursor") 22 + 23 + limit := 50 // default 24 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 25 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 26 + limit = l 27 + } 28 + } 29 + 30 + gr, err := git.PlainOpen(repoPath) 31 + if err != nil { 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("RepoNotFound"), 34 + xrpcerr.WithMessage("repository not found"), 35 + ), http.StatusNotFound) 36 + return 37 + } 38 + 39 + branches, _ := gr.Branches() 40 + 41 + offset := 0 42 + if cursor != "" { 43 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 44 + offset = o 45 + } 46 + } 47 + 48 + end := offset + limit 49 + if end > len(branches) { 50 + end = len(branches) 51 + } 52 + 53 + paginatedBranches := branches[offset:end] 54 + 55 + // Create response using existing types.RepoBranchesResponse 56 + response := types.RepoBranchesResponse{ 57 + Branches: paginatedBranches, 58 + } 59 + 60 + // Write JSON response directly 61 + w.Header().Set("Content-Type", "application/json") 62 + if err := json.NewEncoder(w).Encode(response); err != nil { 63 + x.Logger.Error("failed to encode response", "error", err) 64 + writeError(w, xrpcerr.NewXrpcError( 65 + xrpcerr.WithTag("InternalServerError"), 66 + xrpcerr.WithMessage("failed to encode response"), 67 + ), http.StatusInternalServerError) 68 + return 69 + } 70 + }
+98
knotserver/xrpc/repo_compare.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + rev1Param := r.URL.Query().Get("rev1") 23 + if rev1Param == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing rev1 parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + rev2Param := r.URL.Query().Get("rev2") 32 + if rev2Param == "" { 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("InvalidRequest"), 35 + xrpcerr.WithMessage("missing rev2 parameter"), 36 + ), http.StatusBadRequest) 37 + return 38 + } 39 + 40 + rev1, _ := url.PathUnescape(rev1Param) 41 + rev2, _ := url.PathUnescape(rev2Param) 42 + 43 + gr, err := git.PlainOpen(repoPath) 44 + if err != nil { 45 + writeError(w, xrpcerr.NewXrpcError( 46 + xrpcerr.WithTag("RepoNotFound"), 47 + xrpcerr.WithMessage("repository not found"), 48 + ), http.StatusNotFound) 49 + return 50 + } 51 + 52 + commit1, err := gr.ResolveRevision(rev1) 53 + if err != nil { 54 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 55 + writeError(w, xrpcerr.NewXrpcError( 56 + xrpcerr.WithTag("RevisionNotFound"), 57 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 58 + ), http.StatusBadRequest) 59 + return 60 + } 61 + 62 + commit2, err := gr.ResolveRevision(rev2) 63 + if err != nil { 64 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 65 + writeError(w, xrpcerr.NewXrpcError( 66 + xrpcerr.WithTag("RevisionNotFound"), 67 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 68 + ), http.StatusBadRequest) 69 + return 70 + } 71 + 72 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 73 + if err != nil { 74 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 75 + writeError(w, xrpcerr.NewXrpcError( 76 + xrpcerr.WithTag("CompareError"), 77 + xrpcerr.WithMessage("error comparing revisions"), 78 + ), http.StatusBadRequest) 79 + return 80 + } 81 + 82 + resp := types.RepoFormatPatchResponse{ 83 + Rev1: commit1.Hash.String(), 84 + Rev2: commit2.Hash.String(), 85 + FormatPatch: formatPatch, 86 + Patch: rawPatch, 87 + } 88 + 89 + w.Header().Set("Content-Type", "application/json") 90 + if err := json.NewEncoder(w).Encode(resp); err != nil { 91 + x.Logger.Error("failed to encode response", "error", err) 92 + writeError(w, xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InternalServerError"), 94 + xrpcerr.WithMessage("failed to encode response"), 95 + ), http.StatusInternalServerError) 96 + return 97 + } 98 + }
+65
knotserver/xrpc/repo_diff.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + "tangled.sh/tangled.sh/core/types" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + refParam := r.URL.Query().Get("ref") 22 + if refParam == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing ref parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + ref, _ := url.QueryUnescape(refParam) 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("RefNotFound"), 36 + xrpcerr.WithMessage("repository or ref not found"), 37 + ), http.StatusNotFound) 38 + return 39 + } 40 + 41 + diff, err := gr.Diff() 42 + if err != nil { 43 + x.Logger.Error("getting diff", "error", err.Error()) 44 + writeError(w, xrpcerr.NewXrpcError( 45 + xrpcerr.WithTag("RefNotFound"), 46 + xrpcerr.WithMessage("failed to generate diff"), 47 + ), http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + resp := types.RepoCommitResponse{ 52 + Ref: ref, 53 + Diff: diff, 54 + } 55 + 56 + w.Header().Set("Content-Type", "application/json") 57 + if err := json.NewEncoder(w).Encode(resp); err != nil { 58 + x.Logger.Error("failed to encode response", "error", err) 59 + writeError(w, xrpcerr.NewXrpcError( 60 + xrpcerr.WithTag("InternalServerError"), 61 + xrpcerr.WithMessage("failed to encode response"), 62 + ), http.StatusInternalServerError) 63 + return 64 + } 65 + }
+54
knotserver/xrpc/repo_get_default_branch.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.Open(repoPath, "") 21 + if err != nil { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("RepoNotFound"), 24 + xrpcerr.WithMessage("repository not found"), 25 + ), http.StatusNotFound) 26 + return 27 + } 28 + 29 + branch, err := gr.FindMainBranch() 30 + if err != nil { 31 + x.Logger.Error("getting default branch", "error", err.Error()) 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("InvalidRequest"), 34 + xrpcerr.WithMessage("failed to get default branch"), 35 + ), http.StatusInternalServerError) 36 + return 37 + } 38 + 39 + response := tangled.RepoGetDefaultBranch_Output{ 40 + Name: branch, 41 + Hash: "", 42 + When: "1970-01-01T00:00:00.000Z", 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + if err := json.NewEncoder(w).Encode(response); err != nil { 47 + x.Logger.Error("failed to encode response", "error", err) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("InternalServerError"), 50 + xrpcerr.WithMessage("failed to encode response"), 51 + ), http.StatusInternalServerError) 52 + return 53 + } 54 + }
+93
knotserver/xrpc/repo_languages.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "math" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 17 + refParam := r.URL.Query().Get("ref") 18 + if refParam == "" { 19 + refParam = "HEAD" // default 20 + } 21 + ref, _ := url.PathUnescape(refParam) 22 + 23 + repo := r.URL.Query().Get("repo") 24 + repoPath, err := x.parseRepoParam(repo) 25 + if err != nil { 26 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + gr, err := git.Open(repoPath, ref) 31 + if err != nil { 32 + x.Logger.Error("opening repo", "error", err.Error()) 33 + writeError(w, xrpcerr.NewXrpcError( 34 + xrpcerr.WithTag("RefNotFound"), 35 + xrpcerr.WithMessage("repository or ref not found"), 36 + ), http.StatusNotFound) 37 + return 38 + } 39 + 40 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 41 + defer cancel() 42 + 43 + sizes, err := gr.AnalyzeLanguages(ctx) 44 + if err != nil { 45 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 46 + writeError(w, xrpcerr.NewXrpcError( 47 + xrpcerr.WithTag("InvalidRequest"), 48 + xrpcerr.WithMessage("failed to analyze repository languages"), 49 + ), http.StatusNoContent) 50 + return 51 + } 52 + 53 + var apiLanguages []*tangled.RepoLanguages_Language 54 + var totalSize int64 55 + 56 + for _, size := range sizes { 57 + totalSize += size 58 + } 59 + 60 + for name, size := range sizes { 61 + percentagef64 := float64(size) / float64(totalSize) * 100 62 + percentage := math.Round(percentagef64) 63 + 64 + lang := &tangled.RepoLanguages_Language{ 65 + Name: name, 66 + Size: size, 67 + Percentage: int64(percentage), 68 + } 69 + 70 + apiLanguages = append(apiLanguages, lang) 71 + } 72 + 73 + response := tangled.RepoLanguages_Output{ 74 + Ref: ref, 75 + Languages: apiLanguages, 76 + } 77 + 78 + if totalSize > 0 { 79 + response.TotalSize = &totalSize 80 + totalFiles := int64(len(sizes)) 81 + response.TotalFiles = &totalFiles 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + if err := json.NewEncoder(w).Encode(response); err != nil { 86 + x.Logger.Error("failed to encode response", "error", err) 87 + writeError(w, xrpcerr.NewXrpcError( 88 + xrpcerr.WithTag("InternalServerError"), 89 + xrpcerr.WithMessage("failed to encode response"), 90 + ), http.StatusInternalServerError) 91 + return 92 + } 93 + }
+101
knotserver/xrpc/repo_log.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "strconv" 8 + 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + "tangled.sh/tangled.sh/core/types" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + refParam := r.URL.Query().Get("ref") 23 + if refParam == "" { 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InvalidRequest"), 26 + xrpcerr.WithMessage("missing ref parameter"), 27 + ), http.StatusBadRequest) 28 + return 29 + } 30 + 31 + path := r.URL.Query().Get("path") 32 + cursor := r.URL.Query().Get("cursor") 33 + 34 + limit := 50 // default 35 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 36 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 37 + limit = l 38 + } 39 + } 40 + 41 + ref, err := url.QueryUnescape(refParam) 42 + if err != nil { 43 + writeError(w, xrpcerr.NewXrpcError( 44 + xrpcerr.WithTag("InvalidRequest"), 45 + xrpcerr.WithMessage("invalid ref parameter"), 46 + ), http.StatusBadRequest) 47 + return 48 + } 49 + 50 + gr, err := git.Open(repoPath, ref) 51 + if err != nil { 52 + writeError(w, xrpcerr.NewXrpcError( 53 + xrpcerr.WithTag("RefNotFound"), 54 + xrpcerr.WithMessage("repository or ref not found"), 55 + ), http.StatusNotFound) 56 + return 57 + } 58 + 59 + offset := 0 60 + if cursor != "" { 61 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 62 + offset = o 63 + } 64 + } 65 + 66 + commits, err := gr.Commits(offset, limit) 67 + if err != nil { 68 + x.Logger.Error("fetching commits", "error", err.Error()) 69 + writeError(w, xrpcerr.NewXrpcError( 70 + xrpcerr.WithTag("PathNotFound"), 71 + xrpcerr.WithMessage("failed to read commit log"), 72 + ), http.StatusNotFound) 73 + return 74 + } 75 + 76 + // Create response using existing types.RepoLogResponse 77 + response := types.RepoLogResponse{ 78 + Commits: commits, 79 + Ref: ref, 80 + Page: (offset / limit) + 1, 81 + PerPage: limit, 82 + Total: len(commits), // This is not accurate for pagination, but matches existing behavior 83 + } 84 + 85 + if path != "" { 86 + response.Description = path 87 + } 88 + 89 + response.Log = true 90 + 91 + // Write JSON response directly 92 + w.Header().Set("Content-Type", "application/json") 93 + if err := json.NewEncoder(w).Encode(response); err != nil { 94 + x.Logger.Error("failed to encode response", "error", err) 95 + writeError(w, xrpcerr.NewXrpcError( 96 + xrpcerr.WithTag("InternalServerError"), 97 + xrpcerr.WithMessage("failed to encode response"), 98 + ), http.StatusInternalServerError) 99 + return 100 + } 101 + }
+99
knotserver/xrpc/repo_tags.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "github.com/go-git/go-git/v5/plumbing/object" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + "tangled.sh/tangled.sh/core/types" 13 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + cursor := r.URL.Query().Get("cursor") 25 + 26 + limit := 50 // default 27 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 28 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 29 + limit = l 30 + } 31 + } 32 + 33 + gr, err := git.Open(repoPath, "") 34 + if err != nil { 35 + x.Logger.Error("failed to open", "error", err) 36 + writeError(w, xrpcerr.NewXrpcError( 37 + xrpcerr.WithTag("RepoNotFound"), 38 + xrpcerr.WithMessage("repository not found"), 39 + ), http.StatusNotFound) 40 + return 41 + } 42 + 43 + tags, err := gr.Tags() 44 + if err != nil { 45 + x.Logger.Warn("getting tags", "error", err.Error()) 46 + tags = []object.Tag{} 47 + } 48 + 49 + rtags := []*types.TagReference{} 50 + for _, tag := range tags { 51 + var target *object.Tag 52 + if tag.Target != plumbing.ZeroHash { 53 + target = &tag 54 + } 55 + tr := types.TagReference{ 56 + Tag: target, 57 + } 58 + 59 + tr.Reference = types.Reference{ 60 + Name: tag.Name, 61 + Hash: tag.Hash.String(), 62 + } 63 + 64 + if tag.Message != "" { 65 + tr.Message = tag.Message 66 + } 67 + 68 + rtags = append(rtags, &tr) 69 + } 70 + 71 + // apply pagination manually 72 + offset := 0 73 + if cursor != "" { 74 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 75 + offset = o 76 + } 77 + } 78 + 79 + // calculate end index 80 + end := min(offset+limit, len(rtags)) 81 + 82 + paginatedTags := rtags[offset:end] 83 + 84 + // Create response using existing types.RepoTagsResponse 85 + response := types.RepoTagsResponse{ 86 + Tags: paginatedTags, 87 + } 88 + 89 + // Write JSON response directly 90 + w.Header().Set("Content-Type", "application/json") 91 + if err := json.NewEncoder(w).Encode(response); err != nil { 92 + x.Logger.Error("failed to encode response", "error", err) 93 + writeError(w, xrpcerr.NewXrpcError( 94 + xrpcerr.WithTag("InternalServerError"), 95 + xrpcerr.WithMessage("failed to encode response"), 96 + ), http.StatusInternalServerError) 97 + return 98 + } 99 + }
+116
knotserver/xrpc/repo_tree.go
···
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "net/url" 7 + "path/filepath" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 15 + ctx := r.Context() 16 + 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + refParam := r.URL.Query().Get("ref") 25 + if refParam == "" { 26 + writeError(w, xrpcerr.NewXrpcError( 27 + xrpcerr.WithTag("InvalidRequest"), 28 + xrpcerr.WithMessage("missing ref parameter"), 29 + ), http.StatusBadRequest) 30 + return 31 + } 32 + 33 + path := r.URL.Query().Get("path") 34 + // path can be empty (defaults to root) 35 + 36 + ref, err := url.QueryUnescape(refParam) 37 + if err != nil { 38 + writeError(w, xrpcerr.NewXrpcError( 39 + xrpcerr.WithTag("InvalidRequest"), 40 + xrpcerr.WithMessage("invalid ref parameter"), 41 + ), http.StatusBadRequest) 42 + return 43 + } 44 + 45 + gr, err := git.Open(repoPath, ref) 46 + if err != nil { 47 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("RefNotFound"), 50 + xrpcerr.WithMessage("repository or ref not found"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + files, err := gr.FileTree(ctx, path) 56 + if err != nil { 57 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("PathNotFound"), 60 + xrpcerr.WithMessage("failed to read repository tree"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // convert NiceTree -> tangled.RepoTree_TreeEntry 66 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 67 + for i, file := range files { 68 + entry := &tangled.RepoTree_TreeEntry{ 69 + Name: file.Name, 70 + Mode: file.Mode, 71 + Size: file.Size, 72 + Is_file: file.IsFile, 73 + Is_subtree: file.IsSubtree, 74 + } 75 + 76 + if file.LastCommit != nil { 77 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 78 + Hash: file.LastCommit.Hash.String(), 79 + Message: file.LastCommit.Message, 80 + When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"), 81 + } 82 + } 83 + 84 + treeEntries[i] = entry 85 + } 86 + 87 + var parentPtr *string 88 + if path != "" { 89 + parentPtr = &path 90 + } 91 + 92 + var dotdotPtr *string 93 + if path != "" { 94 + dotdot := filepath.Dir(path) 95 + if dotdot != "." { 96 + dotdotPtr = &dotdot 97 + } 98 + } 99 + 100 + response := tangled.RepoTree_Output{ 101 + Ref: ref, 102 + Parent: parentPtr, 103 + Dotdot: dotdotPtr, 104 + Files: treeEntries, 105 + } 106 + 107 + w.Header().Set("Content-Type", "application/json") 108 + if err := json.NewEncoder(w).Encode(response); err != nil { 109 + x.Logger.Error("failed to encode response", "error", err) 110 + writeError(w, xrpcerr.NewXrpcError( 111 + xrpcerr.WithTag("InternalServerError"), 112 + xrpcerr.WithMessage("failed to encode response"), 113 + ), http.StatusInternalServerError) 114 + return 115 + } 116 + }
+84
knotserver/xrpc/xrpc.go
··· 4 "encoding/json" 5 "log/slog" 6 "net/http" 7 8 "tangled.sh/tangled.sh/core/api/tangled" 9 "tangled.sh/tangled.sh/core/idresolver" 10 "tangled.sh/tangled.sh/core/jetstream" ··· 50 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 51 // - use ETags on clients to keep requests to a minimum 52 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 53 return r 54 } 55 56 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {
··· 4 "encoding/json" 5 "log/slog" 6 "net/http" 7 + "net/url" 8 + "strings" 9 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/idresolver" 13 "tangled.sh/tangled.sh/core/jetstream" ··· 53 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 54 // - use ETags on clients to keep requests to a minimum 55 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 56 + 57 + // repo query endpoints (no auth required) 58 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 59 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 63 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 64 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 65 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 66 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 67 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 68 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 69 + 70 + // knot query endpoints (no auth required) 71 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 72 + 73 return r 74 + } 75 + 76 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 77 + // the full repository path on disk 78 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 79 + if repo == "" { 80 + return "", xrpcerr.NewXrpcError( 81 + xrpcerr.WithTag("InvalidRequest"), 82 + xrpcerr.WithMessage("missing repo parameter"), 83 + ) 84 + } 85 + 86 + // Parse repo string (did/repoName format) 87 + parts := strings.Split(repo, "/") 88 + if len(parts) < 2 { 89 + return "", xrpcerr.NewXrpcError( 90 + xrpcerr.WithTag("InvalidRequest"), 91 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 92 + ) 93 + } 94 + 95 + did := strings.Join(parts[:len(parts)-1], "/") 96 + repoName := parts[len(parts)-1] 97 + 98 + // Construct repository path using the same logic as didPath 99 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 100 + if err != nil { 101 + return "", xrpcerr.NewXrpcError( 102 + xrpcerr.WithTag("RepoNotFound"), 103 + xrpcerr.WithMessage("failed to access repository"), 104 + ) 105 + } 106 + 107 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 108 + if err != nil { 109 + return "", xrpcerr.NewXrpcError( 110 + xrpcerr.WithTag("RepoNotFound"), 111 + xrpcerr.WithMessage("failed to access repository"), 112 + ) 113 + } 114 + 115 + return repoPath, nil 116 + } 117 + 118 + // parseStandardParams parses common query parameters used by most handlers 119 + func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) { 120 + // Parse repo parameter 121 + repo = r.URL.Query().Get("repo") 122 + repoPath, err = x.parseRepoParam(repo) 123 + if err != nil { 124 + return "", "", "", err 125 + } 126 + 127 + // Parse and unescape ref parameter 128 + refParam := r.URL.Query().Get("ref") 129 + if refParam == "" { 130 + return "", "", "", xrpcerr.NewXrpcError( 131 + xrpcerr.WithTag("InvalidRequest"), 132 + xrpcerr.WithMessage("missing ref parameter"), 133 + ) 134 + } 135 + 136 + ref, _ = url.QueryUnescape(refParam) 137 + return repo, repoPath, ref, nil 138 } 139 140 func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {