v0.13.0 dc42452

raw.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
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)
19
20// rawTimeout bounds the stream of one file to the visitor.
21const rawTimeout = 5 * time.Minute
22
23// rawChunkSize is the size of the writes that stream a file to the visitor.
24const rawChunkSize = 64 << 10
25
26// rawCSP makes a raw file inert if a browser opens it as a document: it can
27// load nothing and run nothing, whatever it contains.
28const rawCSP = "default-src 'none'; sandbox"
29
30// raw streams the content of a file. Text is served as text/plain, the four
31// image types (by their bytes, never SVG) as images, and anything else as an
32// attachment of unknown type. A file is never served as HTML or SVG, so what
33// a repository holds cannot run in the origin of the forge.
34//
35//hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here
36func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
37	ctx, cancel := context.WithTimeout(r.Context(), rawTimeout)
38	defer cancel()
39	t, repo, err := s.rawTarget(ctx, r)
40	if err != nil {
41		s.fail(ctx, w, r, err)
42		return
43	}
44	b, err := s.openBlob(ctx, repo, t)
45	if err != nil {
46		s.fail(ctx, w, r, err)
47		return
48	}
49	defer b.close()
50
51	// The type needs the first bytes; headers must not be sent before.
52	if err := b.fill(512); err != nil {
53		s.fail(ctx, w, r, err)
54		return
55	}
56	typ, attach := rawType(b)
57	h := w.Header()
58	h.Set("Content-Type", typ)
59	h.Set("X-Content-Type-Options", "nosniff")
60	h.Set("Content-Security-Policy", rawCSP)
61	h.Set("Content-Length", strconv.FormatInt(b.info.Size, 10))
62	if attach {
63		name := path.Base(t.path)
64		cd := mime.FormatMediaType("attachment", map[string]string{"filename": name})
65		if cd == "" {
66			cd = "attachment"
67		}
68		h.Set("Content-Disposition", cd)
69	}
70	if etag := b.info.ID.String(); etag != "" {
71		h.Set("ETag", `"`+etag+`"`)
72	}
73	// Revalidated every time, even at a full commit id: a repository can turn
74	// private, and a shared cache must not keep serving its files (AC2). The
75	// ETag spares the body: the blob id comes with the metadata, before the
76	// content, and a match ends the answer there. No cheaper call gives the
77	// id of the blob at a path: a tree listing of its directory costs more
78	// than opening a small file.
79	cacheHeaders(ctx, h, "no-cache", sessionCache)
80	if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) {
81		w.WriteHeader(http.StatusNotModified)
82		return
83	}
84	w.WriteHeader(http.StatusOK)
85	if r.Method == http.MethodHead {
86		return
87	}
88	if _, err := w.Write(b.buf); err != nil {
89		return
90	}
91	b.buf = nil
92	if b.eof {
93		return
94	}
95	chunk := make([]byte, rawChunkSize)
96	for {
97		n, rerr := b.body.Read(chunk)
98		if n > 0 {
99			if _, err := w.Write(chunk[:n]); err != nil {
100				return
101			}
102		}
103		if errors.Is(rerr, io.EOF) {
104			return
105		}
106		if rerr != nil {
107			// Headers are gone: all that is left is to cut the answer short,
108			// so that the client sees it is incomplete.
109			log := zerowrap.FromCtx(ctx)
110			log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
111			panic(http.ErrAbortHandler)
112		}
113	}
114}
115
116// rawTarget finds the file a raw URL designates and the name of its repository.
117func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, domain.RepoName, error) {
118	d, err := s.loadRepo(ctx, r.PathValue("repo"))
119	if err != nil {
120		return target{}, domain.RepoName{}, err
121	}
122	t, ok := d.locate(r.PathValue("rest"))
123	if !ok || t.path == "" {
124		return target{}, domain.RepoName{}, errNotFound
125	}
126	return t, d.repo.Name, nil
127}
128
129// rawType is the Content-Type of a file and whether it is an attachment.
130// b.buf holds the first bytes (up to 512 when the file has that many).
131func rawType(b *blobStream) (typ string, attachment bool) {
132	if img := sniffImage(b.buf); img != "" {
133		return img, false
134	}
135	if !b.info.Binary {
136		return "text/plain; charset=utf-8", false
137	}
138	return "application/octet-stream", true
139}

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