// Package site turns an activated deployment into HTTP responses. // // Everything here is built around one property: a request must never observe a // mixture of two deployments. That is achieved by making the served state an // immutable snapshot behind an atomic pointer — a handler loads it once, at the // top, and every subsequent decision in that request comes from the value it // loaded. Switching versions is one pointer store, so an in-flight request keeps // reading the deployment it started on until it finishes. package site import ( "path" "time" "github.com/iceBear67/simplepages/internal/cas" "github.com/iceBear67/simplepages/internal/store" ) // FileEntry is what a request path resolves to: enough to serve the bytes and // to answer a conditional request, and nothing else. type FileEntry struct { Digest cas.Digest Size int64 } // Index is the part of a snapshot that costs something to build. // // It is separate from Deployment so activation can pay for it *before* it // changes anything: if the manifest cannot be read or turned into these maps, // the failure happens while the old deployment is still the active one. type Index struct { files map[string]FileEntry dirs map[string]struct{} totalBytes int64 } // NewIndex builds the lookup structures for one manifest. // // Dirs holds every directory prefix, which is what lets Resolve tell "no such // path" apart from "that is a directory, redirect to it with a trailing slash" // without scanning the file map for prefixes on every miss. func NewIndex(files []store.FileRow) *Index { idx := &Index{ files: make(map[string]FileEntry, len(files)), dirs: make(map[string]struct{}), } for _, f := range files { idx.files[f.Path] = FileEntry{Digest: f.Digest, Size: f.Size} idx.totalBytes += f.Size for dir := path.Dir(f.Path); dir != "." && dir != "/"; dir = path.Dir(dir) { if _, ok := idx.dirs[dir]; ok { // Every shorter prefix was added with this one, so there is // nothing left to walk. break } idx.dirs[dir] = struct{}{} } } return idx } // Deployment is an immutable snapshot of what a project serves. // // Nothing mutates one after NewDeployment returns. That is the whole reason a // version switch can be a bare pointer store: readers need no synchronisation // beyond the atomic load that handed them the snapshot. type Deployment struct { ID string ProjectID int64 // Dir is the assembled tree, kept for the webroot symlink and for // operators. It is empty under assemble_mode=none and is never used to // serve a request — content comes from the CAS by digest. Dir string // CreatedAt is the Last-Modified of every file in the deployment. The CAS // file's own mtime would be wrong: blobs are shared across projects and // versions, so their mtime means nothing here and would leak when some // other project first uploaded the same bytes. CreatedAt time.Time ActivatedAt time.Time FileCount int TotalBytes int64 idx *Index } // NewDeployment pairs a row with an already-built index. It allocates one // struct and copies no maps, so activation can call it after the database // commit without any risk of failing there. func NewDeployment(dep *store.Deployment, idx *Index, dir string) *Deployment { d := &Deployment{ ID: dep.PublicID, ProjectID: dep.ProjectID, Dir: dir, CreatedAt: dep.CreatedAt, FileCount: len(idx.files), TotalBytes: idx.totalBytes, idx: idx, } if dep.ActivatedAt != nil { d.ActivatedAt = *dep.ActivatedAt } return d } // Lookup finds one file by its slash-separated path within the deployment. func (d *Deployment) Lookup(rel string) (FileEntry, bool) { e, ok := d.idx.files[rel] return e, ok } // IsDir reports whether rel is a directory prefix of some file. func (d *Deployment) IsDir(rel string) bool { _, ok := d.idx.dirs[rel] return ok }