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 }