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