// 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"
"strconv"
"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
// CanRead reports whether the identity carried in ctx may see the
// repository at all. nil means every repository is readable, which is
// the same "no accounts plane, no strangers to hide from" fallback the
// other gates here take.
//
// It exists because a capability and a visibility are different
// questions and the refusal must not answer the wrong one. A private
// repository is one that does not exist for anyone else, and a 403
// saying "you lack o on secret-repo" confirms both that secret-repo
// exists and what it is called — to a caller who is supposed to have no
// way to tell it from a name nobody ever chose. So when a repo-scoped
// op is refused for lacking a letter, this decides which sentence the
// caller gets: not-found, which is the same answer an unknown name
// gets, or the capability refusal, which is only honest once they can
// see the repository it names.
type CanRead func(ctx context.Context, repo string) bool
// 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, nil, verify...)
}
// RouterWithCaps is Router plus capability enforcement (RFC 0004).
//
// canRead may be nil, in which case no repository is treated as hidden
// and a refusal always reads as a capability refusal.
func RouterWithCaps(r *ops.Registry, version string, capsFor CapsFor, canRead CanRead, 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, canRead))
}
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, req *http.Request) {
// The web addresses a repository as /r/{owner}/{repo}; the API
// keys it by bare name, so /api/v1/r/{owner}/{repo}/info matches
// no route and landed here with "no such endpoint" and nothing
// else — the hint repoFor gives only fired for the %2F spelling,
// which does route (4bc7035f, G-1; jva #1068). Name the path
// that would have worked. Still a 404, still no confirmation
// that the bare name exists.
// Only when the path WITHOUT its first segment is a route this
// API has. The first version hinted on shape alone — three
// segments after /r/ — and told a caller of a non-existent
// /r/quarry/ci/run/<id>/cancel to "try /r/ci/run/<id>/cancel",
// reading the repository as an owner and pointing at nonsense
// (the other session, measuring 49323c33). The mux knows which
// paths are real; ask it.
if owner, rest, ok := ownerPrefixed(req.URL.Path); ok && routeExists(mux, req.Method, rest) {
writeError(w, http.StatusNotFound, fmt.Errorf(
"no such endpoint: the API names a repository by its bare name, without the owner %q; try %s",
owner, rest))
return
}
writeError(w, http.StatusNotFound, errors.New("no such endpoint"))
})
return mux, nil
}
func opHandler(r *ops.Registry, d *ops.Descriptor, verify Verify, capsFor CapsFor, canRead CanRead) 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) {
// Which refusal this is depends on whether the caller can
// see the repository being named.
//
// A repo-scoped refusal that says "you lack o on
// secret-repo" is a sentence about a repository, and it
// is only available to somebody the repository exists
// for. To everyone else the name must be as meaningless
// as any other unclaimed string — otherwise a stranger
// enumerates a fleet's private repositories by asking for
// each name and reading which ones are named back at
// them, and the owner with it.
//
// The answer is the SAME one an unknown name gets, not
// merely the same status: "no repository %q: not found"
// is what repoFor already returns for a repository the
// caller may not see, so the two are indistinguishable
// rather than merely similar.
//
// repoFor has said this since it was written — a
// repository you may not see reports NOT FOUND, never
// forbidden. It never got the chance here: the cap gate
// runs first and refused on the way past, so the op's own
// not-found was unreachable for exactly the ops that
// carry a Caps letter.
if d.Scope == ops.ScopeRepo && repo != "" && canRead != nil &&
!canRead(req.Context(), repo) {
writeError(w, http.StatusNotFound,
fmt.Errorf("no repository %q: %w", repo, ops.ErrNotFound))
return
}
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 writeOpError(w, req, d.Name, err) {
return
}
if f, ok := out.(ops.File); ok {
ServeFile(w, req, d.Name, f)
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)
}
if msg, ok := wrongType(err); ok {
return nil, fmt.Errorf("body: %s: %w", msg, 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
}
}
// Idempotency-Key is a header by convention (Stripe, the IETF
// draft); an op that takes a retry key declares the field, and one
// that does not never sees it.
if k := strings.TrimSpace(req.Header.Get("Idempotency-Key")); k != "" {
if err := ops.OverlayValues(url.Values{"idempotencyKey": {k}}, 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.
// wrongType turns the decoder's "cannot unmarshal string into Go
// struct field moderationSetIn.enabled of type bool" into the API's own
// sentence. The Go one names a struct and a Go type, neither of which
// the caller can see; the spec they read says the field is a boolean,
// so the refusal says that, in the words the spec uses (jva, #1085).
func wrongType(err error) (string, bool) {
var ute *json.UnmarshalTypeError
if !errors.As(err, &ute) || ute.Field == "" {
return "", false
}
want := "a " + ute.Type.String()
switch ute.Type.Kind() {
case reflect.Bool:
want = "a boolean"
case reflect.String:
want = "a string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
want = "a number"
case reflect.Slice, reflect.Array:
want = "a list"
case reflect.Struct, reflect.Map:
want = "an object"
case reflect.Pointer:
return wrongType(&json.UnmarshalTypeError{Value: ute.Value, Type: ute.Type.Elem(), Field: ute.Field})
}
got := ute.Value
switch got {
case "string", "number", "bool", "array", "object", "null":
got = map[string]string{"string": "a string", "number": "a number", "bool": "a boolean",
"array": "a list", "object": "an object", "null": "null"}[got]
}
return fmt.Sprintf("field %q must be %s, not %s", ute.Field, want, got), true
}
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"
}
// One subject decides both clauses: where the letters apply, and who
// can grant them.
//
// The grantor follows the SUBJECT, not the sentence.
//
// This said "which an admin of the repository grants" on every
// refusal, including ones with no repository at all — so
// GET /api/v1/mirrors, a fleet route, told a caller their missing
// letter was something a repository's admin could hand them, and
// named a repository in a sentence about the forge. jva measured it
// as "a fleet-level list refusing with a per-repo settings error",
// which is the honest reading: the letter was right and the person
// who could grant it was not.
//
// Letters resolve against the fleet when there is no repository, and
// the fleet's grantor is a site admin. Which is also the answer a
// caller can act on: asking a repository's admin for a letter they
// cannot grant is a dead end dressed as instructions.
where, grantor := "", "a site admin"
if repo != "" {
where, grantor = " on "+repo, "an admin of the repository"
}
// The action clause needs a subject too.
//
// Wanted() glosses a letter with what it lets you do ON A REPOSITORY
// — "change this repository's settings" — so on a fleet route the
// gloss names a repository the caller never asked about, and the
// refusal reads as a per-repo error on a fleet call. That was the
// half of jva's "a fleet-level list refusing with a per-repo
// settings error" that fixing the GRANTOR did not reach: the letter
// and the person who can grant it are the honest sentence when there
// is no subject for an action.
if repo != "" {
if s := oltp.Wanted(want); s != "" {
return fmt.Errorf("%s cannot %s%s: that needs capability %q, which %s grants",
who, s, where, want, grantor)
}
}
return fmt.Errorf("%s lacks capability %q%s, which %s grants", who, want, where, grantor)
}
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
}
// writeOpError maps an op's refusal to a status and body, and says
// whether it did. One place, because the JSON surface and a File's
// Open share it.
func writeOpError(w http.ResponseWriter, req *http.Request, name string, err error) bool {
d := struct{ Name string }{name}
_ = d
if errors.Is(err, ops.ErrNotFound) {
writeError(w, http.StatusNotFound, err)
return true
}
if errors.Is(err, ops.ErrInvalid) {
writeError(w, http.StatusBadRequest, err)
return true
}
if errors.Is(err, ops.ErrForbidden) {
writeError(w, http.StatusForbidden, err)
return true
}
if errors.Is(err, ops.ErrUnavailable) {
writeError(w, http.StatusServiceUnavailable, err)
return true
}
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 true
}
return err != nil
}
// ServeFile writes an ops.File as a download. Exported because the web
// route serves the same value the API does, through the same writer.
//
// ETag first: the bytes are named before they are produced, so a
// reader holding them gets a 304 and no build runs. Open is what does
// the work, and its error is an op error.
func ServeFile(w http.ResponseWriter, req *http.Request, name string, f ops.File) {
// The ETag names the bytes only when the file says they are
// immutable and addressable — for an archive, only when the version
// is a full check-in hash. A moving ref (trunk, tip, tag:X) used to
// get the same one-year `immutable` header and an ETag made of the
// ref's own name, so a cache could serve last month's trunk for a
// year and a revalidating one would get a wrong 304 (jva, #1086).
etag := ""
if tag := f.ETag(); tag != "" {
etag = `"` + tag + `"`
if req.Header.Get("If-None-Match") == etag {
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", cacheControl(req, f.Immutable()))
w.WriteHeader(http.StatusNotModified)
return
}
}
rc, size, err := f.Open()
if writeOpError(w, req, name, err) {
return // a refusal carries no cache headers: "no such branch" is not a fact to keep for a year
}
defer rc.Close()
if etag != "" {
w.Header().Set("ETag", etag)
}
// Immutable follows the URL, not the ETag: /archive/trunk names
// its bytes by resolved hash and still moves. The first fix keyed
// this on the ETag and left trunk cacheable for a year (jva, #1086
// re-measured).
w.Header().Set("Cache-Control", cacheControl(req, f.Immutable()))
w.Header().Set("Content-Type", f.ContentType())
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(f.Name()))
if size >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
}
io.Copy(w, rc)
}
// cacheControl says how long a file may be kept and by whom. Immutable
// bytes keep for a year; anything else revalidates every time. A
// response that needed a credential is `private`: `public` would let a
// shared cache store a members-only archive and hand it to the next
// person, which nothing does today and the header used to permit.
func cacheControl(req *http.Request, immutable bool) string {
scope := "public"
if req.Header.Get("Authorization") != "" || req.Header.Get("Cookie") != "" {
scope = "private"
}
if immutable {
return scope + ", max-age=31536000, immutable"
}
return scope + ", no-cache"
}
// ownerPrefixed recognises /api/v1/r/{owner}/{repo}/{op...} — a path with
// at least three segments after /r/, which no API route has — and
// returns the owner and the path with the owner removed.
func ownerPrefixed(path string) (owner, rest string, ok bool) {
const pre = "/api/v1/r/"
if !strings.HasPrefix(path, pre) {
return "", "", false
}
parts := strings.SplitN(strings.TrimPrefix(path, pre), "/", 3)
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return "", "", false
}
return parts[0], pre + parts[1] + "/" + parts[2], true
}
// routeExists asks the mux whether method+path would reach a real
// handler rather than the /api/ catch-all.
func routeExists(mux *http.ServeMux, method, path string) bool {
_, pattern := mux.Handler(&http.Request{Method: method, URL: &url.URL{Path: path}})
return pattern != "" && pattern != "/api/"
}