A vibe coded tangled fork which supports pijul.
1package xrpc
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "fmt"
8 "net/http"
9 "path/filepath"
10 "slices"
11 "strings"
12 "time"
13
14 "tangled.org/core/api/tangled"
15 "tangled.org/core/knotserver/git"
16 xrpcerr "tangled.org/core/xrpc/errors"
17)
18
19func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
20 repo := r.URL.Query().Get("repo")
21 repoPath, err := x.parseRepoParam(repo)
22 if err != nil {
23 writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
24 return
25 }
26
27 ref := r.URL.Query().Get("ref")
28 // ref can be empty (git.Open handles this)
29
30 treePath := r.URL.Query().Get("path")
31 if treePath == "" {
32 writeError(w, xrpcerr.NewXrpcError(
33 xrpcerr.WithTag("InvalidRequest"),
34 xrpcerr.WithMessage("missing path parameter"),
35 ), http.StatusBadRequest)
36 return
37 }
38
39 raw := r.URL.Query().Get("raw") == "true"
40
41 gr, err := git.Open(repoPath, ref)
42 if err != nil {
43 writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
44 return
45 }
46
47 // first check if this path is a submodule
48 submodule, err := gr.Submodule(treePath)
49 if err != nil {
50 // this is okay, continue and try to treat it as a regular file
51 } else {
52 response := tangled.RepoBlob_Output{
53 Ref: ref,
54 Path: treePath,
55 Submodule: &tangled.RepoBlob_Submodule{
56 Name: submodule.Name,
57 Url: submodule.URL,
58 Branch: &submodule.Branch,
59 },
60 }
61 writeJson(w, response)
62 return
63 }
64
65 contents, err := gr.RawContent(treePath)
66 if err != nil {
67 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
68 writeError(w, xrpcerr.NewXrpcError(
69 xrpcerr.WithTag("FileNotFound"),
70 xrpcerr.WithMessage("file not found at the specified path"),
71 ), http.StatusNotFound)
72 return
73 }
74
75 mimeType := http.DetectContentType(contents)
76
77 // override MIME types for formats that http.DetectContentType does not recognize
78 switch filepath.Ext(treePath) {
79 case ".svg":
80 mimeType = "image/svg+xml"
81 case ".avif":
82 mimeType = "image/avif"
83 case ".jxl":
84 mimeType = "image/jxl"
85 case ".heic", ".heif":
86 mimeType = "image/heif"
87 }
88
89 if raw {
90 contentHash := sha256.Sum256(contents)
91 eTag := fmt.Sprintf("\"%x\"", contentHash)
92
93 switch {
94 case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
95 if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
96 w.WriteHeader(http.StatusNotModified)
97 return
98 }
99 w.Header().Set("ETag", eTag)
100 w.Header().Set("Content-Type", mimeType)
101
102 case strings.HasPrefix(mimeType, "text/"):
103 w.Header().Set("Cache-Control", "public, no-cache")
104 // serve all text content as text/plain
105 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
106
107 case isTextualMimeType(mimeType):
108 // handle textual application types (json, xml, etc.) as text/plain
109 w.Header().Set("Cache-Control", "public, no-cache")
110 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
111
112 default:
113 x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
114 writeError(w, xrpcerr.NewXrpcError(
115 xrpcerr.WithTag("InvalidRequest"),
116 xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
117 ), http.StatusForbidden)
118 return
119 }
120 w.Write(contents)
121 return
122 }
123
124 isTextual := func(mt string) bool {
125 return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
126 }
127
128 var content string
129 var encoding string
130
131 isBinary := !isTextual(mimeType)
132 size := int64(len(contents))
133
134 if isBinary {
135 content = base64.StdEncoding.EncodeToString(contents)
136 encoding = "base64"
137 } else {
138 content = string(contents)
139 encoding = "utf-8"
140 }
141
142 response := tangled.RepoBlob_Output{
143 Ref: ref,
144 Path: treePath,
145 Content: &content,
146 Encoding: &encoding,
147 Size: &size,
148 IsBinary: &isBinary,
149 }
150
151 if mimeType != "" {
152 response.MimeType = &mimeType
153 }
154
155 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
156 defer cancel()
157
158 lastCommit, err := gr.LastCommitFile(ctx, treePath)
159 if err == nil && lastCommit != nil {
160 response.LastCommit = &tangled.RepoBlob_LastCommit{
161 Hash: lastCommit.Hash.String(),
162 Message: lastCommit.Message,
163 When: lastCommit.When.Format(time.RFC3339),
164 }
165
166 // try to get author information
167 commit, err := gr.Commit(lastCommit.Hash)
168 if err == nil {
169 response.LastCommit.Author = &tangled.RepoBlob_Signature{
170 Name: commit.Author.Name,
171 Email: commit.Author.Email,
172 }
173 }
174 }
175
176 writeJson(w, response)
177}
178
179// isTextualMimeType returns true if the MIME type represents textual content
180// that should be served as text/plain for security reasons
181func isTextualMimeType(mimeType string) bool {
182 textualTypes := []string{
183 "application/json",
184 "application/xml",
185 "application/yaml",
186 "application/x-yaml",
187 "application/toml",
188 "application/javascript",
189 "application/ecmascript",
190 }
191
192 return slices.Contains(textualTypes, mimeType)
193}