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:
Laurence Withers 2026-06-06 11:07:03 +01:00
commit 5b8873a4f5
3 changed files with 149 additions and 36 deletions

1
go.mod
View file

@ -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
) )

View file

@ -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
View 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)))
}
}