internal/vfs/serving/serving.go (224 lines of code) (raw):
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package serving
import (
"context"
"errors"
"io"
"net/http"
"net/textproto"
"strings"
"time"
"gitlab.com/gitlab-org/gitlab-pages/internal/errortracking"
"gitlab.com/gitlab-org/gitlab-pages/internal/feature"
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
"gitlab.com/gitlab-org/gitlab-pages/internal/logging"
"gitlab.com/gitlab-org/gitlab-pages/internal/logging/slowlogs"
"gitlab.com/gitlab-org/gitlab-pages/internal/vfs"
)
var errUnknownContentType = errors.New("serving: unknown content type")
// ServeCompressedFile is a modified version of https://github.com/golang/go/blob/go1.16.10/src/net/http/fs.go#L192
func ServeCompressedFile(w http.ResponseWriter, req *http.Request, modtime time.Time, content vfs.File) {
serveContent(w, req, modtime, content)
}
// serveContent is a modified version of https://github.com/golang/go/blob/go1.16.10/src/net/http/fs.go#L221
// this function relies on the assumption that a Content-Type header is set
func serveContent(w http.ResponseWriter, r *http.Request, modtime time.Time, content vfs.File) {
defer slowlogs.Recorder(r.Context(), "serving.serveContent")()
setLastModified(w, modtime)
done := checkPreconditions(w, r, modtime)
if done {
return
}
code := http.StatusOK
_, haveType := w.Header()["Content-Type"]
if !haveType {
// this shouldn't happen
errortracking.CaptureErrWithReqAndStackTrace(errUnknownContentType, r)
logging.LogRequest(r).WithError(errUnknownContentType).Error("could not serve content")
httperrors.Serve500(w)
return
}
if r.Method == http.MethodHead {
w.WriteHeader(code)
return
}
handleContentCopy(w, r, content)
}
func handleContentCopy(w http.ResponseWriter, r *http.Request, content vfs.File) {
defer slowlogs.Recorder(r.Context(), "serving.handleContentCopy")()
if _, err := io.Copy(w, content); err != nil {
if errors.Is(err, &vfs.ReadError{}) {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return
}
logging.LogRequest(r).WithField("handle_read_errors", feature.HandleReadErrors.Enabled()).WithError(err).Error("error reading content")
if feature.HandleReadErrors.Enabled() {
errortracking.CaptureErrWithReqAndStackTrace(err, r)
logging.LogRequest(r).WithError(err).Error("error reading content")
httperrors.Serve500(w)
}
}
}
}
// scanETag determines if a syntactically valid ETag is present at s. If so,
// the ETag and remaining text after consuming ETag is returned. Otherwise,
// it returns "", "".
func scanETag(s string) (etag string, remain string) {
s = textproto.TrimString(s)
start := 0
if strings.HasPrefix(s, "W/") {
start = 2
}
if len(s[start:]) < 2 || s[start] != '"' {
return "", ""
}
// ETag is either W/"text" or "text".
// See RFC 7232 2.3.
for i := start + 1; i < len(s); i++ {
c := s[i]
switch {
// Character values allowed in ETags.
case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80:
case c == '"':
return s[:i+1], s[i+1:]
default:
return "", ""
}
}
return "", ""
}
// etagStrongMatch reports whether a and b match using strong ETag comparison.
// Assumes a and b are valid ETags.
func etagStrongMatch(a, b string) bool {
return a == b && a != "" && a[0] == '"'
}
// etagWeakMatch reports whether a and b match using weak ETag comparison.
// Assumes a and b are valid ETags.
func etagWeakMatch(a, b string) bool {
return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
}
// condResult is the result of an HTTP request precondition check.
// See https://tools.ietf.org/html/rfc7232 section 3.
type condResult int
const (
condNone condResult = iota
condTrue
condFalse
)
func checkIfMatch(w http.ResponseWriter, r *http.Request) condResult {
im := r.Header.Get("If-Match")
if im == "" {
return condNone
}
for {
im = textproto.TrimString(im)
if len(im) == 0 {
break
}
if im[0] == ',' {
im = im[1:]
continue
}
if im[0] == '*' {
return condTrue
}
etag, remain := scanETag(im)
if etag == "" {
break
}
if etagStrongMatch(etag, w.Header().Get("Etag")) {
return condTrue
}
im = remain
}
return condFalse
}
func checkIfUnmodifiedSince(r *http.Request, modtime time.Time) condResult {
ius := r.Header.Get("If-Unmodified-Since")
if ius == "" || isZeroTime(modtime) {
return condNone
}
t, err := http.ParseTime(ius)
if err != nil {
return condNone
}
// The Last-Modified header truncates sub-second precision so
// the modtime needs to be truncated too.
modtime = modtime.Truncate(time.Second)
if modtime.Before(t) || modtime.Equal(t) {
return condTrue
}
return condFalse
}
func checkIfNoneMatch(w http.ResponseWriter, r *http.Request) condResult {
inm := r.Header.Get("If-None-Match")
if inm == "" {
return condNone
}
buf := inm
for {
buf = textproto.TrimString(buf)
if len(buf) == 0 {
break
}
if buf[0] == ',' {
buf = buf[1:]
continue
}
if buf[0] == '*' {
return condFalse
}
etag, remain := scanETag(buf)
if etag == "" {
break
}
if etagWeakMatch(etag, w.Header().Get("Etag")) {
return condFalse
}
buf = remain
}
return condTrue
}
func checkIfModifiedSince(r *http.Request, modtime time.Time) condResult {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return condNone
}
ims := r.Header.Get("If-Modified-Since")
if ims == "" || isZeroTime(modtime) {
return condNone
}
t, err := http.ParseTime(ims)
if err != nil {
return condNone
}
// The Last-Modified header truncates sub-second precision so
// the modtime needs to be truncated too.
modtime = modtime.Truncate(time.Second)
if modtime.Before(t) || modtime.Equal(t) {
return condFalse
}
return condTrue
}
var unixEpochTime = time.Unix(0, 0)
// isZeroTime reports whether t is obviously unspecified (either zero or Unix()=0).
func isZeroTime(t time.Time) bool {
return t.IsZero() || t.Equal(unixEpochTime)
}
func setLastModified(w http.ResponseWriter, modtime time.Time) {
if !isZeroTime(modtime) {
w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat))
}
}
func writeNotModified(w http.ResponseWriter) {
// RFC 7232 section 4.1:
// a sender SHOULD NOT generate representation metadata other than the
// above listed fields unless said metadata exists for the purpose of
// guiding cache updates (e.g., Last-Modified might be useful if the
// response does not have an ETag field).
h := w.Header()
delete(h, "Content-Type")
delete(h, "Content-Length")
if h.Get("Etag") != "" {
delete(h, "Last-Modified")
}
w.WriteHeader(http.StatusNotModified)
}
// checkPreconditions evaluates request preconditions and reports whether a precondition
// resulted in sending StatusNotModified or StatusPreconditionFailed.
func checkPreconditions(w http.ResponseWriter, r *http.Request, modtime time.Time) (done bool) {
defer slowlogs.Recorder(r.Context(), "serving.checkPreconditions")()
// This function carefully follows RFC 7232 section 6.
ch := checkIfMatch(w, r)
if ch == condNone {
ch = checkIfUnmodifiedSince(r, modtime)
}
if ch == condFalse {
w.WriteHeader(http.StatusPreconditionFailed)
return true
}
switch checkIfNoneMatch(w, r) {
case condFalse:
if r.Method == http.MethodGet || r.Method == http.MethodHead {
writeNotModified(w)
return true
}
w.WriteHeader(http.StatusPreconditionFailed)
return true
case condNone:
if checkIfModifiedSince(r, modtime) == condFalse {
writeNotModified(w)
return true
}
}
return false
}