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