Files
pages/internal/site/resolve.go
T
2026-08-15 07:13:00 +00:00

130 lines
4.7 KiB
Go

package site
import (
"net/http"
"path"
"strings"
"github.com/iceBear67/simplepages/internal/pathutil"
)
// Result is what a request path resolved to. It is a value, not a response:
// Resolve performs no I/O and touches nothing, which is what makes the routing
// rules exhaustively testable as a table.
type Result struct {
Entry FileEntry
// Name is the logical path within the deployment, which decides the
// Content-Type. It is never a filesystem path.
Name string
// Status is 200, 301, 400 or 404. A 404 may still carry an Entry, which is
// the project's custom not-found document.
Status int
// Location is the site-absolute path to redirect to, set when Status is 301.
// It is unescaped; the caller is responsible for building the header value.
Location string
}
// Resolve maps a request onto a file in the deployment.
//
// project and rest are what splitTilde produced, so rest keeps its leading
// slash and is empty for "/~proj". accept is the request's Accept header, which
// only matters for the SPA fallback.
func Resolve(d *Deployment, cfg *ProjectConfig, project, rest, accept string) Result {
// "/~proj" must become "/~proj/" before anything else: without the trailing
// slash every relative link in the page would resolve one level too high.
if rest == "" {
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + "/"}
}
// splitTilde always leaves the leading slash on, so this cannot happen from
// the serving path. Enforcing it anyway is what keeps every "/~" + project +
// … below a path *inside* the project: without it a rest of "x" would build
// "/~demox" and send the client to a different project entirely.
if rest[0] != '/' {
return Result{Status: http.StatusBadRequest}
}
// Canonicalise. The trailing slash survives Clean deliberately — it is the
// difference between asking for a directory and asking for a file, and
// dropping it here would redirect "/dir/" to "/dir" only for step 6 to
// redirect it back.
trailing := strings.HasSuffix(rest, "/")
clean := path.Clean(rest)
canon := clean
if trailing && clean != "/" {
canon += "/"
}
if canon != rest {
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + canon}
}
rel := strings.TrimPrefix(clean, "/")
if rel == "" {
if cfg.IndexFile == "" {
return Result{Status: http.StatusNotFound}
}
rel = cfg.IndexFile
}
// Clean has already removed any "..", so this is defence in depth rather
// than the primary guard — but it is also what rejects NUL and control
// bytes, and a path that cannot be a manifest entry cannot be a hit.
if err := pathutil.Validate(rel); err != nil {
return Result{Status: http.StatusBadRequest}
}
if e, ok := d.Lookup(rel); ok {
if trailing && clean != "/" {
// "/page.html/" names a file with a directory's URL. Serving it there
// would make every relative link inside resolve one level too deep,
// and would cache the same bytes under two URLs.
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + clean}
}
return Result{Entry: e, Name: rel, Status: http.StatusOK}
}
if d.IsDir(rel) {
if !trailing {
// Redirect rather than serve the index directly, again so relative
// links inside the page resolve against the directory.
return Result{Status: http.StatusMovedPermanently, Location: "/~" + project + clean + "/"}
}
if idx := path.Join(rel, cfg.IndexFile); pathutil.Validate(idx) == nil {
if e, ok := d.Lookup(idx); ok {
return Result{Entry: e, Name: idx, Status: http.StatusOK}
}
}
}
// SPA fallback, gated on Accept. Without that gate a missing
// /assets/app.js would come back as HTML with status 200, and the failure
// surfaces later as "Unexpected token '<'" somewhere entirely unrelated.
if cfg.SPAFallback && acceptsHTML(accept) {
if e, ok := d.Lookup(cfg.IndexFile); ok {
return Result{Entry: e, Name: cfg.IndexFile, Status: http.StatusOK}
}
}
if cfg.NotFoundFile != "" {
if e, ok := d.Lookup(cfg.NotFoundFile); ok {
return Result{Entry: e, Name: cfg.NotFoundFile, Status: http.StatusNotFound}
}
}
return Result{Status: http.StatusNotFound}
}
// acceptsHTML reports whether the client asked for HTML specifically.
//
// "*/*" does not count. A browser navigating to a page sends text/html; a
// fetch() for a script or a JSON document sends */* or something narrower, and
// those are exactly the requests that must keep getting a 404.
func acceptsHTML(accept string) bool {
for len(accept) > 0 {
var field string
field, accept, _ = strings.Cut(accept, ",")
media, _, _ := strings.Cut(field, ";")
if strings.EqualFold(strings.TrimSpace(media), "text/html") {
return true
}
}
return false
}