1package repo
2
3import (
4 "fmt"
5 "io"
6 "net/http"
7 "net/url"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "tangled.org/core/api/tangled"
13 "tangled.org/core/appview/pages"
14 "tangled.org/core/appview/pages/markup"
15 xrpcclient "tangled.org/core/appview/xrpcclient"
16
17 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
18 "github.com/go-chi/chi/v5"
19)
20
21func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
22 l := rp.logger.With("handler", "RepoBlob")
23 f, err := rp.repoResolver.Resolve(r)
24 if err != nil {
25 l.Error("failed to get repo and knot", "err", err)
26 return
27 }
28 ref := chi.URLParam(r, "ref")
29 ref, _ = url.PathUnescape(ref)
30 filePath := chi.URLParam(r, "*")
31 filePath, _ = url.PathUnescape(filePath)
32 scheme := "http"
33 if !rp.config.Core.Dev {
34 scheme = "https"
35 }
36 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
37 xrpcc := &indigoxrpc.Client{
38 Host: host,
39 }
40 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
41 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo)
42 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
43 l.Error("failed to call XRPC repo.blob", "err", xrpcerr)
44 rp.pages.Error503(w)
45 return
46 }
47 // Use XRPC response directly instead of converting to internal types
48 var breadcrumbs [][]string
49 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))})
50 if filePath != "" {
51 for idx, elem := range strings.Split(filePath, "/") {
52 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
53 }
54 }
55 showRendered := false
56 renderToggle := false
57 if markup.GetFormat(resp.Path) == markup.FormatMarkdown {
58 renderToggle = true
59 showRendered = r.URL.Query().Get("code") != "true"
60 }
61 var unsupported bool
62 var isImage bool
63 var isVideo bool
64 var contentSrc string
65 if resp.IsBinary != nil && *resp.IsBinary {
66 ext := strings.ToLower(filepath.Ext(resp.Path))
67 switch ext {
68 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
69 isImage = true
70 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
71 isVideo = true
72 default:
73 unsupported = true
74 }
75 // fetch the raw binary content using sh.tangled.repo.blob xrpc
76 repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name)
77 baseURL := &url.URL{
78 Scheme: scheme,
79 Host: f.Knot,
80 Path: "/xrpc/sh.tangled.repo.blob",
81 }
82 query := baseURL.Query()
83 query.Set("repo", repoName)
84 query.Set("ref", ref)
85 query.Set("path", filePath)
86 query.Set("raw", "true")
87 baseURL.RawQuery = query.Encode()
88 blobURL := baseURL.String()
89 contentSrc = blobURL
90 if !rp.config.Core.Dev {
91 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
92 }
93 }
94 lines := 0
95 if resp.IsBinary == nil || !*resp.IsBinary {
96 lines = strings.Count(resp.Content, "\n") + 1
97 }
98 var sizeHint uint64
99 if resp.Size != nil {
100 sizeHint = uint64(*resp.Size)
101 } else {
102 sizeHint = uint64(len(resp.Content))
103 }
104 user := rp.oauth.GetUser(r)
105 // Determine if content is binary (dereference pointer)
106 isBinary := false
107 if resp.IsBinary != nil {
108 isBinary = *resp.IsBinary
109 }
110 rp.pages.RepoBlob(w, pages.RepoBlobParams{
111 LoggedInUser: user,
112 RepoInfo: f.RepoInfo(user),
113 BreadCrumbs: breadcrumbs,
114 ShowRendered: showRendered,
115 RenderToggle: renderToggle,
116 Unsupported: unsupported,
117 IsImage: isImage,
118 IsVideo: isVideo,
119 ContentSrc: contentSrc,
120 RepoBlob_Output: resp,
121 Contents: resp.Content,
122 Lines: lines,
123 SizeHint: sizeHint,
124 IsBinary: isBinary,
125 })
126}
127
128func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
129 l := rp.logger.With("handler", "RepoBlobRaw")
130 f, err := rp.repoResolver.Resolve(r)
131 if err != nil {
132 l.Error("failed to get repo and knot", "err", err)
133 w.WriteHeader(http.StatusBadRequest)
134 return
135 }
136 ref := chi.URLParam(r, "ref")
137 ref, _ = url.PathUnescape(ref)
138 filePath := chi.URLParam(r, "*")
139 filePath, _ = url.PathUnescape(filePath)
140 scheme := "http"
141 if !rp.config.Core.Dev {
142 scheme = "https"
143 }
144 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name)
145 baseURL := &url.URL{
146 Scheme: scheme,
147 Host: f.Knot,
148 Path: "/xrpc/sh.tangled.repo.blob",
149 }
150 query := baseURL.Query()
151 query.Set("repo", repo)
152 query.Set("ref", ref)
153 query.Set("path", filePath)
154 query.Set("raw", "true")
155 baseURL.RawQuery = query.Encode()
156 blobURL := baseURL.String()
157 req, err := http.NewRequest("GET", blobURL, nil)
158 if err != nil {
159 l.Error("failed to create request", "err", err)
160 return
161 }
162 // forward the If-None-Match header
163 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
164 req.Header.Set("If-None-Match", clientETag)
165 }
166 client := &http.Client{}
167 resp, err := client.Do(req)
168 if err != nil {
169 l.Error("failed to reach knotserver", "err", err)
170 rp.pages.Error503(w)
171 return
172 }
173 defer resp.Body.Close()
174 // forward 304 not modified
175 if resp.StatusCode == http.StatusNotModified {
176 w.WriteHeader(http.StatusNotModified)
177 return
178 }
179 if resp.StatusCode != http.StatusOK {
180 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
181 w.WriteHeader(resp.StatusCode)
182 _, _ = io.Copy(w, resp.Body)
183 return
184 }
185 contentType := resp.Header.Get("Content-Type")
186 body, err := io.ReadAll(resp.Body)
187 if err != nil {
188 l.Error("error reading response body from knotserver", "err", err)
189 w.WriteHeader(http.StatusInternalServerError)
190 return
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
207func isTextualMimeType(mimeType string) bool {
208 textualTypes := []string{
209 "application/json",
210 "application/xml",
211 "application/yaml",
212 "application/x-yaml",
213 "application/toml",
214 "application/javascript",
215 "application/ecmascript",
216 "message/",
217 }
218 return slices.Contains(textualTypes, mimeType)
219}