+27
-1
appview/repo/repo.go
+27
-1
appview/repo/repo.go
···
685
685
return
686
686
}
687
687
688
-
if strings.Contains(contentType, "text/plain") {
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
689
691
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
690
692
w.Write(body)
691
693
} else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
694
+
// Serve images and videos with their original content type
692
695
w.Header().Set("Content-Type", contentType)
693
696
w.Write(body)
694
697
} else {
698
+
// Block potentially dangerous content types
695
699
w.WriteHeader(http.StatusUnsupportedMediaType)
696
700
w.Write([]byte("unsupported content type"))
697
701
return
698
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
699
725
}
700
726
701
727
// modify the spindle configured for this repo
+40
knotserver/db/pubkeys.go
+40
knotserver/db/pubkeys.go
···
1
1
package db
2
2
3
3
import (
4
+
"strconv"
4
5
"time"
5
6
6
7
"tangled.sh/tangled.sh/core/api/tangled"
···
99
100
100
101
return keys, nil
101
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
+5
knotserver/ingester.go
···
98
98
l := log.FromContext(ctx)
99
99
l = l.With("handler", "processPull")
100
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
+
101
106
l = l.With("target_repo", record.Target.Repo)
102
107
l = l.With("target_branch", record.Target.Branch)
103
108
+58
knotserver/xrpc/list_keys.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}
+116
knotserver/xrpc/repo_tree.go
+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
+84
knotserver/xrpc/xrpc.go
···
4
4
"encoding/json"
5
5
"log/slog"
6
6
"net/http"
7
+
"net/url"
8
+
"strings"
7
9
10
+
securejoin "github.com/cyphar/filepath-securejoin"
8
11
"tangled.sh/tangled.sh/core/api/tangled"
9
12
"tangled.sh/tangled.sh/core/idresolver"
10
13
"tangled.sh/tangled.sh/core/jetstream"
···
50
53
// - we can calculate on PR submit/resubmit/gitRefUpdate etc.
51
54
// - use ETags on clients to keep requests to a minimum
52
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
+
53
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
54
138
}
55
139
56
140
func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) {