main 1fb2421

raw.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"context"
8	"io"
9	"mime"
10	"net/http"
11	"path"
12	"strconv"
13	"time"
14
15	"github.com/bnema/zerowrap"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/httprange"
19)
20
21// rawTimeout bounds the stream of one file to the visitor.
22const rawTimeout = 5 * time.Minute
23
24// rawChunkSize is the size of the writes that stream a file to the visitor.
25const rawChunkSize = 64 << 10
26
27// rawCSP makes a raw file inert if a browser opens it as a document: it can
28// load nothing and run nothing, whatever it contains.
29const rawCSP = "default-src 'none'; sandbox"
30
31// raw streams the content of a file. Text is served as text/plain, the four
32// image types and videos (by their bytes, never SVG) inline, and anything else as an
33// attachment of unknown type. A file is never served as HTML or SVG, so what
34// a repository holds cannot run in the origin of the forge.
35func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
36	ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
37	defer cancel()
38	t, repo, err := s.rawTarget(ctx, r)
39	if err != nil {
40		s.fail(ctx, w, r, err)
41		return
42	}
43	b, err := s.openBlob(ctx, repo, t)
44	if err != nil {
45		s.fail(ctx, w, r, err)
46		return
47	}
48	defer b.close()
49
50	// The type needs the first bytes; headers must not be sent before.
51	if err := b.fill(512); err != nil {
52		s.fail(ctx, w, r, err)
53		return
54	}
55	typ, attach := rawType(b)
56	h := w.Header()
57	h.Set("Content-Type", typ)
58	h.Set("X-Content-Type-Options", "nosniff")
59	h.Set("Content-Security-Policy", rawCSP)
60	h.Set("Content-Length", strconv.FormatInt(b.info.Size, 10))
61	if attach {
62		name := path.Base(t.path)
63		cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
64		if cd == "" {
65			cd = "attachment"
66		}
67		h.Set("Content-Disposition", cd)
68	}
69	if etag := b.info.ID.String(); etag != "" {
70		h.Set("ETag", `"`+etag+`"`)
71	}
72	// Revalidated every time, even at a full commit id: a repository can turn
73	// private, and a shared cache must not keep serving its files (AC2). The
74	// ETag spares the body: the blob id comes with the metadata, before the
75	// content, and a match ends the answer there. No cheaper call gives the
76	// id of the blob at a path: a tree listing of its directory costs more
77	// than opening a small file.
78	cacheHeaders(ctx, h, "no-cache", sessionCache)
79	if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) {
80		w.WriteHeader(http.StatusNotModified)
81		return
82	}
83
84	// Git blob readers are forward-only. Seeking discards the prefix for each
85	// request: bounded memory, but O(offset) I/O, intended for small video clips.
86	// Reuse the single-range policy; multipart ranges are ignored, never buffered.
87	h.Set("Accept-Ranges", "bytes")
88	offset, length, partial, err := httprange.Select(r, b.info.Size, h.Get("ETag"))
89	if err != nil {
90		h.Set("Content-Range", httprange.Unsatisfied(b.info.Size))
91		h.Set("Content-Length", "0")
92		w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
93		return
94	}
95	status := http.StatusOK
96	if partial {
97		status = http.StatusPartialContent
98		h.Set("Content-Range", httprange.ContentRange(offset, length, b.info.Size))
99	}
100	h.Set("Content-Length", strconv.FormatInt(length, 10))
101	w.WriteHeader(status)
102	if r.Method == http.MethodHead {
103		return
104	}
105	reader := io.MultiReader(bytes.NewReader(b.buf), b.body)
106	if _, err = io.CopyN(io.Discard, reader, offset); err == nil {
107		var written int64
108		written, err = io.CopyBuffer(w, io.LimitReader(reader, length), make([]byte, rawChunkSize))
109		if err == nil && written != length {
110			err = io.ErrUnexpectedEOF
111		}
112	}
113	if err != nil {
114		log := zerowrap.FromCtx(ctx)
115		log.Error().Err(err).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
116		panic(http.ErrAbortHandler)
117	}
118}
119
120// rawTarget finds the file a raw URL designates and the name of its repository.
121func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, domain.RepoName, error) {
122	d, err := s.loadRepo(ctx, r.PathValue("repo"))
123	if err != nil {
124		return target{}, domain.RepoName{}, err
125	}
126	t, ok := d.locate(r.PathValue("rest"))
127	if !ok || t.path == "" {
128		return target{}, domain.RepoName{}, errNotFound
129	}
130	return t, d.repo.Name, nil
131}
132
133// rawType is the Content-Type of a file and whether it is an attachment.
134// b.buf holds the first bytes (up to 512 when the file has that many).
135func rawType(b *blobStream) (typ string, attachment bool) {
136	if img := sniffImage(b.buf); img != "" {
137		return img, false
138	}
139	if video := sniffVideo(b.buf); video != "" {
140		return video, false
141	}
142	if !b.info.Binary {
143		return "text/plain; charset=utf-8", false
144	}
145	return "application/octet-stream", true
146}
147
148// sniffVideo admits known video signatures only, never HTML or SVG by extension.
149func sniffVideo(head []byte) string {
150	switch http.DetectContentType(head) {
151	case "video/mp4":
152		return "video/mp4"
153	case "video/webm":
154		return "video/webm"
155	}
156	if len(head) >= 12 && string(head[4:8]) == "ftyp" {
157		switch string(head[8:12]) {
158		case "qt  ":
159			return "video/quicktime"
160		case "M4V ", "isom", "iso2", "mp41", "mp42", "avc1":
161			return "video/mp4"
162		}
163	}
164	// Ogg is a container for audio as well: require the Theora identification packet.
165	if bytes.HasPrefix(head, []byte("OggS\x00")) && bytes.Contains(head, []byte("\x80theora")) {
166		return "video/ogg"
167	}
168	return ""
169}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close