Monorepo for Tangled
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 if filepath.Ext(treePath) == ".svg" {
78 mimeType = "image/svg+xml"
79 }
80
81 if raw {
82 contentHash := sha256.Sum256(contents)
83 eTag := fmt.Sprintf("\"%x\"", contentHash)
84
85 switch {
86 case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
87 if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
88 w.WriteHeader(http.StatusNotModified)
89 return
90 }
91 w.Header().Set("ETag", eTag)
92 w.Header().Set("Content-Type", mimeType)
93
94 case strings.HasPrefix(mimeType, "text/"):
95 w.Header().Set("Cache-Control", "public, no-cache")
96 // serve all text content as text/plain
97 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
98
99 case isTextualMimeType(mimeType):
100 // handle textual application types (json, xml, etc.) as text/plain
101 w.Header().Set("Cache-Control", "public, no-cache")
102 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
103
104 default:
105 x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
106 writeError(w, xrpcerr.NewXrpcError(
107 xrpcerr.WithTag("InvalidRequest"),
108 xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
109 ), http.StatusForbidden)
110 return
111 }
112 w.Write(contents)
113 return
114 }
115
116 isTextual := func(mt string) bool {
117 return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
118 }
119
120 var content string
121 var encoding string
122
123 isBinary := !isTextual(mimeType)
124 size := int64(len(contents))
125
126 if isBinary {
127 content = base64.StdEncoding.EncodeToString(contents)
128 encoding = "base64"
129 } else {
130 content = string(contents)
131 encoding = "utf-8"
132 }
133
134 response := tangled.RepoBlob_Output{
135 Ref: ref,
136 Path: treePath,
137 Content: &content,
138 Encoding: &encoding,
139 Size: &size,
140 IsBinary: &isBinary,
141 }
142
143 if mimeType != "" {
144 response.MimeType = &mimeType
145 }
146
147 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
148 defer cancel()
149
150 lastCommit, err := gr.LastCommitFile(ctx, treePath)
151 if err == nil && lastCommit != nil {
152 response.LastCommit = &tangled.RepoBlob_LastCommit{
153 Hash: lastCommit.Hash.String(),
154 Message: lastCommit.Message,
155 When: lastCommit.When.Format(time.RFC3339),
156 }
157
158 // try to get author information
159 commit, err := gr.Commit(lastCommit.Hash)
160 if err == nil {
161 response.LastCommit.Author = &tangled.RepoBlob_Signature{
162 Name: commit.Author.Name,
163 Email: commit.Author.Email,
164 }
165 }
166 }
167
168 writeJson(w, response)
169}
170
171// isTextualMimeType returns true if the MIME type represents textual content
172// that should be served as text/plain for security reasons
173func isTextualMimeType(mimeType string) bool {
174 textualTypes := []string{
175 "application/json",
176 "application/xml",
177 "application/yaml",
178 "application/x-yaml",
179 "application/toml",
180 "application/javascript",
181 "application/ecmascript",
182 }
183
184 return slices.Contains(textualTypes, mimeType)
185}