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