130 lines
4.7 KiB
Go
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
|
|
}
|