This interface allows for returning a Cache-Control header that can vary depending on HTTP 404 vs. HTTP 200. The interface could be expanded in future.
285 lines
7.9 KiB
Go
285 lines
7.9 KiB
Go
package htpack
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"src.lwithers.me.uk/go/htpack/packed"
|
|
)
|
|
|
|
const (
|
|
encodingGzip = "gzip"
|
|
encodingBrotli = "br"
|
|
)
|
|
|
|
// New returns a new handler. Standard security headers are set.
|
|
func New(packfile string) (*Handler, error) {
|
|
mapped, err := packed.MapAndLoad(packfile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
h := &Handler{
|
|
mapped: mapped.Data,
|
|
dir: mapped.Directory.Files,
|
|
headers: make(map[string]string),
|
|
cacheControl200: "no-cache",
|
|
cacheControl404: "no-cache",
|
|
startTime: time.Now(),
|
|
}
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
|
|
h.SetHeader("X-Frame-Options", "SAMEORIGIN")
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
|
|
h.SetHeader("X-Content-Type-Options", "nosniff")
|
|
|
|
return h, nil
|
|
}
|
|
|
|
// Handler implements http.Handler and allows options to be set.
|
|
type Handler struct {
|
|
mapped []byte
|
|
dir map[string]*packed.File
|
|
headers map[string]string
|
|
cacheControl200 string
|
|
cacheControl404 string
|
|
startTime time.Time
|
|
notFound *packed.File
|
|
}
|
|
|
|
// SetHeader allows a custom header to be set on HTTP responses. These are
|
|
// always emitted by ServeHTTP, whether the response status is success or
|
|
// otherwise. Note that you can override the standard security headers
|
|
// (X-Frame-Options and X-Content-Type-Options) using this function. You can
|
|
// remove previously-set headers altogether by passing an empty string for
|
|
// value.
|
|
func (h *Handler) SetHeader(key, value string) {
|
|
if value == "" {
|
|
delete(h.headers, key)
|
|
} else {
|
|
h.headers[key] = value
|
|
}
|
|
}
|
|
|
|
// SetIndex allows setting an index.html (or equivalent) that can be used to
|
|
// serve requests landing at a directory. For instance, if a file named
|
|
// "/foo/index.html" exists, and this function is called with "index.html",
|
|
// then a route will be registered to serve the contents of this file at
|
|
// "/foo". Noting that the ServeHTTP handler discards a trailing "/" on non
|
|
// root URLs, this means that it will serve equivalent content for requests
|
|
// to "/foo/index.html", "/foo/" and "/foo".
|
|
//
|
|
// Existing routes are not overwritten, and this function could be called
|
|
// multiple times with different filenames (noting later calls would not
|
|
// overwrite files matching earlier calls).
|
|
func (h *Handler) SetIndex(filename string) {
|
|
for k, v := range h.dir {
|
|
if filepath.Base(k) == filename {
|
|
routeToAdd := filepath.Dir(k)
|
|
if _, exists := h.dir[routeToAdd]; !exists {
|
|
h.dir[routeToAdd] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetNotFound allows overriding the returned resource when a request is made
|
|
// for a resource that does not exist. The default behaviour would be to return
|
|
// a standard HTTP 404 Not Found response; calling this function with an empty
|
|
// string will restore that behaviour.
|
|
//
|
|
// This function will return an error if the named resource is not present in
|
|
// the packfile.
|
|
func (h *Handler) SetNotFound(notFound string) error {
|
|
if notFound == "" {
|
|
h.notFound = nil
|
|
return nil
|
|
}
|
|
|
|
notFound = path.Clean(notFound)
|
|
dir := h.dir[path.Clean(notFound)]
|
|
if dir == nil {
|
|
return fmt.Errorf("no such resource %q", notFound)
|
|
}
|
|
h.notFound = dir
|
|
return nil
|
|
}
|
|
|
|
// SetCacheControl allows control over the cache-control header. If not called,
|
|
// "Cache-Control: no-cache" is the default.
|
|
//
|
|
// The first and second arguments are required, and specify the header to use
|
|
// for 200 responses and 404 responses respectively.
|
|
//
|
|
// TODO: could be expanded to take extra varargs for more specific control over
|
|
// particular assets, like different expiry per content-type.
|
|
func (h *Handler) SetCacheControl(hdr200, hdr404 string) {
|
|
h.cacheControl200 = hdr200
|
|
h.cacheControl404 = hdr404
|
|
}
|
|
|
|
// ServeHTTP handles requests for files. It supports GET and HEAD methods, with
|
|
// anything else returning a 405. Exact path matches are required, else a 404 is
|
|
// returned.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
// set custom headers before any processing; ensures these are set even
|
|
// on error responses
|
|
for hkey, hval := range h.headers {
|
|
w.Header().Set(hkey, hval)
|
|
}
|
|
|
|
switch req.Method {
|
|
case "HEAD", "GET":
|
|
// OK
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
info := h.dir[path.Clean(req.URL.Path)]
|
|
if info == nil {
|
|
w.Header().Set("Cache-Control", h.cacheControl404)
|
|
if h.notFound == nil {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
info = h.notFound
|
|
}
|
|
|
|
// set standard headers
|
|
w.Header().Set("Cache-Control", h.cacheControl200)
|
|
w.Header().Set("Vary", "Accept-Encoding")
|
|
w.Header().Set("Etag", info.Etag)
|
|
w.Header().Set("Content-Type", info.ContentType)
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
|
|
// process etag / modtime
|
|
if clientHasCachedVersion(info.Etag, h.startTime, req) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
// select compression
|
|
data := info.Uncompressed
|
|
gzip, brotli := acceptedEncodings(req)
|
|
if brotli && info.Brotli != nil {
|
|
data = info.Brotli
|
|
w.Header().Set("Content-Encoding", encodingBrotli)
|
|
} else if gzip && info.Gzip != nil {
|
|
data = info.Gzip
|
|
w.Header().Set("Content-Encoding", encodingGzip)
|
|
}
|
|
|
|
// range support (single-part ranges only)
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests#Single_part_ranges
|
|
offset, length, isPartial := getFileRange(data, req)
|
|
if isPartial {
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d",
|
|
offset, offset+length-1, data.Length))
|
|
}
|
|
|
|
// now we know exactly what we're writing, finalise HTTP header
|
|
w.Header().Set("Content-Length", strconv.FormatUint(length, 10))
|
|
if isPartial {
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
} else {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// send body (though not for HEAD)
|
|
if req.Method == "HEAD" {
|
|
return
|
|
}
|
|
|
|
offset += data.Offset
|
|
w.Write(h.mapped[offset : offset+length])
|
|
}
|
|
|
|
func acceptedEncodings(req *http.Request) (gzip, brotli bool) {
|
|
encodings := req.Header.Get("Accept-Encoding")
|
|
for _, enc := range strings.Split(encodings, ",") {
|
|
switch strings.TrimSpace(enc) {
|
|
case encodingGzip:
|
|
gzip = true
|
|
case encodingBrotli:
|
|
brotli = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// clientHasCachedVersion returns true if the client has a cached version of
|
|
// the resource. We'll check the etags presented by the client, but if etags
|
|
// are not present then we'll check the if-modified-since date.
|
|
func clientHasCachedVersion(etag string, startTime time.Time, req *http.Request,
|
|
) bool {
|
|
checkEtags := req.Header.Get("If-None-Match")
|
|
for _, check := range strings.Split(checkEtags, ",") {
|
|
if etag == strings.TrimSpace(check) {
|
|
// client knows the etag, so it has this version of the
|
|
// resource cached already
|
|
return true
|
|
}
|
|
}
|
|
|
|
// if the client presented etags at all, we use that as our definitive
|
|
// answer
|
|
if _, sawEtags := req.Header["If-None-Match"]; sawEtags {
|
|
return false
|
|
}
|
|
|
|
// check the timestamp the client last grabbed the resource
|
|
cachedTime, err := http.ParseTime(req.Header.Get("If-Modified-Since"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return cachedTime.After(startTime)
|
|
}
|
|
|
|
// getFileRange returns the byte offset and length of the file to serve, along
|
|
// with whether or not it's partial content.
|
|
func getFileRange(data *packed.FileData, req *http.Request) (offset, length uint64, isPartial bool) {
|
|
length = data.Length
|
|
|
|
// only accept "Range: bytes=…"
|
|
r := req.Header.Get("Range")
|
|
if !strings.HasPrefix(r, "bytes=") {
|
|
return
|
|
}
|
|
r = strings.TrimPrefix(r, "bytes=")
|
|
|
|
// only accept a single range, "from-to", mapping to interval [from,to]
|
|
pos := strings.IndexByte(r, '-')
|
|
if pos == -1 {
|
|
return
|
|
}
|
|
sfrom, sto := r[:pos], r[pos+1:]
|
|
from, err := strconv.ParseUint(sfrom, 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
to, err := strconv.ParseUint(sto, 10, 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// validate the interval lies within the file
|
|
switch {
|
|
case from > to,
|
|
from >= data.Length,
|
|
to >= data.Length:
|
|
return
|
|
}
|
|
|
|
// all good
|
|
offset = from
|
|
length = to - from + 1
|
|
isPartial = true
|
|
return
|
|
}
|