144 lines
5.1 KiB
Go
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)
|
|
}
|