Files
2026-08-15 07:13:00 +00:00

144 lines
5.1 KiB
Go

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)
}