Signed-off-by: oppiliappan me@oppi.li
+8
-1
appview/pages/templates/repo/index.html
+8
-1
appview/pages/templates/repo/index.html
···
35
{{ end }}
36
37
{{ define "repoLanguages" }}
38
-
<details class="group -m-6 mb-4">
39
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
{{ range $value := .Languages }}
41
<div
···
129
{{ $icon := "folder" }}
130
{{ $iconStyle := "size-4 fill-current" }}
131
132
{{ if .IsFile }}
133
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
{{ $icon = "file" }}
135
{{ $iconStyle = "size-4" }}
136
{{ end }}
137
<a href="{{ $link }}" class="{{ $linkstyle }}">
138
<div class="flex items-center gap-2">
139
{{ i $icon $iconStyle "flex-shrink-0" }}
···
35
{{ end }}
36
37
{{ define "repoLanguages" }}
38
+
<details class="group -my-4 -m-6 mb-4">
39
<summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t">
40
{{ range $value := .Languages }}
41
<div
···
129
{{ $icon := "folder" }}
130
{{ $iconStyle := "size-4 fill-current" }}
131
132
+
{{ if .IsSubmodule }}
133
+
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
134
+
{{ $icon = "folder-input" }}
135
+
{{ $iconStyle = "size-4" }}
136
+
{{ end }}
137
+
138
{{ if .IsFile }}
139
{{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }}
140
{{ $icon = "file" }}
141
{{ $iconStyle = "size-4" }}
142
{{ end }}
143
+
144
<a href="{{ $link }}" class="{{ $linkstyle }}">
145
<div class="flex items-center gap-2">
146
{{ i $icon $iconStyle "flex-shrink-0" }}
+8
appview/pages/templates/repo/tree.html
+8
appview/pages/templates/repo/tree.html
···
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
62
{{ if .IsFile }}
63
{{ $icon = "file" }}
64
{{ $iconStyle = "size-4" }}
65
{{ end }}
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
<div class="flex items-center gap-2">
68
{{ i $icon $iconStyle "flex-shrink-0" }}
···
59
{{ $icon := "folder" }}
60
{{ $iconStyle := "size-4 fill-current" }}
61
62
+
{{ if .IsSubmodule }}
63
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
64
+
{{ $icon = "folder-input" }}
65
+
{{ $iconStyle = "size-4" }}
66
+
{{ end }}
67
+
68
{{ if .IsFile }}
69
+
{{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }}
70
{{ $icon = "file" }}
71
{{ $iconStyle = "size-4" }}
72
{{ end }}
73
+
74
<a href="{{ $link }}" class="{{ $linkstyle }}">
75
<div class="flex items-center gap-2">
76
{{ i $icon $iconStyle "flex-shrink-0" }}
+136
-64
appview/repo/blob.go
+136
-64
appview/repo/blob.go
···
1
package repo
2
3
import (
4
"fmt"
5
"io"
6
"net/http"
···
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
21
func (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"
···
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))})
···
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
128
func (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"
···
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 {
···
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")
···
204
}
205
}
206
207
func isTextualMimeType(mimeType string) bool {
208
textualTypes := []string{
209
"application/json",
···
1
package repo
2
3
import (
4
+
"encoding/base64"
5
"fmt"
6
"io"
7
"net/http"
···
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 : | |
34
func (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"
···
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))})
···
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
88
func (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"
···
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 {
···
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")
···
175
}
176
}
177
178
+
// NewBlobView creates a BlobView from the XRPC response
179
+
func 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
+
253
+
func 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
+
279
func isTextualMimeType(mimeType string) bool {
280
textualTypes := []string{
281
"application/json",