forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

knotserver/xrpc: port all handlers to xrpc queries

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

+27 -1
appview/repo/repo.go
···
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
+40
knotserver/db/pubkeys.go
···
package db
import (
+
"strconv"
"time"
"tangled.sh/tangled.sh/core/api/tangled"
···
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
+
}
+5
knotserver/ingester.go
···
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)
+58
knotserver/xrpc/list_keys.go
···
+
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
+
}
+
}
+80
knotserver/xrpc/repo_archive.go
···
+
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
+
}
+
}
+150
knotserver/xrpc/repo_blob.go
···
+
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)
+
}
+96
knotserver/xrpc/repo_branch.go
···
+
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
+
}
+
}
+70
knotserver/xrpc/repo_branches.go
···
+
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
+
}
+
}
+98
knotserver/xrpc/repo_compare.go
···
+
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
+
}
+
}
+65
knotserver/xrpc/repo_diff.go
···
+
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
+
}
+
}
+54
knotserver/xrpc/repo_get_default_branch.go
···
+
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
+
}
+
}
+93
knotserver/xrpc/repo_languages.go
···
+
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
+
}
+
}
+101
knotserver/xrpc/repo_log.go
···
+
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
+
}
+
}
+99
knotserver/xrpc/repo_tags.go
···
+
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
+
}
+
}
+116
knotserver/xrpc/repo_tree.go
···
+
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
+
}
+
}
+84
knotserver/xrpc/xrpc.go
···
"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"
···
// - 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) {