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