Handler: add NewMapped, AddMapped
The Handler now supports serving multiple packfiles, possibly with different prefixes. Precedence is given by order in which packfiles are registered.
This commit is contained in:
parent
5bece326fb
commit
5b8873a4f5
3 changed files with 149 additions and 36 deletions
1
go.mod
1
go.mod
|
|
@ -3,6 +3,7 @@ module src.lwithers.me.uk/go/htpack
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/google/go-cmp v0.7.0
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.45.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
|
||||||
135
handler.go
135
handler.go
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -24,10 +23,19 @@ func New(packfile string) (*Handler, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return NewMapped("/", mapped), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMapped returns a new handler, given an already-mapped datafile. Standard
|
||||||
|
// security headers are set.
|
||||||
|
//
|
||||||
|
// Files are served under the directory prefix given by path, which may be
|
||||||
|
// empty or "/" for the root path. Note the [Handler.ServeMapped] method may specify
|
||||||
|
// additional files to be served under different paths, not affected by this
|
||||||
|
// path.
|
||||||
|
func NewMapped(path string, mapped *packed.Mapped) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
mapped: mapped.Data,
|
files: make(map[string]handlerFile),
|
||||||
dir: mapped.Directory.Files,
|
|
||||||
headers: make(map[string]string),
|
headers: make(map[string]string),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
@ -38,16 +46,24 @@ func New(packfile string) (*Handler, error) {
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
|
||||||
h.SetHeader("X-Content-Type-Options", "nosniff")
|
h.SetHeader("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
return h, nil
|
h.addFiles(path, mapped)
|
||||||
|
|
||||||
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler implements http.Handler and allows options to be set.
|
// Handler implements http.Handler and allows options to be set.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
mapped []byte
|
files map[string]handlerFile
|
||||||
dir map[string]*packed.File
|
|
||||||
headers map[string]string
|
headers map[string]string
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
notFound *packed.File
|
notFound *handlerFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMapped serves up additional files from the given mapped packfile. The
|
||||||
|
// given path is taken as a serving prefix. If a file for a given path already
|
||||||
|
// exists, the existing file takes precedence.
|
||||||
|
func (h *Handler) AddMapped(path string, mapped *packed.Mapped) {
|
||||||
|
h.addFiles(path, mapped)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetHeader allows a custom header to be set on HTTP responses. These are
|
// SetHeader allows a custom header to be set on HTTP responses. These are
|
||||||
|
|
@ -76,11 +92,11 @@ func (h *Handler) SetHeader(key, value string) {
|
||||||
// multiple times with different filenames (noting later calls would not
|
// multiple times with different filenames (noting later calls would not
|
||||||
// overwrite files matching earlier calls).
|
// overwrite files matching earlier calls).
|
||||||
func (h *Handler) SetIndex(filename string) {
|
func (h *Handler) SetIndex(filename string) {
|
||||||
for k, v := range h.dir {
|
for k, v := range h.files {
|
||||||
if filepath.Base(k) == filename {
|
if path.Base(k) == filename {
|
||||||
routeToAdd := filepath.Dir(k)
|
routeToAdd := path.Dir(k)
|
||||||
if _, exists := h.dir[routeToAdd]; !exists {
|
if _, exists := h.files[routeToAdd]; !exists {
|
||||||
h.dir[routeToAdd] = v
|
h.files[routeToAdd] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,14 +116,62 @@ func (h *Handler) SetNotFound(notFound string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
notFound = path.Clean(notFound)
|
notFound = path.Clean(notFound)
|
||||||
dir := h.dir[path.Clean(notFound)]
|
f, ok := h.files[path.Clean(notFound)]
|
||||||
if dir == nil {
|
if !ok {
|
||||||
return fmt.Errorf("no such resource %q", notFound)
|
return fmt.Errorf("no such resource %q", notFound)
|
||||||
}
|
}
|
||||||
h.notFound = dir
|
h.notFound = &f
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type handlerFile struct {
|
||||||
|
contentType, etag string
|
||||||
|
uncompressed, gzip, brotli, zstd []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) addFiles(prefix string, mapped *packed.Mapped) {
|
||||||
|
// our directory contains files like "/index.html". We will add
|
||||||
|
// a prefix, which might be "" or "/foo".
|
||||||
|
prefix = path.Clean(prefix)
|
||||||
|
prefix = strings.TrimSuffix(prefix, "/")
|
||||||
|
switch {
|
||||||
|
case prefix == "":
|
||||||
|
case prefix == ".":
|
||||||
|
prefix = ""
|
||||||
|
case !strings.HasPrefix(prefix, "/"):
|
||||||
|
prefix = "/" + prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
for filePath, file := range mapped.Directory.Files {
|
||||||
|
handlerPath := prefix + filePath
|
||||||
|
if _, exists := h.files[handlerPath]; exists {
|
||||||
|
// don't overwrite existing entries
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.files[handlerPath] = buildHandlerFile(mapped.Data, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHandlerFile(data []byte, file *packed.File) handlerFile {
|
||||||
|
byteSlice := func(fileData *packed.FileData) []byte {
|
||||||
|
if fileData == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
start := fileData.Offset
|
||||||
|
end := start + fileData.Length
|
||||||
|
return data[start:end]
|
||||||
|
}
|
||||||
|
hf := handlerFile{
|
||||||
|
contentType: file.ContentType,
|
||||||
|
etag: file.Etag,
|
||||||
|
}
|
||||||
|
hf.uncompressed = byteSlice(file.Uncompressed)
|
||||||
|
hf.gzip = byteSlice(file.Gzip)
|
||||||
|
hf.brotli = byteSlice(file.Brotli)
|
||||||
|
hf.zstd = byteSlice(file.Zstd)
|
||||||
|
return hf
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHTTP handles requests for files. It supports GET and HEAD methods, with
|
// 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
|
// anything else returning a 405. Exact path matches are required, else a 404 is
|
||||||
// returned.
|
// returned.
|
||||||
|
|
@ -126,48 +190,48 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info := h.dir[path.Clean(req.URL.Path)]
|
info, ok := h.files[path.Clean(req.URL.Path)]
|
||||||
if info == nil {
|
if !ok {
|
||||||
if h.notFound == nil {
|
if h.notFound == nil {
|
||||||
http.NotFound(w, req)
|
http.NotFound(w, req)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
info = h.notFound
|
info = *h.notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// set standard headers
|
// set standard headers
|
||||||
w.Header().Set("Vary", "Accept-Encoding")
|
w.Header().Set("Vary", "Accept-Encoding")
|
||||||
w.Header().Set("Etag", info.Etag)
|
w.Header().Set("Etag", info.etag)
|
||||||
w.Header().Set("Content-Type", info.ContentType)
|
w.Header().Set("Content-Type", info.contentType)
|
||||||
w.Header().Set("Accept-Ranges", "bytes")
|
w.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
|
||||||
// process etag / modtime
|
// process etag / modtime
|
||||||
if clientHasCachedVersion(info.Etag, h.startTime, req) {
|
if clientHasCachedVersion(info.etag, h.startTime, req) {
|
||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// select compression
|
// select compression
|
||||||
data := info.Uncompressed
|
data := info.uncompressed
|
||||||
gzip, brotli, zstd := acceptedEncodings(req)
|
gzip, brotli, zstd := acceptedEncodings(req)
|
||||||
switch {
|
switch {
|
||||||
case zstd && info.Zstd != nil:
|
case zstd && info.zstd != nil:
|
||||||
data = info.Zstd
|
data = info.zstd
|
||||||
w.Header().Set("Content-Encoding", encodingZstd)
|
w.Header().Set("Content-Encoding", encodingZstd)
|
||||||
case brotli && info.Brotli != nil:
|
case brotli && info.brotli != nil:
|
||||||
data = info.Brotli
|
data = info.brotli
|
||||||
w.Header().Set("Content-Encoding", encodingBrotli)
|
w.Header().Set("Content-Encoding", encodingBrotli)
|
||||||
case gzip && info.Gzip != nil:
|
case gzip && info.gzip != nil:
|
||||||
data = info.Gzip
|
data = info.gzip
|
||||||
w.Header().Set("Content-Encoding", encodingGzip)
|
w.Header().Set("Content-Encoding", encodingGzip)
|
||||||
}
|
}
|
||||||
|
|
||||||
// range support (single-part ranges only)
|
// range support (single-part ranges only)
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests#Single_part_ranges
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests#Single_part_ranges
|
||||||
offset, length, isPartial := getFileRange(data, req)
|
offset, length, isPartial := getFileRange(uint64(len(data)), req)
|
||||||
if isPartial {
|
if isPartial {
|
||||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d",
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d",
|
||||||
offset, offset+length-1, data.Length))
|
offset, offset+length-1, len(data)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// now we know exactly what we're writing, finalise HTTP header
|
// now we know exactly what we're writing, finalise HTTP header
|
||||||
|
|
@ -183,8 +247,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
offset += data.Offset
|
w.Write(data[offset : offset+length])
|
||||||
w.Write(h.mapped[offset : offset+length])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func acceptedEncodings(req *http.Request) (gzip, brotli, zstd bool) {
|
func acceptedEncodings(req *http.Request) (gzip, brotli, zstd bool) {
|
||||||
|
|
@ -230,8 +293,8 @@ func clientHasCachedVersion(etag string, startTime time.Time, req *http.Request,
|
||||||
|
|
||||||
// getFileRange returns the byte offset and length of the file to serve, along
|
// getFileRange returns the byte offset and length of the file to serve, along
|
||||||
// with whether or not it's partial content.
|
// with whether or not it's partial content.
|
||||||
func getFileRange(data *packed.FileData, req *http.Request) (offset, length uint64, isPartial bool) {
|
func getFileRange(dataLength uint64, req *http.Request) (offset, length uint64, isPartial bool) {
|
||||||
length = data.Length
|
length = dataLength
|
||||||
|
|
||||||
// only accept "Range: bytes=…"
|
// only accept "Range: bytes=…"
|
||||||
r := req.Header.Get("Range")
|
r := req.Header.Get("Range")
|
||||||
|
|
@ -258,8 +321,8 @@ func getFileRange(data *packed.FileData, req *http.Request) (offset, length uint
|
||||||
// validate the interval lies within the file
|
// validate the interval lies within the file
|
||||||
switch {
|
switch {
|
||||||
case from > to,
|
case from > to,
|
||||||
from >= data.Length,
|
from >= dataLength,
|
||||||
to >= data.Length:
|
to >= dataLength:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
49
handler_test.go
Normal file
49
handler_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package htpack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"src.lwithers.me.uk/go/htpack/packed"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
mapped, err := packed.MapAndLoad("packed/testdata/helloworld.pack")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("loading test packfile:", err)
|
||||||
|
}
|
||||||
|
defer mapped.Close()
|
||||||
|
|
||||||
|
orig, err := os.ReadFile("packed/testdata/helloworld.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("loading test textfile:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewMapped("", mapped)
|
||||||
|
h.AddMapped("/foo", mapped)
|
||||||
|
|
||||||
|
testsv := httptest.NewServer(h)
|
||||||
|
defer testsv.Close()
|
||||||
|
cl := testsv.Client()
|
||||||
|
url := testsv.URL + "/helloworld.txt"
|
||||||
|
|
||||||
|
resp, err := cl.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("executing GET request:", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("reading response body:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(body, orig) {
|
||||||
|
t.Error("difference in response (- got, + expected):")
|
||||||
|
t.Error(cmp.Diff(string(body), string(orig)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue