forked from
tangled.org/core
Monorepo for Tangled
1package repo
2
3import (
4 "encoding/base64"
5 "fmt"
6 "io"
7 "net/http"
8 "net/url"
9 "path/filepath"
10 "slices"
11 "strings"
12 "time"
13
14 "tangled.org/core/api/tangled"
15 "tangled.org/core/appview/config"
16 "tangled.org/core/appview/db"
17 "tangled.org/core/appview/models"
18 "tangled.org/core/appview/pages"
19 "tangled.org/core/appview/pages/markup"
20 "tangled.org/core/appview/reporesolver"
21 xrpcclient "tangled.org/core/appview/xrpcclient"
22 "tangled.org/core/types"
23
24 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
25 "github.com/go-chi/chi/v5"
26 "github.com/go-git/go-git/v5/plumbing"
27)
28
29// the content can be one of the following:
30//
31// - code : text | | raw
32// - markup : text | rendered | raw
33// - svg : text | rendered | raw
34// - png : | rendered | raw
35// - video : | rendered | raw
36// - submodule : | rendered |
37// - rest : | |
38func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
39 l := rp.logger.With("handler", "RepoBlob")
40
41 f, err := rp.repoResolver.Resolve(r)
42 if err != nil {
43 l.Error("failed to get repo and knot", "err", err)
44 return
45 }
46
47 ref := chi.URLParam(r, "ref")
48 ref, _ = url.PathUnescape(ref)
49
50 filePath := chi.URLParam(r, "*")
51 filePath, _ = url.PathUnescape(filePath)
52
53 scheme := "http"
54 if !rp.config.Core.Dev {
55 scheme = "https"
56 }
57 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
58 xrpcc := &indigoxrpc.Client{
59 Host: host,
60 }
61 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
62 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
63 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
64 l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
65 rp.pages.Error503(w)
66 return
67 }
68
69 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
70
71 // Use XRPC response directly instead of converting to internal types
72 var breadcrumbs [][]string
73 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))})
74 if filePath != "" {
75 for idx, elem := range strings.Split(filePath, "/") {
76 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
77 }
78 }
79
80 // Create the blob view
81 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
82
83 user := rp.oauth.GetMultiAccountUser(r)
84
85 // Get email to DID mapping for commit author
86 var emails []string
87 if resp.LastCommit != nil && resp.LastCommit.Author != nil {
88 emails = append(emails, resp.LastCommit.Author.Email)
89 }
90 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
91 if err != nil {
92 l.Error("failed to get email to did mapping", "err", err)
93 emailToDidMap = make(map[string]string)
94 }
95
96 var lastCommitInfo *types.LastCommitInfo
97 if resp.LastCommit != nil {
98 when, _ := time.Parse(time.RFC3339, resp.LastCommit.When)
99 lastCommitInfo = &types.LastCommitInfo{
100 Hash: plumbing.NewHash(resp.LastCommit.Hash),
101 Message: resp.LastCommit.Message,
102 When: when,
103 }
104 }
105
106 rp.pages.RepoBlob(w, pages.RepoBlobParams{
107 LoggedInUser: user,
108 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
109 BreadCrumbs: breadcrumbs,
110 BlobView: blobView,
111 EmailToDid: emailToDidMap,
112 LastCommitInfo: lastCommitInfo,
113 RepoBlob_Output: resp,
114 })
115}
116
117func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
118 l := rp.logger.With("handler", "RepoBlobRaw")
119
120 f, err := rp.repoResolver.Resolve(r)
121 if err != nil {
122 l.Error("failed to get repo and knot", "err", err)
123 w.WriteHeader(http.StatusBadRequest)
124 return
125 }
126
127 ref := chi.URLParam(r, "ref")
128 ref, _ = url.PathUnescape(ref)
129
130 filePath := chi.URLParam(r, "*")
131 filePath, _ = url.PathUnescape(filePath)
132
133 scheme := "http"
134 if !rp.config.Core.Dev {
135 scheme = "https"
136 }
137 repo := f.DidSlashRepo()
138 baseURL := &url.URL{
139 Scheme: scheme,
140 Host: f.Knot,
141 Path: "/xrpc/sh.tangled.repo.blob",
142 }
143 query := baseURL.Query()
144 query.Set("repo", repo)
145 query.Set("ref", ref)
146 query.Set("path", filePath)
147 query.Set("raw", "true")
148 baseURL.RawQuery = query.Encode()
149 blobURL := baseURL.String()
150 req, err := http.NewRequest("GET", blobURL, nil)
151 if err != nil {
152 l.Error("failed to create request", "err", err)
153 return
154 }
155
156 // forward the If-None-Match header
157 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
158 req.Header.Set("If-None-Match", clientETag)
159 }
160 client := &http.Client{}
161
162 resp, err := client.Do(req)
163 if err != nil {
164 l.Error("failed to reach knotserver", "err", err)
165 rp.pages.Error503(w)
166 return
167 }
168
169 defer resp.Body.Close()
170
171 // forward 304 not modified
172 if resp.StatusCode == http.StatusNotModified {
173 w.WriteHeader(http.StatusNotModified)
174 return
175 }
176
177 if resp.StatusCode != http.StatusOK {
178 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
179 w.WriteHeader(resp.StatusCode)
180 _, _ = io.Copy(w, resp.Body)
181 return
182 }
183
184 contentType := resp.Header.Get("Content-Type")
185 body, err := io.ReadAll(resp.Body)
186 if err != nil {
187 l.Error("error reading response body from knotserver", "err", err)
188 w.WriteHeader(http.StatusInternalServerError)
189 return
190 }
191
192 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) {
193 // serve all textual content as text/plain
194 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
195 w.Write(body)
196 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
197 // serve images and videos with their original content type
198 w.Header().Set("Content-Type", contentType)
199 w.Write(body)
200 } else {
201 w.WriteHeader(http.StatusUnsupportedMediaType)
202 w.Write([]byte("unsupported content type"))
203 return
204 }
205}
206
207// NewBlobView creates a BlobView from the XRPC response
208func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView {
209 view := models.BlobView{
210 Contents: "",
211 Lines: 0,
212 }
213
214 // Set size
215 if resp.Size != nil {
216 view.SizeHint = uint64(*resp.Size)
217 } else if resp.Content != nil {
218 view.SizeHint = uint64(len(*resp.Content))
219 }
220
221 if resp.Submodule != nil {
222 view.ContentType = models.BlobContentTypeSubmodule
223 view.HasRenderedView = true
224 view.ContentSrc = resp.Submodule.Url
225 return view
226 }
227
228 // Determine if binary
229 if resp.IsBinary != nil && *resp.IsBinary {
230 view.ContentSrc = generateBlobURL(config, repo, ref, filePath)
231 ext := strings.ToLower(filepath.Ext(resp.Path))
232
233 switch ext {
234 case ".jpg", ".jpeg", ".png", ".gif", ".webp":
235 view.ContentType = models.BlobContentTypeImage
236 view.HasRawView = true
237 view.HasRenderedView = true
238 view.ShowingRendered = true
239
240 case ".svg":
241 view.ContentType = models.BlobContentTypeSvg
242 view.HasRawView = true
243 view.HasTextView = true
244 view.HasRenderedView = true
245 view.ShowingRendered = queryParams.Get("code") != "true"
246 if resp.Content != nil {
247 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
248 view.Contents = string(bytes)
249 view.Lines = countLines(view.Contents)
250 }
251
252 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
253 view.ContentType = models.BlobContentTypeVideo
254 view.HasRawView = true
255 view.HasRenderedView = true
256 view.ShowingRendered = true
257 }
258
259 return view
260 }
261
262 // otherwise, we are dealing with text content
263 view.HasRawView = true
264 view.HasTextView = true
265
266 if resp.Content != nil {
267 view.Contents = *resp.Content
268 view.Lines = countLines(view.Contents)
269 }
270
271 // with text, we may be dealing with markdown
272 format := markup.GetFormat(resp.Path)
273 if format == markup.FormatMarkdown {
274 view.ContentType = models.BlobContentTypeMarkup
275 view.HasRenderedView = true
276 view.ShowingRendered = queryParams.Get("code") != "true"
277 }
278
279 return view
280}
281
282func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string {
283 scheme := "http"
284 if !config.Core.Dev {
285 scheme = "https"
286 }
287
288 repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name)
289 baseURL := &url.URL{
290 Scheme: scheme,
291 Host: repo.Knot,
292 Path: "/xrpc/sh.tangled.repo.blob",
293 }
294 query := baseURL.Query()
295 query.Set("repo", repoName)
296 query.Set("ref", ref)
297 query.Set("path", filePath)
298 query.Set("raw", "true")
299 baseURL.RawQuery = query.Encode()
300 blobURL := baseURL.String()
301
302 if !config.Core.Dev {
303 return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
304 }
305 return blobURL
306}
307
308func isTextualMimeType(mimeType string) bool {
309 textualTypes := []string{
310 "application/json",
311 "application/xml",
312 "application/yaml",
313 "application/x-yaml",
314 "application/toml",
315 "application/javascript",
316 "application/ecmascript",
317 "message/",
318 }
319 return slices.Contains(textualTypes, mimeType)
320}
321
322// TODO: dedup with strings
323func countLines(content string) int {
324 if content == "" {
325 return 0
326 }
327
328 count := strings.Count(content, "\n")
329
330 if !strings.HasSuffix(content, "\n") {
331 count++
332 }
333
334 return count
335}