init
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user