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}