mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
zip: Static file handler + composable Middleware (With/Chain) — additive (#6)
Two additive capabilities on the zip surface, neither touching the existing Use(Handler...) c.Next model. Static(fs.FS, ...StaticOption) — leaf file handler for a wildcard route. Serves the "*" subpath from any fs.FS (embed.FS or os.DirFS); path.Clean + fs.ValidPath reject traversal fail-closed (404); a missing file calls c.Next() so a later route or SPA catch-all wins, never a 500. Sets Content-Type/Length/Last-Modified, honours HEAD and If-Modified-Since (304). WithIndex / WithStripPrefix options. fasthttp owns the streamed file's close. Middleware = func(Handler) Handler, Chain(mw...), and App.With(mw...) — chi's per-route wrapping idiom. With wraps the leaf at registration (not via c.Next), Chain composes left-to-right. Coexists with Use: Use = global/prefix stack (declaration order), With/Chain = per-route explicit wrapping. Tests: serves file (content/type/len) over embed.FS AND os.DirFS, HEAD, traversal blocked, missing->Next->catch-all, specificity beats the wildcard, index, strip-prefix, 304; Chain/With execution order + the cloud rateLimit+CSRF order and short-circuit proof. Suite green except the two pre-existing macOS-host failures (TestListen_DualTransport, TestMCP_FreeToolSurface). Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
Hanzo Dev
parent
b6fafc3a4f
commit
a0825689f3
@@ -0,0 +1,82 @@
|
||||
package zip
|
||||
|
||||
import "github.com/zap-proto/fiber/v3"
|
||||
|
||||
// Middleware is a composable request transformer in the classic wrapping form:
|
||||
// given the next Handler it returns a Handler that runs around it. This is a
|
||||
// DIFFERENT tool from Use — they do different jobs and compose freely:
|
||||
//
|
||||
// - Use(Handler...) registers GLOBAL / prefix middleware. It runs for every
|
||||
// matched route (or every route under a Group) in DECLARATION order and
|
||||
// chains via c.Next(). Reach for it for ambient cross-cutting concerns
|
||||
// that apply broadly: logging, recovery, request-id.
|
||||
//
|
||||
// - Middleware + With + Chain wrap ONE leaf handler explicitly, at
|
||||
// registration time, with no c.Next() indirection. Reach for it when a
|
||||
// specific endpoint needs a specific pipeline:
|
||||
//
|
||||
// app.With(RateLimit, CSRF).Post("/v1/keys", mintKey)
|
||||
//
|
||||
// wraps mintKey as RateLimit(CSRF(mintKey)): RateLimit is outermost and
|
||||
// runs first, CSRF next, the handler last; any layer short-circuits by
|
||||
// returning without calling next.
|
||||
//
|
||||
// A Middleware body is written by hand, no framework glue:
|
||||
//
|
||||
// func RequireCSRF(next zip.Handler) zip.Handler {
|
||||
// return func(c *zip.Ctx) error {
|
||||
// if !validCSRF(c) {
|
||||
// return c.String(403, "bad csrf") // short-circuit
|
||||
// }
|
||||
// return next(c) // continue
|
||||
// }
|
||||
// }
|
||||
type Middleware = func(next Handler) Handler
|
||||
|
||||
// Chain composes middleware left-to-right into one Middleware. Chain(a, b, c)
|
||||
// nests as a(b(c(handler))): a is outermost (runs first inbound, last
|
||||
// outbound), c innermost, wrapping the handler directly. Chain() with no
|
||||
// arguments is the identity middleware.
|
||||
func Chain(mw ...Middleware) Middleware {
|
||||
return func(next Handler) Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
next = mw[i](next)
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
// wrapRouter decorates an inner Router so every leaf route it registers has its
|
||||
// Handler wrapped by a Middleware chain first (chi's With idiom). Non-leaf
|
||||
// operations delegate to the inner Router; Group and Route propagate the chain
|
||||
// so leaves registered beneath them stay wrapped. Registration still flows
|
||||
// through the same fiber path as any other route, so specificity precedence is
|
||||
// unchanged — only the leaf handler is pre-wrapped.
|
||||
type wrapRouter struct {
|
||||
inner Router
|
||||
wrap Middleware
|
||||
}
|
||||
|
||||
func (w *wrapRouter) Use(handlers ...Handler) Router { w.inner.Use(handlers...); return w }
|
||||
|
||||
func (w *wrapRouter) Get(p string, h Handler) Router { w.inner.Get(p, w.wrap(h)); return w }
|
||||
func (w *wrapRouter) Post(p string, h Handler) Router { w.inner.Post(p, w.wrap(h)); return w }
|
||||
func (w *wrapRouter) Put(p string, h Handler) Router { w.inner.Put(p, w.wrap(h)); return w }
|
||||
func (w *wrapRouter) Patch(p string, h Handler) Router { w.inner.Patch(p, w.wrap(h)); return w }
|
||||
func (w *wrapRouter) Delete(p string, h Handler) Router { w.inner.Delete(p, w.wrap(h)); return w }
|
||||
func (w *wrapRouter) Head(p string, h Handler) Router { w.inner.Head(p, w.wrap(h)); return w }
|
||||
func (w *wrapRouter) Options(p string, h Handler) Router { w.inner.Options(p, w.wrap(h)); return w }
|
||||
func (w *wrapRouter) All(p string, h Handler) Router { w.inner.All(p, w.wrap(h)); return w }
|
||||
|
||||
func (w *wrapRouter) Group(prefix string, handlers ...Handler) Router {
|
||||
return &wrapRouter{inner: w.inner.Group(prefix, handlers...), wrap: w.wrap}
|
||||
}
|
||||
|
||||
func (w *wrapRouter) Route(prefix string, fn func(r Router)) Router {
|
||||
return &wrapRouter{
|
||||
inner: w.inner.Route(prefix, func(r Router) { fn(&wrapRouter{inner: r, wrap: w.wrap}) }),
|
||||
wrap: w.wrap,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *wrapRouter) Fiber() fiber.Router { return w.inner.Fiber() }
|
||||
@@ -0,0 +1,197 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// These tests pin the composable Middleware surface (Middleware/Chain/With),
|
||||
// which is ADDITIVE to Use: Use registers global c.Next() middleware; With
|
||||
// wraps a single leaf handler at registration. The two coexist and compose.
|
||||
|
||||
func mwApp() *zip.App {
|
||||
return zip.New(zip.Config{DisableStartupMessage: true})
|
||||
}
|
||||
|
||||
// order returns a Middleware that appends its tag to *rec on the way in, then
|
||||
// calls next. It never short-circuits.
|
||||
func order(rec *[]string, tag string) zip.Middleware {
|
||||
return func(next zip.Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
*rec = append(*rec, tag)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_NestsLeftToRight proves Chain(a,b,c) executes a outermost, then b,
|
||||
// then c, then the handler — a(b(c(handler))).
|
||||
func TestChain_NestsLeftToRight(t *testing.T) {
|
||||
app := mwApp()
|
||||
var rec []string
|
||||
app.With(order(&rec, "a"), order(&rec, "b"), order(&rec, "c")).
|
||||
Get("/x", func(c *zip.Ctx) error {
|
||||
rec = append(rec, "handler")
|
||||
return c.String(200, "ok")
|
||||
})
|
||||
|
||||
if resp, body := doGet(t, app, "/x"); resp.StatusCode != 200 || body != "ok" {
|
||||
t.Fatalf("status=%d body=%q, want 200 ok", resp.StatusCode, body)
|
||||
}
|
||||
if got := strings.Join(rec, ","); got != "a,b,c,handler" {
|
||||
t.Fatalf("execution order %q, want a,b,c,handler", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_Value proves Chain composes into a reusable Middleware value that
|
||||
// behaves identically to passing the parts to With.
|
||||
func TestChain_Value(t *testing.T) {
|
||||
app := mwApp()
|
||||
var rec []string
|
||||
pipeline := zip.Chain(order(&rec, "outer"), order(&rec, "inner"))
|
||||
app.With(pipeline).Get("/y", func(c *zip.Ctx) error {
|
||||
rec = append(rec, "handler")
|
||||
return c.String(200, "ok")
|
||||
})
|
||||
|
||||
_, _ = doGet(t, app, "/y")
|
||||
if got := strings.Join(rec, ","); got != "outer,inner,handler" {
|
||||
t.Fatalf("execution order %q, want outer,inner,handler", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- The cloud proof --------------------------------------------------------
|
||||
//
|
||||
// cmd/cloud today hand-nests its per-route middleware:
|
||||
//
|
||||
// s.rateLimit(s.requireCSRF(s.mintKey))
|
||||
//
|
||||
// This test proves the identical pipeline via app.With(RateLimit, CSRF).Post,
|
||||
// asserting (1) execution order — RateLimit outer, CSRF inner, handler last —
|
||||
// and (2) short-circuit — a RateLimit rejection stops before CSRF and the
|
||||
// handler ever run. This is why With/Chain earns its place: the nesting reads
|
||||
// left-to-right at the call site instead of inside-out.
|
||||
|
||||
// rateLimit is a realistic guard middleware: when blocked it writes 429 and
|
||||
// does NOT call next (short-circuit).
|
||||
func rateLimit(rec *[]string, block bool) zip.Middleware {
|
||||
return func(next zip.Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
*rec = append(*rec, "ratelimit")
|
||||
if block {
|
||||
return c.String(http.StatusTooManyRequests, "rate limited")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireCSRF(rec *[]string) zip.Middleware {
|
||||
return func(next zip.Handler) zip.Handler {
|
||||
return func(c *zip.Ctx) error {
|
||||
*rec = append(*rec, "csrf")
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWith_CloudRateLimitCSRF_Order(t *testing.T) {
|
||||
app := mwApp()
|
||||
var rec []string
|
||||
app.With(rateLimit(&rec, false), requireCSRF(&rec)).
|
||||
Post("/v1/keys", func(c *zip.Ctx) error {
|
||||
rec = append(rec, "mintkey")
|
||||
return c.String(200, "minted")
|
||||
})
|
||||
|
||||
if resp, body := doPost(t, app, "/v1/keys"); resp.StatusCode != 200 || body != "minted" {
|
||||
t.Fatalf("status=%d body=%q, want 200 minted", resp.StatusCode, body)
|
||||
}
|
||||
if got := strings.Join(rec, ","); got != "ratelimit,csrf,mintkey" {
|
||||
t.Fatalf("order %q, want ratelimit,csrf,mintkey (RateLimit outer, CSRF inner, handler last)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWith_CloudRateLimit_ShortCircuits(t *testing.T) {
|
||||
app := mwApp()
|
||||
var rec []string
|
||||
app.With(rateLimit(&rec, true), requireCSRF(&rec)).
|
||||
Post("/v1/keys", func(c *zip.Ctx) error {
|
||||
rec = append(rec, "mintkey")
|
||||
return c.String(200, "minted")
|
||||
})
|
||||
|
||||
resp, body := doPost(t, app, "/v1/keys")
|
||||
if resp.StatusCode != http.StatusTooManyRequests || body != "rate limited" {
|
||||
t.Fatalf("status=%d body=%q, want 429 \"rate limited\"", resp.StatusCode, body)
|
||||
}
|
||||
if got := strings.Join(rec, ","); got != "ratelimit" {
|
||||
t.Fatalf("order %q, want just ratelimit — CSRF and handler must NOT run after a short-circuit", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWith_CoexistsWithUse proves the two models compose: a global Use
|
||||
// middleware still runs (in declaration order, via c.Next) before a
|
||||
// With-wrapped leaf.
|
||||
func TestWith_CoexistsWithUse(t *testing.T) {
|
||||
app := mwApp()
|
||||
var rec []string
|
||||
app.Use(func(c *zip.Ctx) error { rec = append(rec, "use"); return c.Next() })
|
||||
app.With(order(&rec, "with")).Get("/z", func(c *zip.Ctx) error {
|
||||
rec = append(rec, "handler")
|
||||
return c.String(200, "ok")
|
||||
})
|
||||
|
||||
_, _ = doGet(t, app, "/z")
|
||||
if got := strings.Join(rec, ","); got != "use,with,handler" {
|
||||
t.Fatalf("order %q, want use,with,handler (global Use before per-route With)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWith_SpecificityPreserved proves a With-wrapped wildcard still loses to a
|
||||
// later, more-specific plain route — With changes the handler, not routing.
|
||||
func TestWith_SpecificityPreserved(t *testing.T) {
|
||||
app := mwApp()
|
||||
var rec []string
|
||||
app.With(order(&rec, "mw")).Get("/api/*", func(c *zip.Ctx) error { return c.String(200, "wild") })
|
||||
app.Get("/api/health", func(c *zip.Ctx) error { return c.String(200, "health") })
|
||||
|
||||
if resp, body := doGet(t, app, "/api/health"); resp.StatusCode != 200 || body != "health" {
|
||||
t.Fatalf("/api/health: status=%d body=%q, want the specific route (not the With-wrapped wildcard)", resp.StatusCode, body)
|
||||
}
|
||||
if len(rec) != 0 {
|
||||
t.Fatalf("With middleware ran for /api/health (%v); the specific route must win outright", rec)
|
||||
}
|
||||
if resp, body := doGet(t, app, "/api/other"); resp.StatusCode != 200 || body != "wild" {
|
||||
t.Fatalf("/api/other: status=%d body=%q, want the wildcard", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
func doGet(t *testing.T, app *zip.App, target string) (*http.Response, string) {
|
||||
return doReq(t, app, "GET", target)
|
||||
}
|
||||
|
||||
func doPost(t *testing.T, app *zip.App, target string) (*http.Response, string) {
|
||||
return doReq(t, app, "POST", target)
|
||||
}
|
||||
|
||||
func doReq(t *testing.T, app *zip.App, method, target string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest(%s %s): %v", method, target, err)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test(%s %s): %v", method, target, err)
|
||||
}
|
||||
b := make([]byte, 512)
|
||||
n, _ := resp.Body.Read(b)
|
||||
_ = resp.Body.Close()
|
||||
return resp, string(b[:n])
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/zap-proto/fiber/v3"
|
||||
)
|
||||
|
||||
// StaticOption configures Static. Zero options serve fsys as-is.
|
||||
type StaticOption func(*staticConfig)
|
||||
|
||||
type staticConfig struct {
|
||||
index string // dir-request index document (e.g. "index.html"); "" disables.
|
||||
stripPrefix string // request-path prefix to strip; "" uses the route's "*" capture.
|
||||
}
|
||||
|
||||
// WithIndex serves name (e.g. "index.html") for directory and root requests.
|
||||
// Without it a directory request falls through via c.Next().
|
||||
func WithIndex(name string) StaticOption {
|
||||
return func(c *staticConfig) { c.index = name }
|
||||
}
|
||||
|
||||
// WithStripPrefix derives the fs path from the request path with prefix
|
||||
// removed, instead of from the route's "*" capture. Use it when the captured
|
||||
// subpath is not the fs path — e.g. a versioned URL served from an unversioned
|
||||
// tree: WithStripPrefix("/static/v2/") maps /static/v2/app.js to app.js.
|
||||
func WithStripPrefix(prefix string) StaticOption {
|
||||
return func(c *staticConfig) { c.stripPrefix = prefix }
|
||||
}
|
||||
|
||||
// Static returns a leaf Handler that serves files from fsys. Mount it on a
|
||||
// wildcard route; the "*" capture selects the file:
|
||||
//
|
||||
// app.Get("/assets/*", zip.Static(assets)) // embed.FS
|
||||
// app.Get("/app/*", zip.Static(os.DirFS("dist"), zip.WithIndex("index.html")))
|
||||
//
|
||||
// Contract:
|
||||
// - The subpath is cleaned and checked with fs.ValidPath; any ".." escape or
|
||||
// absolute path is rejected fail-closed with 404 — Static can never read
|
||||
// outside fsys.
|
||||
// - A missing file yields c.Next(), so a later more-specific route or a SPA
|
||||
// catch-all still wins — never a 500.
|
||||
// - Sets Content-Type (by extension), Content-Length and Last-Modified;
|
||||
// honours HEAD and If-Modified-Since (304). Nothing else — no compression,
|
||||
// no byte ranges, no directory listing.
|
||||
//
|
||||
// fsys is any fs.FS: an embed.FS for baked-in assets or os.DirFS(dir) for a
|
||||
// directory on disk. Both are traversal-safe by construction; the fs.ValidPath
|
||||
// gate is defence in depth on top of that.
|
||||
func Static(fsys fs.FS, opts ...StaticOption) Handler {
|
||||
var cfg staticConfig
|
||||
for _, o := range opts {
|
||||
o(&cfg)
|
||||
}
|
||||
return func(c *Ctx) error {
|
||||
sub := c.fc.Params("*")
|
||||
if cfg.stripPrefix != "" {
|
||||
sub = strings.TrimPrefix(c.fc.Path(), cfg.stripPrefix)
|
||||
}
|
||||
name, ok := staticClean(sub, cfg.index)
|
||||
if !ok {
|
||||
return ErrNotFound("not found") // traversal / absolute → fail closed
|
||||
}
|
||||
if name == "" {
|
||||
return c.Next() // dir/root with no index → let a later route win
|
||||
}
|
||||
f, info, ok := cfg.open(fsys, name)
|
||||
if !ok {
|
||||
return c.Next() // missing → SPA catch-all / next route wins
|
||||
}
|
||||
return serveFile(c, f, info)
|
||||
}
|
||||
}
|
||||
|
||||
// staticClean maps a raw wildcard subpath to a validated fs path. ok=false is
|
||||
// the fail-closed traversal guard: any path that escapes the root (".."), is
|
||||
// absolute, or is otherwise not an fs.ValidPath is rejected. path.Clean first
|
||||
// collapses in-root navigation ("a/../b" → "b") so only genuine escapes fail.
|
||||
// A bare root/dir request maps to index, or to "" (nothing to serve here) when
|
||||
// no index is set.
|
||||
func staticClean(sub, index string) (name string, ok bool) {
|
||||
sub = strings.TrimPrefix(sub, "/")
|
||||
if sub == "" {
|
||||
return index, true // "" when no index → handler yields via c.Next()
|
||||
}
|
||||
clean := path.Clean(sub)
|
||||
if !fs.ValidPath(clean) {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// open resolves name to a readable file, following a directory to its index
|
||||
// document when configured. ok=false for missing files, directories without an
|
||||
// index, and unreadable entries — all of which the caller turns into c.Next().
|
||||
func (cfg staticConfig) open(fsys fs.FS, name string) (fs.File, fs.FileInfo, bool) {
|
||||
f, err := fsys.Open(name)
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return nil, nil, false
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return f, info, true
|
||||
}
|
||||
// Directory: serve its index document, or fall through.
|
||||
_ = f.Close()
|
||||
if cfg.index == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
f, err = fsys.Open(path.Join(name, cfg.index))
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
info, err = f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
_ = f.Close()
|
||||
return nil, nil, false
|
||||
}
|
||||
return f, info, true
|
||||
}
|
||||
|
||||
// serveFile writes info's bytes to the response: Last-Modified + conditional
|
||||
// 304, Content-Type by extension, then the body streamed with a known size so
|
||||
// Content-Length is set. It takes ownership of f — fasthttp closes the stream
|
||||
// after writing the body (or after skipping it for HEAD, which it detects via
|
||||
// Response.SkipBody and still emits Content-Length for), so the one SendStream
|
||||
// call serves GET and HEAD alike. The only path that does not stream — a 304 —
|
||||
// closes f itself.
|
||||
func serveFile(c *Ctx, f fs.File, info fs.FileInfo) error {
|
||||
if mod := info.ModTime(); !mod.IsZero() {
|
||||
c.fc.Set(fiber.HeaderLastModified, mod.UTC().Format(http.TimeFormat))
|
||||
if ims := c.fc.Get(fiber.HeaderIfModifiedSince); ims != "" {
|
||||
if t, err := http.ParseTime(ims); err == nil && !mod.Truncate(time.Second).After(t) {
|
||||
_ = f.Close() // not streaming — release it ourselves
|
||||
c.fc.Status(fiber.StatusNotModified)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
ctype := mime.TypeByExtension(path.Ext(info.Name()))
|
||||
if ctype == "" {
|
||||
ctype = fiber.MIMEOctetStream
|
||||
}
|
||||
c.fc.Set(fiber.HeaderContentType, ctype)
|
||||
c.fc.Status(fiber.StatusOK)
|
||||
return c.fc.SendStream(f, int(info.Size())) // fasthttp closes f after writing
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package zip
|
||||
|
||||
import "testing"
|
||||
|
||||
// staticClean is the fail-closed traversal guard, tested directly so the
|
||||
// security property holds regardless of what URL normalization the transport
|
||||
// does upstream: any ".." escape or non-fs.ValidPath input is rejected, while
|
||||
// in-root navigation ("a/../b") collapses and is allowed.
|
||||
func TestStaticClean_FailClosed(t *testing.T) {
|
||||
cases := []struct {
|
||||
sub, index string
|
||||
wantName string
|
||||
wantOK bool
|
||||
}{
|
||||
{"main.css", "", "main.css", true},
|
||||
{"a/b/c.js", "", "a/b/c.js", true},
|
||||
{"a/../b.js", "", "b.js", true}, // in-root nav collapses — allowed
|
||||
{"./x.css", "", "x.css", true}, // leading . collapses
|
||||
{"", "", "", true}, // root, no index → nothing to serve
|
||||
{"", "index.html", "index.html", true}, // root → index
|
||||
{"/", "", "", true}, // just a slash → root
|
||||
{"..", "", "", false}, // bare escape
|
||||
{"../secret", "", "", false}, // escape one level
|
||||
{"../../etc/passwd", "", "", false},
|
||||
{"a/../../secret", "", "", false}, // escapes after collapse
|
||||
{"sub/../../secret", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := staticClean(c.sub, c.index)
|
||||
if ok != c.wantOK || name != c.wantName {
|
||||
t.Errorf("staticClean(%q, %q) = (%q, %v), want (%q, %v)",
|
||||
c.sub, c.index, name, ok, c.wantName, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package zip_test
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// These tests pin zip.Static: it serves files from any fs.FS (embed.FS AND
|
||||
// os.DirFS are exercised), rejects traversal fail-closed, and — the load-
|
||||
// bearing property — a missing file yields c.Next() so a later route or SPA
|
||||
// catch-all still wins, while a more-specific route beats the wildcard by
|
||||
// specificity, never by registration order.
|
||||
|
||||
//go:embed testdata/assets
|
||||
var assetsEmbed embed.FS
|
||||
|
||||
func embedAssets(t *testing.T) fs.FS {
|
||||
t.Helper()
|
||||
sub, err := fs.Sub(assetsEmbed, "testdata/assets")
|
||||
if err != nil {
|
||||
t.Fatalf("fs.Sub: %v", err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func dirAssets() fs.FS { return os.DirFS("testdata/assets") }
|
||||
|
||||
// serve issues an in-memory request (optionally with headers) and returns the
|
||||
// full response plus the body it read.
|
||||
func serve(t *testing.T, app *zip.App, method, target string, headers map[string]string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, target, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest(%s %s): %v", method, target, err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := app.Fiber().Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Test(%s %s): %v", method, target, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp, string(b)
|
||||
}
|
||||
|
||||
// bothFS runs fn against Static backed by an embed.FS and an os.DirFS, proving
|
||||
// the handler is fs.FS-generic.
|
||||
func bothFS(t *testing.T, fn func(t *testing.T, assets fs.FS)) {
|
||||
t.Helper()
|
||||
t.Run("embed.FS", func(t *testing.T) { fn(t, embedAssets(t)) })
|
||||
t.Run("os.DirFS", func(t *testing.T) { fn(t, dirAssets()) })
|
||||
}
|
||||
|
||||
func TestStatic_ServesFile(t *testing.T) {
|
||||
bothFS(t, func(t *testing.T, assets fs.FS) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/assets/*", zip.Static(assets))
|
||||
|
||||
want, _ := fs.ReadFile(assets, "main.css")
|
||||
resp, body := serve(t, app, "GET", "/assets/main.css", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if body != string(want) {
|
||||
t.Fatalf("body %q, want %q", body, want)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
|
||||
t.Fatalf("Content-Type %q, want text/css…", ct)
|
||||
}
|
||||
if cl := resp.Header.Get("Content-Length"); cl != strconv.Itoa(len(want)) {
|
||||
t.Fatalf("Content-Length %q, want %d", cl, len(want))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatic_Head(t *testing.T) {
|
||||
bothFS(t, func(t *testing.T, assets fs.FS) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/assets/*", zip.Static(assets))
|
||||
|
||||
want, _ := fs.ReadFile(assets, "main.css")
|
||||
resp, body := serve(t, app, "HEAD", "/assets/main.css", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if body != "" {
|
||||
t.Fatalf("HEAD body %q, want empty", body)
|
||||
}
|
||||
if cl := resp.Header.Get("Content-Length"); cl != strconv.Itoa(len(want)) {
|
||||
t.Fatalf("HEAD Content-Length %q, want %d (must report the size GET would send)", cl, len(want))
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
|
||||
t.Fatalf("HEAD Content-Type %q, want text/css…", ct)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStatic_MissingFallsThroughToNext is the load-bearing behaviour: a missing
|
||||
// file must NOT 500 or hard-404 — it calls c.Next() so a later route wins. Here
|
||||
// a SPA catch-all registered after the wildcard serves the miss.
|
||||
func TestStatic_MissingFallsThroughToNext(t *testing.T) {
|
||||
bothFS(t, func(t *testing.T, assets fs.FS) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/assets/*", zip.Static(assets))
|
||||
app.Get("/*", func(c *zip.Ctx) error { return c.String(200, "spa") })
|
||||
|
||||
resp, body := serve(t, app, "GET", "/assets/does-not-exist.css", nil)
|
||||
if resp.StatusCode != 200 || body != "spa" {
|
||||
t.Fatalf("missing file: status=%d body=%q, want 200 \"spa\" (Static must Next() to the catch-all)", resp.StatusCode, body)
|
||||
}
|
||||
// The real file still serves — Static didn't just always-Next.
|
||||
if resp, body := serve(t, app, "GET", "/assets/main.css", nil); resp.StatusCode != 200 || strings.Contains(body, "spa") {
|
||||
t.Fatalf("real file: status=%d body=%q, want the file, not the SPA", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// With no later route, a missing file is a clean 404 (Next() with c.matched
|
||||
// set), never a 500.
|
||||
func TestStatic_Missing404WhenNoCatchAll(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/assets/*", zip.Static(embedAssets(t)))
|
||||
|
||||
resp, _ := serve(t, app, "GET", "/assets/nope.css", nil)
|
||||
if resp.StatusCode != 404 {
|
||||
t.Fatalf("status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatic_TraversalBlocked proves no request can escape fsys to read the
|
||||
// sibling secret, across several traversal encodings. os.DirFS is rooted at
|
||||
// assets and Static's fs.ValidPath gate rejects "..", so the parent's
|
||||
// secret.txt is unreachable — the response never carries its content and is
|
||||
// never a 200.
|
||||
func TestStatic_TraversalBlocked(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/assets/*", zip.Static(dirAssets()))
|
||||
|
||||
for _, target := range []string{
|
||||
"/assets/../secret.txt",
|
||||
"/assets/../../secret.txt",
|
||||
"/assets/%2e%2e/secret.txt",
|
||||
"/assets/..%2fsecret.txt",
|
||||
"/assets/sub/../../secret.txt",
|
||||
} {
|
||||
resp, body := serve(t, app, "GET", target, nil)
|
||||
if strings.Contains(body, "TOP-SECRET") {
|
||||
t.Fatalf("%s LEAKED the sibling secret: %q", target, body)
|
||||
}
|
||||
if resp.StatusCode == 200 {
|
||||
t.Fatalf("%s returned 200 (%q); traversal must fail closed", target, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatic_LaterStaticRouteWins: a specific route registered AFTER the
|
||||
// wildcard Static still wins for its exact path (specificity, not order),
|
||||
// while every other subpath is served by Static.
|
||||
func TestStatic_LaterStaticRouteWins(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/assets/*", zip.Static(embedAssets(t)))
|
||||
app.Get("/assets/special.txt", func(c *zip.Ctx) error { return c.String(200, "special-route") })
|
||||
|
||||
if resp, body := serve(t, app, "GET", "/assets/special.txt", nil); resp.StatusCode != 200 || body != "special-route" {
|
||||
t.Fatalf("/assets/special.txt: status=%d body=%q, want the specific route to win over Static", resp.StatusCode, body)
|
||||
}
|
||||
if resp, body := serve(t, app, "GET", "/assets/main.css", nil); resp.StatusCode != 200 || !strings.HasPrefix(body, "body{") {
|
||||
t.Fatalf("/assets/main.css: status=%d body=%q, want Static to serve it", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatic_WithIndex serves the index document for directory and root
|
||||
// requests, and still serves nested files directly.
|
||||
func TestStatic_WithIndex(t *testing.T) {
|
||||
bothFS(t, func(t *testing.T, assets fs.FS) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/app/*", zip.Static(assets, zip.WithIndex("index.html")))
|
||||
|
||||
idx, _ := fs.ReadFile(assets, "index.html")
|
||||
if resp, body := serve(t, app, "GET", "/app/", nil); resp.StatusCode != 200 || body != string(idx) {
|
||||
t.Fatalf("/app/: status=%d body=%q, want index.html %q", resp.StatusCode, body, idx)
|
||||
}
|
||||
page, _ := fs.ReadFile(assets, "sub/page.html")
|
||||
if resp, body := serve(t, app, "GET", "/app/sub/page.html", nil); resp.StatusCode != 200 || body != string(page) {
|
||||
t.Fatalf("/app/sub/page.html: status=%d body=%q, want the nested file", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestStatic_WithStripPrefix derives the fs path from the request path minus a
|
||||
// prefix instead of the "*" capture: a versioned URL served from an
|
||||
// unversioned tree. The default "*" capture ("v2/main.css") would miss; the
|
||||
// strip maps it to "main.css".
|
||||
func TestStatic_WithStripPrefix(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/static/*", zip.Static(embedAssets(t), zip.WithStripPrefix("/static/v2/")))
|
||||
|
||||
if resp, body := serve(t, app, "GET", "/static/v2/main.css", nil); resp.StatusCode != 200 || !strings.HasPrefix(body, "body{") {
|
||||
t.Fatalf("/static/v2/main.css: status=%d body=%q, want main.css via strip-prefix", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatic_IfModifiedSince304 exercises the conditional-GET path against
|
||||
// os.DirFS (real mod times; embed.FS mod times are zero so Last-Modified is
|
||||
// omitted there by design).
|
||||
func TestStatic_IfModifiedSince304(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
app.Get("/assets/*", zip.Static(dirAssets()))
|
||||
|
||||
resp, _ := serve(t, app, "GET", "/assets/main.css", nil)
|
||||
lastMod := resp.Header.Get("Last-Modified")
|
||||
if lastMod == "" {
|
||||
t.Fatal("os.DirFS serve set no Last-Modified")
|
||||
}
|
||||
// Re-request with the exact Last-Modified: must be 304 with no body.
|
||||
resp2, body2 := serve(t, app, "GET", "/assets/main.css", map[string]string{"If-Modified-Since": lastMod})
|
||||
if resp2.StatusCode != 304 {
|
||||
t.Fatalf("conditional GET: status %d, want 304", resp2.StatusCode)
|
||||
}
|
||||
if body2 != "" {
|
||||
t.Fatalf("304 body %q, want empty", body2)
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><title>home</title><h1>home</h1>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
body{color:red}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<p>sub page</p>
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
TOP-SECRET
|
||||
@@ -211,6 +211,21 @@ func (a *App) Use(handlers ...Handler) Router {
|
||||
return &routerAdapter{r: a.fiber, app: a}
|
||||
}
|
||||
|
||||
// With returns a Router whose subsequent leaf registrations (Get/Post/…/All)
|
||||
// have mw wrapped around the handler at registration time — chi's idiom, pure
|
||||
// composition (RateLimit(CSRF(handler))). It does NOT touch the global Use
|
||||
// stack and does NOT route through c.Next(); it is the per-route counterpart to
|
||||
// Use. Routes registered on the returned Router still obey specificity
|
||||
// precedence exactly like any other route.
|
||||
//
|
||||
// app.With(RateLimit, CSRF).Post("/v1/keys", mintKey)
|
||||
func (a *App) With(mw ...Middleware) Router {
|
||||
return &wrapRouter{
|
||||
inner: &routerAdapter{r: a.fiber, app: a},
|
||||
wrap: Chain(mw...),
|
||||
}
|
||||
}
|
||||
|
||||
// UseFiber lets callers register raw fiber.Handler middleware (for the
|
||||
// fiber/v3/middleware/* packages). zip middleware is preferred.
|
||||
func (a *App) UseFiber(handlers ...fiber.Handler) Router {
|
||||
|
||||
Reference in New Issue
Block a user