clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / internal / workbench / oprunner / serve.go
serve.go 4.2K
1package oprunner23import (4 "errors"5 "fmt"6 "io"7 "net/http"8 "strings"9)1011// The HTTP envelope: the three refusals and the streamed answer.12//13// They live in this package rather than in the workbench's handler because14// they are the properties issue #5 ยง5.6 asks to be carried verbatim, and a15// property that is worth naming is worth being able to test without a page16// around it.1718// ErrForeignOrigin is a cross-site POST turned away.19var ErrForeignOrigin = errors.New("operations are submitted from the workbench alone")2021// CheckOrigin refuses a request whose Origin is not this server's own.22//23// A browser sends Origin on any cross-site POST and omits it on a same-origin24// form submission from an ordinary page, so an absent header is not suspicious25// -- it is what a plain form looks like. A present one has to agree with the26// host the request arrived at, scheme included.27func CheckOrigin(r *http.Request) error {28 origin := r.Header.Get("Origin")29 if origin == "" {30 return nil31 }32 for _, scheme := range []string{"http://", "https://"} {33 if origin == scheme+r.Host {34 return nil35 }36 }37 return fmt.Errorf("%w: %s is not %s", ErrForeignOrigin, origin, r.Host)38}3940// ValidTarget admits the targets a crawl may be pointed at: lowercase names,41// digits, separators, and -- for a source addressed as two slugs joined by a42// slash -- exactly one slash.43//44// A leading dash is refused so a target can never be read as a flag. The rule45// is the reference workbench's, carried unchanged, because it is the shape the46// sources' own slugs actually take and a looser one would only be looser.47func ValidTarget(target string, pair bool) error {48 if target == "" {49 return errors.New("empty")50 }51 if strings.HasPrefix(target, "-") {52 return errors.New("starts with a dash")53 }54 slashes := 055 for _, r := range target {56 switch {57 case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':58 case r == '/':59 slashes++60 default:61 return fmt.Errorf("contains %q", r)62 }63 }64 if pair && slashes != 1 {65 return errors.New("needs exactly one slash")66 }67 if !pair && slashes != 0 {68 return errors.New("takes no slash")69 }70 if strings.HasPrefix(target, "/") || strings.HasSuffix(target, "/") {71 return errors.New("has an empty half")72 }73 return nil74}7576// A RowWriter renders one row onto the wire. The runner owns when a row77// happens; what a row looks like belongs to whoever asked for the operation,78// which is what keeps HTML out of this package.79type RowWriter func(io.Writer, Row) error8081// Serve is the whole envelope around one operation: an origin-checked POST,82// one operation at a time, and the run's rows streamed as they arrive.83//84// - A foreign Origin is 403 and nothing runs.85// - A second operation is 409 and nothing runs.86// - Otherwise the answer is 200 and a stream: every row is written through87// render and flushed, so a long crawl reads as progress rather than as88// silence.89//90// Once the first row is out the status is spent, which is why both refusals91// are decided before it. A failure after that point is the last row's business,92// not the status line's -- the operation was accepted and did happen.93func (r *Runner) Serve(w http.ResponseWriter, req *http.Request, op Operation, render RowWriter) {94 if err := CheckOrigin(req); err != nil {95 http.Error(w, err.Error(), http.StatusForbidden)96 return97 }98 if err := op.Validate(); err != nil {99 http.Error(w, err.Error(), http.StatusBadRequest)100 return101 }102 release, ok := r.Acquire(op.Name)103 if !ok {104 http.Error(w, ErrBusy.Error()+": "+r.Busy(), http.StatusConflict)105 return106 }107 defer release()108109 w.Header().Set("Content-Type", "text/html; charset=utf-8")110 w.Header().Set("Cache-Control", "no-store")111 // The rows are HTML fragments and are streamed, so a browser must not be112 // left to guess at the type from the first chunk it sees.113 w.Header().Set("X-Content-Type-Options", "nosniff")114 w.WriteHeader(http.StatusOK)115116 control := http.NewResponseController(w)117 _ = Stream(req.Context(), op, func(row Row) error {118 if err := render(w, row); err != nil {119 return err120 }121 return control.Flush()122 })123 // Every ending is already a row: a failed program's exit, a page that went124 // away mid-run, a run that finished. There is nothing left to say here.125}