refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
167
modules/boardwalk/boardwalk.go
Normal file
167
modules/boardwalk/boardwalk.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Package boardwalk serves the embedded admin SPA build (D-01, D-02).
|
||||
//
|
||||
// The committed dist/ is path-agnostic: Vite builds it with a relative base
|
||||
// and index.html carries the __SUMMER_ADMIN_BASE__ token. Handler rewrites
|
||||
// index.html once for the configured backend.uri, serves hashed assets with
|
||||
// long-lived caching, falls back to index.html for client-side routes and
|
||||
// hands every unmatched api/ path back to the caller so API misses stay JSON.
|
||||
package boardwalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// BaseToken is replaced in dist/index.html by the configured admin prefix.
|
||||
const BaseToken = "__SUMMER_ADMIN_BASE__"
|
||||
|
||||
// contentSecurityPolicy keeps the admin out of frames and allows scripts
|
||||
// only from its own origin; the build contains no inline script.
|
||||
const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"
|
||||
|
||||
var contentTypes = map[string]string{
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".svg": "image/svg+xml",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
// Dist returns the embedded build tree rooted at dist/.
|
||||
func Dist() (fs.FS, error) {
|
||||
return fs.Sub(distFS, "dist")
|
||||
}
|
||||
|
||||
// Handler serves the embedded SPA under prefix (for example /backend).
|
||||
// notFoundAPI answers any request whose path under the prefix is api or
|
||||
// starts with api/; it must write the admin API's JSON 404 envelope.
|
||||
func Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error) {
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newHandler(root, prefix, notFoundAPI)
|
||||
}
|
||||
|
||||
func newHandler(root fs.FS, prefix string, notFoundAPI http.Handler) (http.Handler, error) {
|
||||
if notFoundAPI == nil {
|
||||
return nil, errors.New("boardwalk: notFoundAPI handler is nil")
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
return nil, fmt.Errorf("boardwalk: prefix %q must start with /", prefix)
|
||||
}
|
||||
raw, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("boardwalk: dist/index.html: %w", err)
|
||||
}
|
||||
index, err := RewriteIndex(raw, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &handler{root: root, prefix: prefix, index: index, notFoundAPI: notFoundAPI}, nil
|
||||
}
|
||||
|
||||
// RewriteIndex points relative asset URLs at prefix and injects the prefix
|
||||
// into the summer-admin-base meta. It fails when the token is absent, which
|
||||
// catches a stale or hand-edited dist at boot.
|
||||
func RewriteIndex(raw []byte, prefix string) ([]byte, error) {
|
||||
if !bytes.Contains(raw, []byte(BaseToken)) {
|
||||
return nil, errors.New("boardwalk: dist/index.html has no " + BaseToken + " token; rebuild the admin SPA")
|
||||
}
|
||||
escaped := html.EscapeString(prefix)
|
||||
out := bytes.ReplaceAll(raw, []byte(`="./`), []byte(`="`+escaped+`/`))
|
||||
out = bytes.ReplaceAll(out, []byte(BaseToken), []byte(escaped))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
root fs.FS
|
||||
prefix string
|
||||
index []byte
|
||||
notFoundAPI http.Handler
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
setSecurityHeaders(w.Header())
|
||||
rel := strings.TrimPrefix(r.URL.Path, h.prefix)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel == "api" || strings.HasPrefix(rel, "api/") {
|
||||
h.notFoundAPI.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean("/"+rel), "/")
|
||||
if name == "" || name == "index.html" {
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
if info, err := fs.Stat(h.root, name); err == nil {
|
||||
if info.Mode().IsRegular() {
|
||||
h.serveFile(w, r, name)
|
||||
return
|
||||
}
|
||||
// A directory is never listed; it is treated as a client route.
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
if path.Ext(name) != "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.serveIndex(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
|
||||
}
|
||||
|
||||
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
|
||||
body, err := fs.ReadFile(h.root, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType(name))
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func contentType(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
if ct, ok := contentTypes[ext]; ok {
|
||||
return ct
|
||||
}
|
||||
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func setSecurityHeaders(h http.Header) {
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", contentSecurityPolicy)
|
||||
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||||
}
|
||||
455
modules/boardwalk/boardwalk_test.go
Normal file
455
modules/boardwalk/boardwalk_test.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package boardwalk
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
const testPrefix = "/acme-admin"
|
||||
|
||||
var (
|
||||
assetRef = regexp.MustCompile(`(?:src|href)="([^"]+)"`)
|
||||
scriptTag = regexp.MustCompile(`<script\b[^>]*>`)
|
||||
)
|
||||
|
||||
type apiSpy struct{ calls int }
|
||||
|
||||
func (s *apiSpy) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
s.calls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"Not found","details":{}}}`))
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T) (http.Handler, *apiSpy) {
|
||||
t.Helper()
|
||||
spy := &apiSpy{}
|
||||
h, err := Handler(testPrefix, spy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h, spy
|
||||
}
|
||||
|
||||
func get(h http.Handler, target string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIndexRewrite(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
for _, target := range []string{testPrefix, testPrefix + "/", testPrefix + "/index.html"} {
|
||||
rec := get(h, target)
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d", target, rec.Code)
|
||||
}
|
||||
if !strings.Contains(body, `<meta name="summer-admin-base" content="`+testPrefix+`"`) {
|
||||
t.Fatalf("%s index has no injected base: %s", target, body)
|
||||
}
|
||||
if strings.Contains(body, BaseToken) || strings.Contains(body, `="./`) {
|
||||
t.Fatalf("%s index was not rewritten: %s", target, body)
|
||||
}
|
||||
if !strings.Contains(body, `src="`+testPrefix+`/assets/`) {
|
||||
t.Fatalf("%s index script is not under the prefix: %s", target, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteIndexRequiresToken(t *testing.T) {
|
||||
if _, err := RewriteIndex([]byte(`<html><script src="./assets/a.js"></script></html>`), testPrefix); err == nil {
|
||||
t.Fatal("index without the base token was accepted")
|
||||
}
|
||||
root := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
|
||||
if _, err := newHandler(root, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) {
|
||||
t.Fatalf("stale dist accepted: %v", err)
|
||||
}
|
||||
if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil {
|
||||
t.Fatal("dist without index.html accepted")
|
||||
}
|
||||
if _, err := Handler(testPrefix, nil); err == nil {
|
||||
t.Fatal("nil API not-found handler accepted")
|
||||
}
|
||||
if _, err := Handler("acme-admin", &apiSpy{}); err == nil {
|
||||
t.Fatal("prefix without a leading slash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteIndexEscapesPrefix(t *testing.T) {
|
||||
out, err := RewriteIndex([]byte(`<meta content="`+BaseToken+`"><script src="./a.js"></script>`), `/a"b`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), `"/a"b`) || !strings.Contains(string(out), `/a"b`) {
|
||||
t.Fatalf("prefix not escaped: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryReferencedAssetIsEmbedded(t *testing.T) {
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index, err := RewriteIndex(raw, testPrefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := assetRef.FindAllStringSubmatch(string(index), -1)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("index references no assets")
|
||||
}
|
||||
h, _ := newTestHandler(t)
|
||||
for _, ref := range refs {
|
||||
target := ref[1]
|
||||
if !strings.HasPrefix(target, testPrefix+"/") {
|
||||
t.Fatalf("reference %q is not under the prefix", target)
|
||||
}
|
||||
name := strings.TrimPrefix(target, testPrefix+"/")
|
||||
if _, err := fs.Stat(root, name); err != nil {
|
||||
t.Fatalf("index references %s, missing from the embedded dist: %v", name, err)
|
||||
}
|
||||
if rec := get(h, target); rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s status=%d", target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoInlineScript(t *testing.T) {
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tags := scriptTag.FindAllString(string(raw), -1)
|
||||
if len(tags) == 0 {
|
||||
t.Fatal("index has no module script")
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if !strings.Contains(tag, " src=") {
|
||||
t.Fatalf("inline script in index.html: %s", tag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.ToLower(string(raw)), "javascript:") {
|
||||
t.Fatal("index.html contains a javascript: URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPathsAreDelegated(t *testing.T) {
|
||||
h, spy := newTestHandler(t)
|
||||
for _, target := range []string{testPrefix + "/api", testPrefix + "/api/", testPrefix + "/api/v1/nope", testPrefix + "/api/v1/auth/login"} {
|
||||
before := spy.calls
|
||||
rec := get(h, target)
|
||||
if spy.calls != before+1 || rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Fatalf("%s was not delegated: status=%d body=%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
before := spy.calls
|
||||
if rec := get(h, testPrefix+"/apiary"); rec.Code != http.StatusOK || spy.calls != before {
|
||||
t.Fatalf("/apiary is a client route, status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileWithExtensionIs404(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
for _, target := range []string{testPrefix + "/assets/missing.js", testPrefix + "/favicon.ico", testPrefix + "/golem/missing.css"} {
|
||||
rec := get(h, target)
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "summer-admin-base") {
|
||||
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
for _, target := range []string{testPrefix + "/acme/demo/widgets", testPrefix + "/acme/demo/widgets/12", testPrefix + "/login"} {
|
||||
rec := get(h, target)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "summer-admin-base") {
|
||||
t.Fatalf("client route %s status=%d", target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraversalIsCleaned(t *testing.T) {
|
||||
root := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte(`<meta content="` + BaseToken + `">`)},
|
||||
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
|
||||
}
|
||||
h, err := newHandler(root, testPrefix, &apiSpy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, target := range []string{
|
||||
testPrefix + "/../../../go.mod",
|
||||
testPrefix + "/assets/../../boardwalk.go",
|
||||
testPrefix + "/%2e%2e/%2e%2e/etc/passwd.txt",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.URL.Path = target
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "module ") || strings.Contains(rec.Body.String(), "package ") {
|
||||
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.URL.Path = testPrefix + "/assets/../index.html"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), BaseToken) {
|
||||
t.Fatalf("cleaned index path served the raw index: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryIsNeverListed(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := fs.ReadDir(root, "assets")
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
for _, target := range []string{testPrefix + "/assets", testPrefix + "/assets/"} {
|
||||
rec := get(h, target)
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, entries[0].Name()) || !strings.Contains(body, "summer-admin-base") {
|
||||
t.Fatalf("%s listed the directory or skipped the shell: %s", target, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTypesAndCaching(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
err = fs.WalkDir(root, "assets", func(name string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
ext := path.Ext(name)
|
||||
ct, ok := want[ext]
|
||||
if !ok || seen[ext] {
|
||||
return nil
|
||||
}
|
||||
seen[ext] = true
|
||||
rec := get(h, testPrefix+"/"+name)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != ct {
|
||||
t.Fatalf("%s status=%d type=%q, want %q", name, rec.Code, rec.Header().Get("Content-Type"), ct)
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("%s Cache-Control=%q", name, cc)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for ext := range want {
|
||||
if !seen[ext] {
|
||||
t.Fatalf("embedded dist has no %s asset", ext)
|
||||
}
|
||||
}
|
||||
index := get(h, testPrefix)
|
||||
if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("index headers = %v", index.Header())
|
||||
}
|
||||
if got := contentType("x.svg"); got != "image/svg+xml" {
|
||||
t.Fatalf("svg type %q", got)
|
||||
}
|
||||
if got := contentType("x.json"); got != "application/json" {
|
||||
t.Fatalf("json type %q", got)
|
||||
}
|
||||
if got := contentType("x.unknown-ext"); got != "application/octet-stream" {
|
||||
t.Fatalf("unknown type %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersOnEveryResponse(t *testing.T) {
|
||||
h, _ := newTestHandler(t)
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := fs.ReadDir(root, "assets")
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("embedded assets: %v", err)
|
||||
}
|
||||
for _, target := range []string{
|
||||
testPrefix,
|
||||
testPrefix + "/acme/demo/widgets",
|
||||
testPrefix + "/assets/" + entries[0].Name(),
|
||||
testPrefix + "/missing.js",
|
||||
testPrefix + "/api/v1/nope",
|
||||
} {
|
||||
rec := get(h, target)
|
||||
for header, want := range map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "same-origin",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'",
|
||||
"X-Robots-Tag": "noindex, nofollow",
|
||||
} {
|
||||
if got := rec.Header().Get(header); got != want {
|
||||
t.Fatalf("%s %s=%q, want %q", target, header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhase10BoardwalkServing covers the remaining serving branches: HEAD,
|
||||
// query strings, encoded traversal, the index requested by name, a nested
|
||||
// prefix, deep client routes, MIME fallback for unknown extensions and the
|
||||
// constructor's error paths.
|
||||
func TestPhase10BoardwalkServing(t *testing.T) {
|
||||
h, spy := newTestHandler(t)
|
||||
|
||||
t.Run("HEAD answers headers without a body", func(t *testing.T) {
|
||||
for _, target := range []string{testPrefix, testPrefix + "/acme/demo/widgets"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodHead, target, nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.Len() != 0 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("HEAD %s: status=%d len=%d type=%q", target, rec.Code, rec.Body.Len(), rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if rec.Header().Get("Content-Length") == "" || rec.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatalf("HEAD %s headers = %v", target, rec.Header())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query strings do not change what is served", func(t *testing.T) {
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := fs.ReadDir(root, "assets")
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Fatalf("assets: %v", err)
|
||||
}
|
||||
asset := get(h, testPrefix+"/assets/"+entries[0].Name()+"?v=2")
|
||||
if asset.Code != http.StatusOK || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("asset with query: status=%d headers=%v", asset.Code, asset.Header())
|
||||
}
|
||||
route := get(h, testPrefix+"/acme/demo/widgets?search=blue&page=2")
|
||||
if route.Code != http.StatusOK || !strings.Contains(route.Body.String(), "summer-admin-base") {
|
||||
t.Fatalf("client route with query: status=%d", route.Code)
|
||||
}
|
||||
before := spy.calls
|
||||
if rec := get(h, testPrefix+"/api/v1/nope?x=1"); rec.Code != http.StatusNotFound || spy.calls != before+1 {
|
||||
t.Fatalf("api with query not delegated: %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("encoded traversal never escapes the build", func(t *testing.T) {
|
||||
for _, target := range []string{
|
||||
testPrefix + "/%2e%2e/%2e%2e/go.mod",
|
||||
testPrefix + "/assets/..%2f..%2fboardwalk.go",
|
||||
testPrefix + "/%2E%2E%2F%2E%2E%2Fgo.sum",
|
||||
} {
|
||||
rec := get(h, target)
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(body, "module ") || strings.Contains(body, "package boardwalk") {
|
||||
t.Fatalf("%s status=%d body=%.80s", target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("index.html by name is the rewritten index", func(t *testing.T) {
|
||||
byName := get(h, testPrefix+"/index.html")
|
||||
root := get(h, testPrefix+"/")
|
||||
if byName.Code != http.StatusOK || byName.Body.String() != root.Body.String() {
|
||||
t.Fatalf("index.html differs from the root index")
|
||||
}
|
||||
if strings.Contains(byName.Body.String(), BaseToken) || byName.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("index.html served raw or cacheable: %v", byName.Header())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested prefix with a trailing slash", func(t *testing.T) {
|
||||
nested, err := Handler("/ops/admin/", &apiSpy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := get(nested, "/ops/admin/acme/demo/widgets/12")
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusOK || !strings.Contains(body, `content="/ops/admin"`) || !strings.Contains(body, `src="/ops/admin/assets/`) {
|
||||
t.Fatalf("nested prefix index: %d %s", rec.Code, body)
|
||||
}
|
||||
if rec := get(nested, "/ops/admin/api/v1/x"); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Fatalf("nested prefix api not delegated: %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deep extensionless paths are client routes", func(t *testing.T) {
|
||||
for _, target := range []string{testPrefix + "/a/b/c/d/e/f", testPrefix + "/settings/mail", testPrefix + "/assets"} {
|
||||
rec := get(h, target)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "summer-admin-base") {
|
||||
t.Fatalf("%s status=%d", target, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown extensions fall back to the mime table, then octet-stream", func(t *testing.T) {
|
||||
root := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte(`<meta content="` + BaseToken + `">`)},
|
||||
"robots.txt": &fstest.MapFile{Data: []byte("User-agent: *\n")},
|
||||
"assets/logo.png": &fstest.MapFile{Data: []byte("\x89PNG")},
|
||||
"assets/blob.zzzext": &fstest.MapFile{Data: []byte("x")},
|
||||
}
|
||||
mapped, err := newHandler(root, testPrefix, &apiSpy{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for target, want := range map[string]string{
|
||||
"/robots.txt": "text/plain; charset=utf-8",
|
||||
"/assets/logo.png": "image/png",
|
||||
"/assets/blob.zzzext": "application/octet-stream",
|
||||
} {
|
||||
rec := get(mapped, testPrefix+target)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != want {
|
||||
t.Fatalf("%s type=%q, want %q", target, rec.Header().Get("Content-Type"), want)
|
||||
}
|
||||
}
|
||||
if cc := get(mapped, testPrefix+"/robots.txt").Header().Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Fatalf("non-asset file Cache-Control=%q, want no-cache", cc)
|
||||
}
|
||||
if got := contentType("UPPER.JS"); got != "text/javascript; charset=utf-8" {
|
||||
t.Fatalf("extension case: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("constructor rejects a nil API handler, a relative prefix and a build without index", func(t *testing.T) {
|
||||
if _, err := Handler(testPrefix, nil); err == nil {
|
||||
t.Fatal("nil notFoundAPI accepted")
|
||||
}
|
||||
if _, err := Handler("backend", &apiSpy{}); err == nil {
|
||||
t.Fatal("relative prefix accepted")
|
||||
}
|
||||
if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), "index.html") {
|
||||
t.Fatalf("missing index: %v", err)
|
||||
}
|
||||
stale := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
|
||||
if _, err := newHandler(stale, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) {
|
||||
t.Fatalf("stale index: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
BIN
modules/boardwalk/dist/assets/dm-mono-latin-400-normal--0xN8mdc.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-400-normal--0xN8mdc.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-mono-latin-400-normal-4GdczIuU.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-400-normal-4GdczIuU.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-mono-latin-500-normal-CN8Miw6E.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-500-normal-CN8Miw6E.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-mono-latin-500-normal-DRMDZjhP.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-500-normal-DRMDZjhP.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-400-normal-1aZr6b2b.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-400-normal-1aZr6b2b.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-400-normal-C2zvOubV.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-400-normal-C2zvOubV.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-500-normal-BtRyHRi6.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-500-normal-BtRyHRi6.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-500-normal-Dw3M13d8.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-mono-latin-ext-500-normal-Dw3M13d8.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-400-normal-BwCSEQnW.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-400-normal-BwCSEQnW.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-500-normal-Dr3UlScf.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-500-normal-Dr3UlScf.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-700-normal-CUSSCpQX.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-700-normal-CUSSCpQX.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-700-normal-DvUfVpUG.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-700-normal-DvUfVpUG.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-700-normal-BLI3TTWz.woff
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-700-normal-BLI3TTWz.woff
vendored
Normal file
Binary file not shown.
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-700-normal-CJIcxD6K.woff2
vendored
Normal file
BIN
modules/boardwalk/dist/assets/dm-sans-latin-ext-700-normal-CJIcxD6K.woff2
vendored
Normal file
Binary file not shown.
3
modules/boardwalk/dist/assets/index-BAlwlQ8W.js
vendored
Normal file
3
modules/boardwalk/dist/assets/index-BAlwlQ8W.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-CLf0gZ3D.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-CLf0gZ3D.css
vendored
Normal file
File diff suppressed because one or more lines are too long
15
modules/boardwalk/dist/index.html
vendored
Normal file
15
modules/boardwalk/dist/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-BAlwlQ8W.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CLf0gZ3D.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user