From 5b8873a4f595d5f3fb88f6bbc2e16b6e136f367b Mon Sep 17 00:00:00 2001 From: Laurence Withers Date: Sat, 6 Jun 2026 11:07:03 +0100 Subject: [PATCH] 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. --- go.mod | 1 + handler.go | 135 +++++++++++++++++++++++++++++++++++------------- handler_test.go | 49 ++++++++++++++++++ 3 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 handler_test.go diff --git a/go.mod b/go.mod index 00017b3..9f23a57 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module src.lwithers.me.uk/go/htpack go 1.25.0 require ( + github.com/google/go-cmp v0.7.0 golang.org/x/sys v0.45.0 google.golang.org/protobuf v1.36.11 ) diff --git a/handler.go b/handler.go index b61957e..a429f19 100644 --- a/handler.go +++ b/handler.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" "path" - "path/filepath" "strconv" "strings" "time" @@ -24,10 +23,19 @@ func New(packfile string) (*Handler, error) { if err != nil { 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{ - mapped: mapped.Data, - dir: mapped.Directory.Files, + files: make(map[string]handlerFile), headers: make(map[string]string), 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 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. type Handler struct { - mapped []byte - dir map[string]*packed.File + files map[string]handlerFile headers map[string]string 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 @@ -76,11 +92,11 @@ func (h *Handler) SetHeader(key, value string) { // 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 + for k, v := range h.files { + if path.Base(k) == filename { + routeToAdd := path.Dir(k) + if _, exists := h.files[routeToAdd]; !exists { + h.files[routeToAdd] = v } } } @@ -100,14 +116,62 @@ func (h *Handler) SetNotFound(notFound string) error { } notFound = path.Clean(notFound) - dir := h.dir[path.Clean(notFound)] - if dir == nil { + f, ok := h.files[path.Clean(notFound)] + if !ok { return fmt.Errorf("no such resource %q", notFound) } - h.notFound = dir + h.notFound = &f 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 // anything else returning a 405. Exact path matches are required, else a 404 is // returned. @@ -126,48 +190,48 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - info := h.dir[path.Clean(req.URL.Path)] - if info == nil { + info, ok := h.files[path.Clean(req.URL.Path)] + if !ok { if h.notFound == nil { http.NotFound(w, req) return } - info = h.notFound + 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("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) { + if clientHasCachedVersion(info.etag, h.startTime, req) { w.WriteHeader(http.StatusNotModified) return } // select compression - data := info.Uncompressed + data := info.uncompressed gzip, brotli, zstd := acceptedEncodings(req) switch { - case zstd && info.Zstd != nil: - data = info.Zstd + case zstd && info.zstd != nil: + data = info.zstd w.Header().Set("Content-Encoding", encodingZstd) - case brotli && info.Brotli != nil: - data = info.Brotli + case brotli && info.brotli != nil: + data = info.brotli w.Header().Set("Content-Encoding", encodingBrotli) - case gzip && info.Gzip != nil: - data = info.Gzip + case 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) + offset, length, isPartial := getFileRange(uint64(len(data)), req) if isPartial { 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 @@ -183,8 +247,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - offset += data.Offset - w.Write(h.mapped[offset : offset+length]) + w.Write(data[offset : offset+length]) } 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 // with whether or not it's partial content. -func getFileRange(data *packed.FileData, req *http.Request) (offset, length uint64, isPartial bool) { - length = data.Length +func getFileRange(dataLength uint64, req *http.Request) (offset, length uint64, isPartial bool) { + length = dataLength // only accept "Range: bytes=…" 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 switch { case from > to, - from >= data.Length, - to >= data.Length: + from >= dataLength, + to >= dataLength: return } diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..c0b986f --- /dev/null +++ b/handler_test.go @@ -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))) + } +}