1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "html/template"
9 "net/http"
10 "path"
11 "strings"
12
13 "connectrpc.com/connect"
14 "github.com/a-h/templ"
15 "github.com/bnema/zerowrap"
16
17 pb "bnema.dev/ermite-api/gen/ermite"
18)
19
20const (
21 // maxCodeBytes is the largest file shown in the page; a larger one gets
22 // a notice and the link to raw.
23 maxCodeBytes = 1 << 20
24 // maxRenderBytes is the largest Markdown file that is rendered; above it
25 // the file is shown as source only.
26 maxRenderBytes = 512 << 10
27)
28
29// BlobPage is a file.
30type BlobPage struct {
31 Name string
32 Notice string
33 // Code is the highlighted source, shown when HasCode.
34 Code template.HTML
35 HasCode bool
36 // Rendered is a Markdown file as HTML; the source is under a fold.
37 Rendered *Readme
38 Outline []Heading
39 // Image says the file is shown as an image loaded from RawHref.
40 Image bool
41 Facts []Fact
42 Last *CommitRow
43 RawHref string
44 HistHref string
45 DirHref string
46}
47
48// blobStream is an open GetBlob stream whose first message has been read.
49type blobStream struct {
50 stream *connect.ServerStreamForClient[pb.GetBlobResponse]
51 info *pb.BlobInfo
52 // buf holds the content already received and not yet handed out.
53 buf []byte
54}
55
56// openBlob starts the stream of a file and reads its metadata.
57func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) {
58 if t.path == "" {
59 return nil, errNotFound
60 }
61 st, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: t.rev, Path: t.path}))
62 if err != nil {
63 return nil, err
64 }
65 if !st.Receive() {
66 err := st.Err()
67 _ = st.Close()
68 if err == nil {
69 err = errNotFound
70 }
71 return nil, err
72 }
73 m := st.Msg()
74 return &blobStream{stream: st, info: m.GetInfo(), buf: append([]byte(nil), m.GetChunk()...)}, nil
75}
76
77func (b *blobStream) close() { _ = b.stream.Close() }
78
79// fill reads from the stream until buf holds n bytes or the content ends.
80func (b *blobStream) fill(n int) error {
81 for len(b.buf) < n && b.stream.Receive() {
82 b.buf = append(b.buf, b.stream.Msg().GetChunk()...)
83 }
84 return b.stream.Err()
85}
86
87// image types: the only images the pages show and raw serves as images.
88var imageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
89
90// sniffImage is the image type of the first bytes of a file, "" when it is
91// not a PNG, JPEG, GIF or WebP. SVG is never one: it can carry scripts.
92func sniffImage(head []byte) string {
93 if len(head) > 512 {
94 head = head[:512]
95 }
96 if t := http.DetectContentType(head); imageTypes[t] {
97 return t
98 }
99 return ""
100}
101
102// blob shows a file: highlighted source, rendered Markdown with its source
103// under a fold, an image, or a notice when the file is too large or binary.
104func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, int, error) {
105 d, err := s.loadRepo(ctx, r.PathValue("repo"))
106 if err != nil {
107 return nil, 0, err
108 }
109 t, ok := d.locate(r.PathValue("rest"))
110 if !ok {
111 return nil, 0, errNotFound
112 }
113 b, err := s.openBlob(ctx, d.name(), t)
114 if err != nil {
115 return nil, 0, err
116 }
117 defer b.close()
118
119 name := path.Base(t.path)
120 dir := path.Dir(t.path)
121 if dir == "." {
122 dir = ""
123 }
124 size := b.info.GetSize()
125 p := BlobPage{
126 Name: visibleText(name),
127 RawHref: repoHref(d.name(), "raw", t.name, t.path),
128 HistHref: repoHref(d.name(), "commits", t.name, t.path),
129 DirHref: repoHref(d.name(), "tree", t.name, dir),
130 Facts: []Fact{{Key: "size", Val: humanSize(size)}},
131 }
132 typ := b.info.GetMediaType()
133 lang := ""
134
135 // An image is recognised by its bytes, whatever Git's text rule says.
136 if err := b.fill(512); err != nil {
137 return nil, 0, err
138 }
139 img := sniffImage(b.buf)
140
141 var src []byte
142 switch {
143 case img != "":
144 p.Image, typ = true, img
145 case b.info.GetBinary():
146 p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size))
147 case size > maxCodeBytes:
148 p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes))
149 case size == 0:
150 p.Notice = "empty file."
151 default:
152 if err := b.fill(int(size)); err != nil {
153 return nil, 0, err
154 }
155 src = b.buf
156 }
157
158 if src != nil {
159 text := string(src)
160 lines := countLines(src)
161 p.Facts = append(p.Facts, Fact{Key: "lines", Val: itoa(lines)})
162 if lines > maxViewLines {
163 text = firstLines(text, maxViewLines)
164 p.Notice = fmt.Sprintf("%d lines: showing the first %d. use raw for the rest.", lines, maxViewLines)
165 }
166 cv := highlightCtx(ctx, t.path, text)
167 p.Code, p.HasCode, lang = cv.html, true, cv.lang
168 if isMarkdown(name) && size <= maxRenderBytes {
169 h, outline, err := s.md.render(src, d.name(), t.name, dir)
170 if err == nil {
171 p.Rendered, p.Outline = &Readme{Name: p.Name, HTML: h}, outline
172 } else {
173 log := zerowrap.FromCtx(ctx)
174 log.Warn().Err(err).Str("repo", d.name()).Msg("render Markdown file, showing source")
175 }
176 }
177 }
178 if lang == "" {
179 lang = typ
180 }
181 p.Facts = append(p.Facts, Fact{Key: "type", Val: lang})
182
183 if last := s.lastChange(ctx, d.name(), t); last != nil {
184 p.Last = last
185 }
186
187 l := s.repoLayout(ctx, d, "tree")
188 pinRef(&l, t)
189 l.Crumbs = pathCrumbs(d.name(), "blob", t)
190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191 l.Status = Status{
192 Mode: "blob", Context: p.Name,
193 Hints: "click a line number · y permalink · ? help", Blob: p.HasCode,
194 }
195 l.HasRail, l.Read, l.RailSum = true, p.Rendered != nil, "file · last change · selection"
196 return Blob(l, p), http.StatusOK, nil
197}
198
199// lastChange is the latest commit that touched the path, nil when it cannot
200// be found (the page does not depend on it).
201func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow {
202 res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
203 Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1,
204 }))
205 if err != nil || len(res.Msg.GetCommits()) != 1 {
206 return nil
207 }
208 row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now())
209 return &row
210}
211
212// isMarkdown says whether a file name is a Markdown file.
213func isMarkdown(name string) bool {
214 n := strings.ToLower(name)
215 return strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".markdown")
216}
217
218// firstLines is the first n lines of s.
219func firstLines(s string, n int) string {
220 idx := 0
221 for i := 0; i < n; i++ {
222 j := strings.IndexByte(s[idx:], '\n')
223 if j < 0 {
224 return s
225 }
226 idx += j + 1
227 }
228 return s[:idx]
229}