diff --git a/knotserver/ingester.go b/knotserver/ingester.go --- a/knotserver/ingester.go +++ b/knotserver/ingester.go @@ -98,6 +98,11 @@ l := log.FromContext(ctx) l = l.With("handler", "processPull") l = l.With("did", did) + + if record.Target == nil { + return fmt.Errorf("ignoring pull record: target repo is nil") + } + l = l.With("target_repo", record.Target.Repo) l = l.With("target_branch", record.Target.Branch) diff --git a/appview/repo/repo.go b/appview/repo/repo.go --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -685,17 +685,43 @@ return } - if strings.Contains(contentType, "text/plain") { + // Safely serve content based on type + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { + // Serve all textual content as text/plain for security w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(body) } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { + // Serve images and videos with their original content type w.Header().Set("Content-Type", contentType) w.Write(body) } else { + // Block potentially dangerous content types w.WriteHeader(http.StatusUnsupportedMediaType) w.Write([]byte("unsupported content type")) return } +} + +// isTextualMimeType returns true if the MIME type represents textual content +// that should be served as text/plain +func isTextualMimeType(mimeType string) bool { + textualTypes := []string{ + "application/json", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml", + "application/javascript", + "application/ecmascript", + "message/", + } + + for _, t := range textualTypes { + if mimeType == t { + return true + } + } + return false } // modify the spindle configured for this repo diff --git a/knotserver/db/pubkeys.go b/knotserver/db/pubkeys.go --- a/knotserver/db/pubkeys.go +++ b/knotserver/db/pubkeys.go @@ -1,6 +1,7 @@ package db import ( + "strconv" "time" "tangled.sh/tangled.sh/core/api/tangled" @@ -98,4 +99,43 @@ } return keys, nil +} + +func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) { + var keys []PublicKey + + offset := 0 + if cursor != "" { + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { + offset = o + } + } + + query := `select key, did, created from public_keys order by created desc limit ? offset ?` + rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results + if err != nil { + return nil, "", err + } + defer rows.Close() + + for rows.Next() { + var publicKey PublicKey + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { + return nil, "", err + } + keys = append(keys, publicKey) + } + + if err := rows.Err(); err != nil { + return nil, "", err + } + + // check if there are more results for pagination + var nextCursor string + if len(keys) > limit { + keys = keys[:limit] // remove the extra item + nextCursor = strconv.Itoa(offset + limit) + } + + return keys, nextCursor, nil } diff --git a/knotserver/xrpc/list_keys.go b/knotserver/xrpc/list_keys.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/list_keys.go @@ -0,0 +1,58 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "strconv" + + "tangled.sh/tangled.sh/core/api/tangled" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { + cursor := r.URL.Query().Get("cursor") + + limit := 100 // default + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { + limit = l + } + } + + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) + if err != nil { + x.Logger.Error("failed to get public keys", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to retrieve public keys"), + ), http.StatusInternalServerError) + return + } + + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) + for _, key := range keys { + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ + Did: key.Did, + Key: key.Key, + CreatedAt: key.CreatedAt, + }) + } + + response := tangled.KnotListKeys_Output{ + Keys: publicKeys, + } + + if nextCursor != "" { + response.Cursor = &nextCursor + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_archive.go b/knotserver/xrpc/repo_archive.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_archive.go @@ -0,0 +1,80 @@ +package xrpc + +import ( + "compress/gzip" + "fmt" + "net/http" + "strings" + + "github.com/go-git/go-git/v5/plumbing" + + "tangled.sh/tangled.sh/core/knotserver/git" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { + repo, repoPath, unescapedRef, err := x.parseStandardParams(r) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + format := r.URL.Query().Get("format") + if format == "" { + format = "tar.gz" // default + } + + prefix := r.URL.Query().Get("prefix") + + if format != "tar.gz" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("only tar.gz format is supported"), + ), http.StatusBadRequest) + return + } + + gr, err := git.Open(repoPath, unescapedRef) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RefNotFound"), + xrpcerr.WithMessage("repository or ref not found"), + ), http.StatusNotFound) + return + } + + repoParts := strings.Split(repo, "/") + repoName := repoParts[len(repoParts)-1] + + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") + + var archivePrefix string + if prefix != "" { + archivePrefix = prefix + } else { + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) + } + + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + w.Header().Set("Content-Type", "application/gzip") + + gw := gzip.NewWriter(w) + defer gw.Close() + + err = gr.WriteTar(gw, archivePrefix) + if err != nil { + // once we start writing to the body we can't report error anymore + // so we are only left with logging the error + x.Logger.Error("writing tar file", "error", err.Error()) + return + } + + err = gw.Flush() + if err != nil { + // once we start writing to the body we can't report error anymore + // so we are only left with logging the error + x.Logger.Error("flushing", "error", err.Error()) + return + } +} diff --git a/knotserver/xrpc/repo_blob.go b/knotserver/xrpc/repo_blob.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_blob.go @@ -0,0 +1,150 @@ +package xrpc + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "slices" + "strings" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { + _, repoPath, ref, err := x.parseStandardParams(r) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + treePath := r.URL.Query().Get("path") + if treePath == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing path parameter"), + ), http.StatusBadRequest) + return + } + + raw := r.URL.Query().Get("raw") == "true" + + gr, err := git.Open(repoPath, ref) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RefNotFound"), + xrpcerr.WithMessage("repository or ref not found"), + ), http.StatusNotFound) + return + } + + contents, err := gr.RawContent(treePath) + if err != nil { + x.Logger.Error("file content", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("FileNotFound"), + xrpcerr.WithMessage("file not found at the specified path"), + ), http.StatusNotFound) + return + } + + mimeType := http.DetectContentType(contents) + + if filepath.Ext(treePath) == ".svg" { + mimeType = "image/svg+xml" + } + + if raw { + contentHash := sha256.Sum256(contents) + eTag := fmt.Sprintf("\"%x\"", contentHash) + + switch { + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", eTag) + + case strings.HasPrefix(mimeType, "text/"): + w.Header().Set("Cache-Control", "public, no-cache") + // serve all text content as text/plain + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + case isTextualMimeType(mimeType): + // handle textual application types (json, xml, etc.) as text/plain + w.Header().Set("Cache-Control", "public, no-cache") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + default: + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), + ), http.StatusForbidden) + return + } + w.Write(contents) + return + } + + isTextual := func(mt string) bool { + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) + } + + var content string + var encoding string + + isBinary := !isTextual(mimeType) + + if isBinary { + content = base64.StdEncoding.EncodeToString(contents) + encoding = "base64" + } else { + content = string(contents) + encoding = "utf-8" + } + + response := tangled.RepoBlob_Output{ + Ref: ref, + Path: treePath, + Content: content, + Encoding: &encoding, + Size: &[]int64{int64(len(contents))}[0], + IsBinary: &isBinary, + } + + if mimeType != "" { + response.MimeType = &mimeType + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} + +// isTextualMimeType returns true if the MIME type represents textual content +// that should be served as text/plain for security reasons +func isTextualMimeType(mimeType string) bool { + textualTypes := []string{ + "application/json", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml", + "application/javascript", + "application/ecmascript", + } + + return slices.Contains(textualTypes, mimeType) +} diff --git a/knotserver/xrpc/repo_branch.go b/knotserver/xrpc/repo_branch.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_branch.go @@ -0,0 +1,96 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/url" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing name parameter"), + ), http.StatusBadRequest) + return + } + + branchName, _ := url.PathUnescape(name) + + gr, err := git.PlainOpen(repoPath) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RepoNotFound"), + xrpcerr.WithMessage("repository not found"), + ), http.StatusNotFound) + return + } + + ref, err := gr.Branch(branchName) + if err != nil { + x.Logger.Error("getting branch", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("BranchNotFound"), + xrpcerr.WithMessage("branch not found"), + ), http.StatusNotFound) + return + } + + commit, err := gr.Commit(ref.Hash()) + if err != nil { + x.Logger.Error("getting commit object", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("BranchNotFound"), + xrpcerr.WithMessage("failed to get commit object"), + ), http.StatusInternalServerError) + return + } + + defaultBranch, err := gr.FindMainBranch() + isDefault := false + if err != nil { + x.Logger.Error("getting default branch", "error", err.Error()) + } else if defaultBranch == branchName { + isDefault = true + } + + response := tangled.RepoBranch_Output{ + Name: ref.Name().Short(), + Hash: ref.Hash().String(), + ShortHash: &[]string{ref.Hash().String()[:7]}[0], + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), + IsDefault: &isDefault, + } + + if commit.Message != "" { + response.Message = &commit.Message + } + + response.Author = &tangled.RepoBranch_Signature{ + Name: commit.Author.Name, + Email: commit.Author.Email, + When: commit.Author.When.Format("2006-01-02T15:04:05.000Z"), + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_branches.go b/knotserver/xrpc/repo_branches.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_branches.go @@ -0,0 +1,70 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "strconv" + + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/types" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + cursor := r.URL.Query().Get("cursor") + + limit := 50 // default + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + gr, err := git.PlainOpen(repoPath) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RepoNotFound"), + xrpcerr.WithMessage("repository not found"), + ), http.StatusNotFound) + return + } + + branches, _ := gr.Branches() + + offset := 0 + if cursor != "" { + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { + offset = o + } + } + + end := offset + limit + if end > len(branches) { + end = len(branches) + } + + paginatedBranches := branches[offset:end] + + // Create response using existing types.RepoBranchesResponse + response := types.RepoBranchesResponse{ + Branches: paginatedBranches, + } + + // Write JSON response directly + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_compare.go b/knotserver/xrpc/repo_compare.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_compare.go @@ -0,0 +1,98 @@ +package xrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/types" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + rev1Param := r.URL.Query().Get("rev1") + if rev1Param == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing rev1 parameter"), + ), http.StatusBadRequest) + return + } + + rev2Param := r.URL.Query().Get("rev2") + if rev2Param == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing rev2 parameter"), + ), http.StatusBadRequest) + return + } + + rev1, _ := url.PathUnescape(rev1Param) + rev2, _ := url.PathUnescape(rev2Param) + + gr, err := git.PlainOpen(repoPath) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RepoNotFound"), + xrpcerr.WithMessage("repository not found"), + ), http.StatusNotFound) + return + } + + commit1, err := gr.ResolveRevision(rev1) + if err != nil { + x.Logger.Error("error resolving revision 1", "msg", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RevisionNotFound"), + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), + ), http.StatusBadRequest) + return + } + + commit2, err := gr.ResolveRevision(rev2) + if err != nil { + x.Logger.Error("error resolving revision 2", "msg", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RevisionNotFound"), + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), + ), http.StatusBadRequest) + return + } + + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) + if err != nil { + x.Logger.Error("error comparing revisions", "msg", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("CompareError"), + xrpcerr.WithMessage("error comparing revisions"), + ), http.StatusBadRequest) + return + } + + resp := types.RepoFormatPatchResponse{ + Rev1: commit1.Hash.String(), + Rev2: commit2.Hash.String(), + FormatPatch: formatPatch, + Patch: rawPatch, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_diff.go b/knotserver/xrpc/repo_diff.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_diff.go @@ -0,0 +1,65 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/url" + + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/types" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + refParam := r.URL.Query().Get("ref") + if refParam == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing ref parameter"), + ), http.StatusBadRequest) + return + } + + ref, _ := url.QueryUnescape(refParam) + + gr, err := git.Open(repoPath, ref) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RefNotFound"), + xrpcerr.WithMessage("repository or ref not found"), + ), http.StatusNotFound) + return + } + + diff, err := gr.Diff() + if err != nil { + x.Logger.Error("getting diff", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RefNotFound"), + xrpcerr.WithMessage("failed to generate diff"), + ), http.StatusInternalServerError) + return + } + + resp := types.RepoCommitResponse{ + Ref: ref, + Diff: diff, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_get_default_branch.go b/knotserver/xrpc/repo_get_default_branch.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_get_default_branch.go @@ -0,0 +1,54 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + gr, err := git.Open(repoPath, "") + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RepoNotFound"), + xrpcerr.WithMessage("repository not found"), + ), http.StatusNotFound) + return + } + + branch, err := gr.FindMainBranch() + if err != nil { + x.Logger.Error("getting default branch", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("failed to get default branch"), + ), http.StatusInternalServerError) + return + } + + response := tangled.RepoGetDefaultBranch_Output{ + Name: branch, + Hash: "", + When: "1970-01-01T00:00:00.000Z", + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_languages.go b/knotserver/xrpc/repo_languages.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_languages.go @@ -0,0 +1,93 @@ +package xrpc + +import ( + "context" + "encoding/json" + "math" + "net/http" + "net/url" + "time" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { + refParam := r.URL.Query().Get("ref") + if refParam == "" { + refParam = "HEAD" // default + } + ref, _ := url.PathUnescape(refParam) + + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + gr, err := git.Open(repoPath, ref) + if err != nil { + x.Logger.Error("opening repo", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RefNotFound"), + xrpcerr.WithMessage("repository or ref not found"), + ), http.StatusNotFound) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) + defer cancel() + + sizes, err := gr.AnalyzeLanguages(ctx) + if err != nil { + x.Logger.Error("failed to analyze languages", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("failed to analyze repository languages"), + ), http.StatusNoContent) + return + } + + var apiLanguages []*tangled.RepoLanguages_Language + var totalSize int64 + + for _, size := range sizes { + totalSize += size + } + + for name, size := range sizes { + percentagef64 := float64(size) / float64(totalSize) * 100 + percentage := math.Round(percentagef64) + + lang := &tangled.RepoLanguages_Language{ + Name: name, + Size: size, + Percentage: int64(percentage), + } + + apiLanguages = append(apiLanguages, lang) + } + + response := tangled.RepoLanguages_Output{ + Ref: ref, + Languages: apiLanguages, + } + + if totalSize > 0 { + response.TotalSize = &totalSize + totalFiles := int64(len(sizes)) + response.TotalFiles = &totalFiles + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_log.go b/knotserver/xrpc/repo_log.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_log.go @@ -0,0 +1,101 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/types" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + refParam := r.URL.Query().Get("ref") + if refParam == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing ref parameter"), + ), http.StatusBadRequest) + return + } + + path := r.URL.Query().Get("path") + cursor := r.URL.Query().Get("cursor") + + limit := 50 // default + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + ref, err := url.QueryUnescape(refParam) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("invalid ref parameter"), + ), http.StatusBadRequest) + return + } + + gr, err := git.Open(repoPath, ref) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RefNotFound"), + xrpcerr.WithMessage("repository or ref not found"), + ), http.StatusNotFound) + return + } + + offset := 0 + if cursor != "" { + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { + offset = o + } + } + + commits, err := gr.Commits(offset, limit) + if err != nil { + x.Logger.Error("fetching commits", "error", err.Error()) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("PathNotFound"), + xrpcerr.WithMessage("failed to read commit log"), + ), http.StatusNotFound) + return + } + + // Create response using existing types.RepoLogResponse + response := types.RepoLogResponse{ + Commits: commits, + Ref: ref, + Page: (offset / limit) + 1, + PerPage: limit, + Total: len(commits), // This is not accurate for pagination, but matches existing behavior + } + + if path != "" { + response.Description = path + } + + response.Log = true + + // Write JSON response directly + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_tags.go b/knotserver/xrpc/repo_tags.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_tags.go @@ -0,0 +1,99 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + + "tangled.sh/tangled.sh/core/knotserver/git" + "tangled.sh/tangled.sh/core/types" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + cursor := r.URL.Query().Get("cursor") + + limit := 50 // default + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + gr, err := git.Open(repoPath, "") + if err != nil { + x.Logger.Error("failed to open", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RepoNotFound"), + xrpcerr.WithMessage("repository not found"), + ), http.StatusNotFound) + return + } + + tags, err := gr.Tags() + if err != nil { + x.Logger.Warn("getting tags", "error", err.Error()) + tags = []object.Tag{} + } + + rtags := []*types.TagReference{} + for _, tag := range tags { + var target *object.Tag + if tag.Target != plumbing.ZeroHash { + target = &tag + } + tr := types.TagReference{ + Tag: target, + } + + tr.Reference = types.Reference{ + Name: tag.Name, + Hash: tag.Hash.String(), + } + + if tag.Message != "" { + tr.Message = tag.Message + } + + rtags = append(rtags, &tr) + } + + // apply pagination manually + offset := 0 + if cursor != "" { + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { + offset = o + } + } + + // calculate end index + end := min(offset+limit, len(rtags)) + + paginatedTags := rtags[offset:end] + + // Create response using existing types.RepoTagsResponse + response := types.RepoTagsResponse{ + Tags: paginatedTags, + } + + // Write JSON response directly + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/repo_tree.go b/knotserver/xrpc/repo_tree.go new file mode 100644 --- /dev/null +++ b/knotserver/xrpc/repo_tree.go @@ -0,0 +1,116 @@ +package xrpc + +import ( + "encoding/json" + "net/http" + "net/url" + "path/filepath" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/knotserver/git" + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" +) + +func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + repo := r.URL.Query().Get("repo") + repoPath, err := x.parseRepoParam(repo) + if err != nil { + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) + return + } + + refParam := r.URL.Query().Get("ref") + if refParam == "" { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing ref parameter"), + ), http.StatusBadRequest) + return + } + + path := r.URL.Query().Get("path") + // path can be empty (defaults to root) + + ref, err := url.QueryUnescape(refParam) + if err != nil { + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("invalid ref parameter"), + ), http.StatusBadRequest) + return + } + + gr, err := git.Open(repoPath, ref) + if err != nil { + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("RefNotFound"), + xrpcerr.WithMessage("repository or ref not found"), + ), http.StatusNotFound) + return + } + + files, err := gr.FileTree(ctx, path) + if err != nil { + x.Logger.Error("failed to get file tree", "error", err, "path", path) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("PathNotFound"), + xrpcerr.WithMessage("failed to read repository tree"), + ), http.StatusNotFound) + return + } + + // convert NiceTree -> tangled.RepoTree_TreeEntry + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) + for i, file := range files { + entry := &tangled.RepoTree_TreeEntry{ + Name: file.Name, + Mode: file.Mode, + Size: file.Size, + Is_file: file.IsFile, + Is_subtree: file.IsSubtree, + } + + if file.LastCommit != nil { + entry.Last_commit = &tangled.RepoTree_LastCommit{ + Hash: file.LastCommit.Hash.String(), + Message: file.LastCommit.Message, + When: file.LastCommit.When.Format("2006-01-02T15:04:05.000Z"), + } + } + + treeEntries[i] = entry + } + + var parentPtr *string + if path != "" { + parentPtr = &path + } + + var dotdotPtr *string + if path != "" { + dotdot := filepath.Dir(path) + if dotdot != "." { + dotdotPtr = &dotdot + } + } + + response := tangled.RepoTree_Output{ + Ref: ref, + Parent: parentPtr, + Dotdot: dotdotPtr, + Files: treeEntries, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + x.Logger.Error("failed to encode response", "error", err) + writeError(w, xrpcerr.NewXrpcError( + xrpcerr.WithTag("InternalServerError"), + xrpcerr.WithMessage("failed to encode response"), + ), http.StatusInternalServerError) + return + } +} diff --git a/knotserver/xrpc/xrpc.go b/knotserver/xrpc/xrpc.go --- a/knotserver/xrpc/xrpc.go +++ b/knotserver/xrpc/xrpc.go @@ -4,7 +4,10 @@ "encoding/json" "log/slog" "net/http" + "net/url" + "strings" + securejoin "github.com/cyphar/filepath-securejoin" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/idresolver" "tangled.sh/tangled.sh/core/jetstream" @@ -50,7 +53,88 @@ // - we can calculate on PR submit/resubmit/gitRefUpdate etc. // - use ETags on clients to keep requests to a minimum r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) + + // repo query endpoints (no auth required) + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) + + // knot query endpoints (no auth required) + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) + return r +} + +// parseRepoParam parses a repo parameter in 'did/repoName' format and returns +// the full repository path on disk +func (x *Xrpc) parseRepoParam(repo string) (string, error) { + if repo == "" { + return "", xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing repo parameter"), + ) + } + + // Parse repo string (did/repoName format) + parts := strings.Split(repo, "/") + if len(parts) < 2 { + return "", xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), + ) + } + + did := strings.Join(parts[:len(parts)-1], "/") + repoName := parts[len(parts)-1] + + // Construct repository path using the same logic as didPath + didRepoPath, err := securejoin.SecureJoin(did, repoName) + if err != nil { + return "", xrpcerr.NewXrpcError( + xrpcerr.WithTag("RepoNotFound"), + xrpcerr.WithMessage("failed to access repository"), + ) + } + + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) + if err != nil { + return "", xrpcerr.NewXrpcError( + xrpcerr.WithTag("RepoNotFound"), + xrpcerr.WithMessage("failed to access repository"), + ) + } + + return repoPath, nil +} + +// parseStandardParams parses common query parameters used by most handlers +func (x *Xrpc) parseStandardParams(r *http.Request) (repo, repoPath, ref string, err error) { + // Parse repo parameter + repo = r.URL.Query().Get("repo") + repoPath, err = x.parseRepoParam(repo) + if err != nil { + return "", "", "", err + } + + // Parse and unescape ref parameter + refParam := r.URL.Query().Get("ref") + if refParam == "" { + return "", "", "", xrpcerr.NewXrpcError( + xrpcerr.WithTag("InvalidRequest"), + xrpcerr.WithMessage("missing ref parameter"), + ) + } + + ref, _ = url.QueryUnescape(refParam) + return repo, repoPath, ref, nil } func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {