Skip to content
quarry Artifact [606015e3ef]
Login

Artifact [606015e3ef]

Artifact 606015e3efd8f4ba1de0f992b0252e5076e3bdebb9985be6dc2980fc18abe12f:


// router.go — the API consumer of the registry (0001 §9). Every registered
// op is mounted on a stdlib ServeMux; input crosses through the shared
// codecs and validator, output leaves as JSON. The generated OpenAPI
// document is served from the same registry at /api/v1/openapi.json.
package api

import (
	"craftdesign.group/quarry/internal/errlog"
	"craftdesign.group/quarry/internal/oltp"

	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"mime"
	"net/http"
	"net/url"
	"reflect"
	"sort"
	"strings"

	"craftdesign.group/quarry/internal/ops"

	"craftdesign.group/quarry/internal/fault"
)

// Verify checks one repo credential pair against the capability the op
// requires. A nil Verify leaves every gated op open (single-user local
// serving); the composition root supplies the OLTP-backed implementation.
// need is the op's own ops.Auth, so one seam serves both the write ops
// (Token) and the administrative ones (Admin) without the router
// deciding what either means.
type Verify func(repo, user, password string, need ops.Auth) Decision

// CapSet is the expanded capability set for one (account, repo). It
// mirrors oltp.CapSet without importing it — the router stays free of
// storage types.
type CapSet interface{ HasAny(letters string) bool }

// CapsFor resolves effective capabilities. nil disables cap enforcement
// in the router (the Auth floor still applies), which is what a forge
// with no accounts plane wants.
type CapsFor func(account, repo string) CapSet

// Decision is what a credential check concluded. Three outcomes rather
// than a bool because two of them are not the same thing and the wire
// has distinct codes for them: a client told 401 retries with the same
// credentials forever, when what it needed to hear was that the
// credentials were fine and the capability was not.
type Decision int

const (
	// Denied: absent or wrong credentials. 401, with a challenge.
	Denied Decision = iota
	// Insufficient: authenticated, but lacking the capability. 403, and
	// deliberately WITHOUT a challenge — re-authenticating cannot help.
	Insufficient
	// Allowed.
	Allowed
)

// Router mounts every op in r plus the OpenAPI document. version flows
// into the spec's info block. verify (optional) gates ops.Token ops
// with HTTP Basic against it.
func Router(r *ops.Registry, version string, verify ...Verify) (http.Handler, error) {
	return RouterWithCaps(r, version, nil, verify...)
}

// RouterWithCaps is Router plus capability enforcement (RFC 0004).
func RouterWithCaps(r *ops.Registry, version string, capsFor CapsFor, verify ...Verify) (http.Handler, error) {
	var v Verify
	if len(verify) > 0 {
		v = verify[0]
	}
	mux := http.NewServeMux()
	for _, d := range r.All() {
		mux.Handle(d.Method+" "+d.Pattern, opHandler(r, d, v, capsFor))
	}

	spec, err := ops.Spec(r, "quarry", version)
	if err != nil {
		return nil, err
	}
	specJSON, err := json.MarshalIndent(spec, "", "  ")
	if err != nil {
		return nil, err
	}
	// The service worker (RFC 0011 §6). Registered here so a node that
	// mounts this router at the root serves it without extra wiring;
	// the composition root mounts it at "/" as well, because quarry's
	// own server mounts this router under /api/ and a worker served
	// from there could not claim the site's scope.
	mux.Handle("GET /sw.js", ServiceWorker())
	mux.HandleFunc("GET /api/v1/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Write(specJSON)
	})
	// Unmatched API paths answer in the API's own dialect, never Go's
	// plain-text default.
	mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
		writeError(w, http.StatusNotFound, errors.New("no such endpoint"))
	})
	return mux, nil
}

func opHandler(r *ops.Registry, d *ops.Descriptor, verify Verify, capsFor CapsFor) http.Handler {
	wild := ops.Wildcards(d.Pattern)
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		// Resolve identity even for ANONYMOUS ops. A private repository
		// is readable by the people with a role on it, and a read op
		// that never looks at credentials cannot tell those people from
		// strangers — so its owner would be locked out of their own
		// repository.
		if d.Auth == ops.Anonymous && verify != nil {
			if user, pass, ok := req.BasicAuth(); ok && user != "" {
				if verify(req.PathValue("repo"), user, pass, ops.Anonymous) == Allowed {
					req = req.WithContext(ops.WithUser(req.Context(), user))
				}
			}
		}
		if d.Auth != ops.Anonymous && verify != nil {
			user, pass, _ := req.BasicAuth()
			switch verify(req.PathValue("repo"), user, pass, d.Auth) {
			case Denied:
				w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`)
				writeError(w, http.StatusUnauthorized, errors.New("authentication required"))
				return
			case Insufficient:
				writeError(w, http.StatusForbidden,
					errors.New("this account does not have the capability this request needs"))
				return
			}
			req = req.WithContext(ops.WithUser(req.Context(), user))
		}
		// The capability refinement (RFC 0004 §4): Auth was the coarse
		// floor, Caps is what this SURFACE needs. Unwired (nil) means
		// caps are not enforced here — the floor still stands.
		if d.Caps != "" && capsFor != nil {
			// Most ops name their repository in the path; a few (the
			// repos/* family) carry it as a field instead, and caps
			// resolved against "" would silently refuse everyone.
			repo := req.PathValue("repo")
			if repo == "" {
				repo = repoFromBody(req)
			}
			// A repo-scoped check with no repository is a FAULT IN THE
			// FORGE, not a decision about the caller.
			//
			// This is the shape that cost four incidents: the subject
			// resolves to "", the caps lookup answers confidently about
			// the fleet instead, and the caller gets a 403 that reads
			// like policy. b683c54b sat open for two days because the
			// refusal named a capability and a grantor and sounded
			// entirely deliberate. Saying 500 here is louder and more
			// honest: nobody debugs their permissions over it.
			if d.Scope == ops.ScopeRepo && repo == "" {
				log.Printf("api: %s is repo-scoped and no repository was resolved from %s %s",
					d.Name, d.Method, d.Pattern)
				writeError(w, http.StatusInternalServerError,
					errors.New("this request names no repository, so "+d.Name+
						" cannot say who may do it; this is a fault in the forge, not in your credentials"))
				return
			}
			c := capsFor(ops.User(req.Context()), repo)
			if !c.HasAny(d.Caps) {
				writeError(w, http.StatusForbidden, capRefusal(ops.User(req.Context()), repo, d.Caps))
				return
			}
			// Carried on, not discarded: Caps answered "may they reach
			// this surface", and an op may still need to know WHICH
			// letters they hold to decide what the surface shows.
			req = req.WithContext(ops.WithCaps(req.Context(), c))
		}
		in, err := decodeIn(req, d, wild)
		if err != nil {
			writeError(w, http.StatusBadRequest, err)
			return
		}
		if leid := req.Header.Get("Last-Event-ID"); leid != "" && req.URL.Query().Get("from") == "" {
			// A reconnecting SSE client resends its cursor in the
			// header, not the query string (Datastar's fetch.ts and
			// every other EventSource client agree on this). Overlay
			// it onto whichever field an op's In struct names "from" —
			// unknown-key overlays are a no-op, so this is harmless on
			// every non-streaming op too.
			//
			// An explicit ?from= in the query wins: it is the caller
			// stating a cursor for THIS request, while the header is a
			// cursor the browser remembered from a connection that has
			// since dropped. Silently overwriting the former with the
			// latter makes an explicit resume unrepeatable.
			ptr := reflect.New(reflect.TypeOf(in))
			ptr.Elem().Set(reflect.ValueOf(in))
			if err := ops.OverlayValues(map[string][]string{"from": {leid}}, ptr.Interface()); err == nil {
				in = deref(ptr.Interface())
			}
		}
		if err := ops.Validate(in); err != nil {
			writeError(w, http.StatusBadRequest, err)
			return
		}
		if d.Kind == ops.Stream {
			serveStream(w, req, r, d, in)
			return
		}
		out, err := r.Invoke(req.Context(), d.Name, in)
		if errors.Is(err, ops.ErrNotFound) {
			writeError(w, http.StatusNotFound, err)
			return
		}
		if errors.Is(err, ops.ErrInvalid) {
			writeError(w, http.StatusBadRequest, err)
			return
		}
		if errors.Is(err, ops.ErrForbidden) {
			writeError(w, http.StatusForbidden, err)
			return
		}
		if errors.Is(err, ops.ErrUnavailable) {
			writeError(w, http.StatusServiceUnavailable, err)
			return
		}
		if err != nil {
			// The real error goes to the log AND the journal; the public
			// body never carries internals (subprocess invocations, SQL,
			// paths). The journal is what makes "details are in the
			// server log" a promise someone can actually collect on.
			log.Printf("api: %s: %v", d.Name, err)
			errlog.Default.ReportCtx(req.Context(), "api", d.Name, req.PathValue("repo"), "",
				fault.Wrap(err, "an API operation failed").
					Cost("this caller got a 500; the public body says nothing, so the journal is the only account of why").
					With("op", d.Name, "method", req.Method, "path", req.URL.Path))
			writeError(w, http.StatusInternalServerError, errors.New("internal error"))
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(out)
	})
}

// decodeIn assembles the op's In struct from the request: query string
// (GET) or body (POST, form or JSON by content type), with path wildcards
// merged last so the routed path is always authoritative.
func decodeIn(req *http.Request, d *ops.Descriptor, wild []string) (any, error) {
	inPtr := d.NewIn()

	switch {
	case d.Method == http.MethodGet:
		if err := ops.DecodeValues(req.URL.Query(), inPtr); err != nil {
			return nil, err
		}
	case isJSON(req):
		// A JSON body is REFUSED for a field this op does not have.
		//
		// Silently ignoring one is Go's default and it loses writes: a
		// body sent under a near-miss key is discarded at decode and
		// the op answers 200, so the caller is told it worked. That
		// cost about a dozen detailed ticket filings in one afternoon,
		// each reduced to its title, because `description` is a
		// plausible spelling of a field ticket.new calls `comment`
		// (628a6ff4).
		//
		// JSON only, deliberately. A caller writing an object means
		// every key in it, so an unknown one is a mistake worth saying
		// out loud. A FORM legitimately carries keys no op declares --
		// submit buttons, CSRF tokens, the page's own state -- and
		// query strings the same, so those two stay lenient. Refusing
		// there would break browsers to fix a mistake only API callers
		// can make.
		body, err := io.ReadAll(req.Body)
		if err != nil {
			return nil, fmt.Errorf("body: %w", err)
		}
		dec := json.NewDecoder(bytes.NewReader(body))
		dec.DisallowUnknownFields()
		if err := dec.Decode(inPtr); err != nil {
			if name, ok := unknownField(err); ok {
				return nil, fmt.Errorf("body: no field %q on %s; it takes: %s: %w",
					name, d.Name, strings.Join(inFieldNames(inPtr), ", "), ops.ErrInvalid)
			}
			return nil, fmt.Errorf("body: %w", err)
		}
		// A JSON body honours a declared default too. The struct alone
		// cannot say which fields the caller omitted — a zero looks the
		// same whether it was sent or not — so the body is read once
		// and its own top-level keys are what absence is judged by.
		// Read into memory rather than streamed twice because the
		// decoder above has already consumed it, and because the same
		// bytes must answer both questions.
		if err := ops.ApplyDefaults(inPtr, bodyKeys(body)); err != nil {
			return nil, err
		}
	default:
		if err := req.ParseForm(); err != nil {
			return nil, fmt.Errorf("form: %w", err)
		}
		if err := ops.DecodeValues(req.PostForm, inPtr); err != nil {
			return nil, err
		}
	}

	if len(wild) > 0 {
		pathVals := url.Values{}
		for _, name := range wild {
			pathVals.Set(name, req.PathValue(name))
		}
		if err := ops.OverlayValues(pathVals, inPtr); err != nil {
			return nil, err
		}
	}
	return deref(inPtr), nil
}

// unknownField picks the field name out of encoding/json's
// DisallowUnknownFields error, whose text is the only place it appears.
// Matched on the stable prefix rather than the whole sentence, and a
// miss falls back to the raw error rather than inventing a name.
func unknownField(err error) (string, bool) {
	const prefix = `json: unknown field `
	msg := err.Error()
	i := strings.Index(msg, prefix)
	if i < 0 {
		return "", false
	}
	return strings.Trim(strings.TrimSpace(msg[i+len(prefix):]), `"`), true
}

// inFieldNames lists an op's input fields as a caller must spell them.
//
// The refusal names them because "no field X" alone leaves somebody
// guessing a second time, and the guess is what caused this. Sorted, so
// the answer does not depend on struct order.
func inFieldNames(inPtr any) []string {
	t := reflect.TypeOf(inPtr)
	for t != nil && t.Kind() == reflect.Pointer {
		t = t.Elem()
	}
	if t == nil || t.Kind() != reflect.Struct {
		return nil
	}
	var out []string
	var walk func(reflect.Type)
	walk = func(t reflect.Type) {
		for i := 0; i < t.NumField(); i++ {
			f := t.Field(i)
			if f.Anonymous && f.Type.Kind() == reflect.Struct {
				walk(f.Type)
				continue
			}
			name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
			if name == "-" || name == "" || !f.IsExported() {
				continue
			}
			out = append(out, name)
		}
	}
	walk(t)
	sort.Strings(out)
	return out
}

func deref(p any) any {
	return reflect.ValueOf(p).Elem().Interface()
}

func isJSON(req *http.Request) bool {
	ct, _, _ := mime.ParseMediaType(req.Header.Get("Content-Type"))
	return strings.HasSuffix(ct, "/json") || strings.HasSuffix(ct, "+json")
}

// serveStream switches the response to Server-Sent Events and runs a
// Stream op's StreamRun until the client disconnects (req.Context()
// done) or the op itself returns. Headers first, exactly once — an op
// that errors mid-stream cannot say so any other way than closing.
func serveStream(w http.ResponseWriter, req *http.Request, r *ops.Registry, d *ops.Descriptor, in any) {
	fl, ok := w.(http.Flusher)
	if !ok {
		writeError(w, http.StatusInternalServerError, errors.New("streaming unsupported"))
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("X-Accel-Buffering", "no")
	w.WriteHeader(http.StatusOK)
	fmt.Fprint(w, ": live\n\n")
	fl.Flush()
	st := &sseStreamer{w: w, fl: fl}
	if err := r.InvokeStream(req.Context(), d.Name, in, st); err != nil && !errors.Is(err, context.Canceled) {
		log.Printf("api: %s: stream: %v", d.Name, err)
	}
}

// sseStreamer is the router's ops.Streamer: it writes wire-format SSE
// frames and flushes after every one, since a buffered frame is a
// delivered message a client hasn't seen yet.
type sseStreamer struct {
	w  http.ResponseWriter
	fl http.Flusher
}

func (s *sseStreamer) Emit(id uint64, data any) error {
	b, err := json.Marshal(data)
	if err != nil {
		return err
	}
	if id != 0 {
		fmt.Fprintf(s.w, "id: %d\n", id)
	}
	fmt.Fprintf(s.w, "data: %s\n\n", b)
	s.fl.Flush()
	return nil
}

func (s *sseStreamer) Event(name string, data any) error {
	b, err := json.Marshal(data)
	if err != nil {
		return err
	}
	fmt.Fprintf(s.w, "event: %s\n", name)
	fmt.Fprintf(s.w, "data: %s\n\n", b)
	s.fl.Flush()
	return nil
}

func (s *sseStreamer) Ping() {
	fmt.Fprint(s.w, ": ping\n\n")
	s.fl.Flush()
}

// capRefusal says what was wanted and of whom, rather than that
// something was wanted.
//
// Every capability refusal on the forge came out of one line — "this
// account does not have the capability this request needs" — which is
// true, names no letter, no ability and no remedy, and leaves a reader
// unable to tell whether they asked for the wrong thing or hold the
// wrong role. jva met it from all three vantages and could act on it
// from none (ticket f44e0c66).
//
// The forge knows the answer at the moment it refuses. This is it
// saying so: who, where, and what the letter buys, in the words the
// settings page already uses.
// maxGateBody bounds what the gate will read looking for a repository
// name. Generous for a field-carrying op and small enough that a large
// upload is not buffered here; anything past it is streamed to the op
// untouched.
const maxGateBody = 1 << 20

// repoFromBody finds the repository an op names as a FIELD rather than
// in its path (the repos/* and accounts/* families).
//
// This read ParseForm alone, which parses form-encoded bodies and
// leaves a JSON one untouched. Every API client sends JSON, so the
// field came back empty, caps resolved against "" — the fleet scope,
// where a repository admin holds nothing — and repo.update refused the
// repository's own admin while account.explain, reading the same
// letters, said they held "a". That contradiction is b683c54b, and it
// made the two ops unusable by anyone who was not a site admin.
//
// The body is PUT BACK. A gate that consumed it would turn a fixed
// refusal into a silent empty update, which is worse than the refusal
// it replaced.
func repoFromBody(req *http.Request) string {
	if req.Body == nil {
		return ""
	}
	ct := req.Header.Get("Content-Type")
	if mt, _, err := mime.ParseMediaType(ct); err == nil {
		ct = mt
	}
	if ct == "application/x-www-form-urlencoded" || ct == "multipart/form-data" {
		if err := req.ParseForm(); err != nil {
			return ""
		}
		if v := req.Form.Get("repo"); v != "" {
			return v
		}
		return req.Form.Get("name")
	}
	// Anything else is treated as JSON, which is what the API speaks.
	head, err := io.ReadAll(io.LimitReader(req.Body, maxGateBody))
	if err != nil {
		// Leave what is left readable; the op reports its own failure.
		req.Body = io.NopCloser(io.MultiReader(bytes.NewReader(head), req.Body))
		return ""
	}
	req.Body = &bodyWithPrefix{prefix: bytes.NewReader(head), rest: req.Body}
	var named struct {
		Repo string `json:"repo"`
		Name string `json:"name"`
	}
	if err := json.Unmarshal(head, &named); err != nil {
		return ""
	}
	if named.Repo != "" {
		return named.Repo
	}
	return named.Name
}

// bodyWithPrefix replays what the gate read, then the remainder, and
// closes the original — so an op that expects an io.ReadCloser gets
// one and the connection is still released.
type bodyWithPrefix struct {
	prefix *bytes.Reader
	rest   io.ReadCloser
}

func (b *bodyWithPrefix) Read(p []byte) (int, error) {
	if b.prefix.Len() > 0 {
		return b.prefix.Read(p)
	}
	return b.rest.Read(p)
}

func (b *bodyWithPrefix) Close() error { return b.rest.Close() }

func capRefusal(user, repo, want string) error {
	who := user
	if who == "" {
		who = "this request"
	}
	where := ""
	if repo != "" {
		where = " on " + repo
	}
	if s := oltp.Wanted(want); s != "" {
		return fmt.Errorf("%s cannot %s%s: that needs capability %q, which an admin of the repository grants",
			who, s, where, want)
	}
	return fmt.Errorf("%s lacks capability %q%s, which an admin of the repository grants", who, want, where)
}

func writeError(w http.ResponseWriter, code int, err error) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}

// bodyKeys is the set of top-level keys a JSON object actually carries,
// lowercased to match encoding/json's case-insensitive field matching.
//
// A body that is not an object answers the empty set rather than an
// error: the decode above has already had its say about the shape, and
// this is only here to decide which defaults to skip.
func bodyKeys(body []byte) map[string]bool {
	var raw map[string]json.RawMessage
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil
	}
	keys := make(map[string]bool, len(raw))
	for k := range raw {
		keys[strings.ToLower(k)] = true
	}
	return keys
}