package site import ( "errors" "io" "log/slog" "mime" "net/http" "net/url" "path" "strconv" "github.com/iceBear67/simplepages/internal/cas" "github.com/iceBear67/simplepages/internal/httpx" ) // Handler serves the active deployment of every project. // // Content comes from the content store by digest, never from the assembled // directory. That is a security decision as much as a performance one: the only // filesystem path this path ever builds is cas/<2>/<2>/<64 hex>, derived from a // [32]byte that came out of a map lookup. No user-controlled string reaches the // filesystem at all, so traversal on the read path is not defended against — // it is structurally impossible. type Handler struct { Registry *Registry CAS *cas.Store Log *slog.Logger } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { w.Header().Set("Allow", "GET, HEAD") h.fail(w, http.StatusMethodNotAllowed, "method not allowed\n") return } project, rest, ok := splitTilde(r.URL.Path) if !ok { h.fail(w, http.StatusNotFound, "no site is served at this path; sites live under /~project/\n") return } p, ok := h.Registry.Lookup(project) if !ok { h.fail(w, http.StatusNotFound, "no such project\n") return } httpx.LogAttr(r.Context(), "project", project) // Loaded exactly once. Every decision below uses this snapshot, so an // activation that lands mid-request cannot split the response across two // versions. Calling p.Active() again anywhere in this function would // reintroduce precisely the failure this program exists to prevent. d := p.Active() if d == nil { h.fail(w, http.StatusServiceUnavailable, "this project has no active deployment\n") return } httpx.LogAttr(r.Context(), "deployment", d.ID) cfg := p.Config() res := Resolve(d, cfg, project, rest, r.Header.Get("Accept")) switch { case res.Status == http.StatusMovedPermanently: // url.URL re-escapes the path, so a project or file name that needed // escaping in the request does not arrive raw in the Location header. loc := url.URL{Path: res.Location, RawQuery: r.URL.RawQuery} w.Header().Set("X-Content-Type-Options", "nosniff") http.Redirect(w, r, loc.String(), http.StatusMovedPermanently) case res.Status == http.StatusBadRequest: h.fail(w, http.StatusBadRequest, "bad request path\n") case res.Name == "": h.fail(w, res.Status, "404 page not found\n") default: h.serveEntry(w, r, d, cfg, res) } } func (h *Handler) serveEntry(w http.ResponseWriter, r *http.Request, d *Deployment, cfg *ProjectConfig, res Result) { f, err := h.CAS.Open(res.Entry.Digest) if err != nil { if errors.Is(err, cas.ErrNotFound) { // The manifest says this blob exists and the store disagrees. GC's // grace period is supposed to make this unreachable, so it means // either a bug or an operator who cleared the store by hand. h.Log.ErrorContext(r.Context(), "blob missing from the content store", "digest", res.Entry.Digest, "deployment", d.ID, "path", res.Name) h.fail(w, http.StatusNotFound, "404 page not found\n") return } h.Log.ErrorContext(r.Context(), "opening blob", "err", err, "deployment", d.ID, "path", res.Name) h.fail(w, http.StatusInternalServerError, "internal server error\n") return } defer f.Close() head := w.Header() head.Set("X-Content-Type-Options", "nosniff") // The type comes from the logical name, not the CAS path, which has no // extension at all. ctype := mime.TypeByExtension(path.Ext(res.Name)) if ctype == "" { ctype = "application/octet-stream" } head.Set("Content-Type", ctype) if cfg.CacheControl != "" { head.Set("Cache-Control", cfg.CacheControl) } if cfg.SPAFallback { // With the fallback on, one URL can answer with the app shell or with a // 404 depending on Accept, so a shared cache must key on it. head.Set("Vary", "Accept") } if res.Status != http.StatusOK { // The custom 404 document. ServeContent always writes 200, so this one // is written by hand; range requests for an error page are not worth // the machinery. head.Set("Content-Length", strconv.FormatInt(res.Entry.Size, 10)) w.WriteHeader(res.Status) if r.Method != http.MethodHead { io.Copy(w, f) } return } // A strong validator: the digest *is* the content, so a matching ETag // cannot be a lie. With it set, ServeContent handles If-None-Match, // If-Modified-Since, If-Range, Range and multipart ranges by itself. head.Set("ETag", `"sha256:`+res.Entry.Digest.String()+`"`) // The empty name keeps ServeContent from sniffing: the type is already set. // The modtime is the deployment's, never the blob's — blobs are shared // across projects, so their mtime says when some unrelated project first // uploaded the same bytes. http.ServeContent(w, r, "", d.CreatedAt, f) } // fail writes a plain-text response. Site errors are never JSON: whatever is on // the other end of a static request is a browser or a curl, not an API client. // http.Error sets the text/plain type and the nosniff header for us. func (h *Handler) fail(w http.ResponseWriter, status int, msg string) { http.Error(w, msg, status) }