286 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			286 lines
		
	
	
		
			7.4 KiB
		
	
	
	
		
			Go
		
	
	
	
| package htpack
 | |
| 
 | |
| import (
 | |
| 	"fmt"
 | |
| 	"net/http"
 | |
| 	"os"
 | |
| 	"path"
 | |
| 	"path/filepath"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"golang.org/x/sys/unix"
 | |
| 	"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) {
 | |
| 	f, err := os.Open(packfile)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	defer f.Close()
 | |
| 
 | |
| 	fi, err := f.Stat()
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	mapped, err := unix.Mmap(int(f.Fd()), 0, int(fi.Size()),
 | |
| 		unix.PROT_READ, unix.MAP_SHARED)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	_, dir, err := packed.Load(f)
 | |
| 	if err != nil {
 | |
| 		unix.Munmap(mapped)
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	h := &Handler{
 | |
| 		mapped:    mapped,
 | |
| 		dir:       dir.Files,
 | |
| 		headers:   make(map[string]string),
 | |
| 		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
 | |
| 	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
 | |
| }
 | |
| 
 | |
| // 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 {
 | |
| 		if h.notFound == nil {
 | |
| 			http.NotFound(w, req)
 | |
| 			return
 | |
| 		}
 | |
| 		info = h.notFound
 | |
| 	}
 | |
| 
 | |
| 	// set standard headers
 | |
| 	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
 | |
| }
 |