Skip to content
quarry Artifact [730a973fb9]
Login

Artifact [730a973fb9]

Artifact 730a973fb991fd43ff4163b641447afd25409daa8d3489d47dbe4836bf2779e3:


package api

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net"
	"net/http"
	"sort"
	"strings"
	"sync"
	"time"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// RFC 0030, Starting without a credential. A client that holds no
// credential cannot ask its person for access, so it starts the same
// request device-style: it names an agent and a kind, binds a pickup
// code, and is given a short code to show. Nothing is filed, told or
// listed until a signed-in person types that code at /setup, picks the
// repositories themselves and continues; the request then waits on that
// person's page like any other. The client names no repository, no
// account and no reason: what it may say reaches no page but its agent
// name, held to the account-name alphabet.

const (
	// setupStartMax is how many unclaimed starts the forge holds at once.
	setupStartMax = 200
	// setupStartPerAddress is how many starts one address may make in a
	// minute.
	setupStartPerAddress = 5
	// setupStartPerShare is how many unclaimed starts one address (an
	// IPv6 /64 counts as one) may hold at once, so a few addresses cannot
	// keep every slot full.
	setupStartPerShare = 3
	// A code that is not held spends the account's and the address's
	// budget of misses; nothing else does.
	setupMissWindow    = 10 * time.Minute
	setupMissesPerUser = 8
	setupMissesPerAddr = 20

	// clientAskerPrefix begins the asker of a claimed start. An account
	// name never holds a space, so no account can be this asker, and no
	// agent is: the request says truthfully that a client asked.
	clientAskerPrefix = "client of "
	// setupClaimReason is the reason a claimed request carries. The forge
	// writes it; the client's words never reach the page.
	setupClaimReason = "A client with no credential asked for this, and you claimed its code."
)

// clientAsker is the asker of a start person claimed.
func clientAsker(person string) string { return clientAskerPrefix + person }

// isClientAsker reports whether agent is the asker of a claimed start.
func isClientAsker(agent string) bool { return strings.HasPrefix(agent, clientAskerPrefix) }

// SetupStartStore holds unclaimed starts. *oltp.DB is one.
type SetupStartStore interface {
	StartSetup(s oltp.SetupStart, max, perAddr int, now time.Time) (string, error)
	SetupStartLoad(addr string, now time.Time) (total, mine int, err error)
	SetupStartByCode(typed string, now time.Time) (oltp.SetupStart, bool, error)
	ClaimSetupStart(typed string, now time.Time) (oltp.SetupStart, bool, error)
	RestoreSetupStart(typed string, s oltp.SetupStart) error
}

// formatSetupCode shows a code as XXXX-XXXX.
func formatSetupCode(code string) string {
	if len(code) != 8 {
		return code
	}
	return code[:4] + "-" + code[4:]
}

type setupStartIn struct {
	Name   string `json:"name"`
	Kind   string `json:"kind"`
	Pickup string `json:"pickup"`
}

type setupStartOut struct {
	Code    string `json:"code"`
	Link    string `json:"link"`
	Expires string `json:"expires"`
}

// SetupStarter is the route a client with no credential starts a setup
// at: POST /api/v1/setup/start. Not an op: it is anonymous, limited by
// address and answers 429 with a Retry-After.
type SetupStarter struct {
	d     Deps
	addr  func(*http.Request) string
	now   func() time.Time
	rates pickupRates
}

// NewSetupStarter wires the route; addr is the forge's rule for telling
// clients apart, nil meaning the socket's host.
func NewSetupStarter(d Deps, addr func(*http.Request) string) *SetupStarter {
	return &SetupStarter{d: d, addr: addr, now: time.Now}
}

func (s *SetupStarter) client(req *http.Request) string {
	if s.addr != nil {
		return s.addr(req)
	}
	if h, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
		return h
	}
	return req.RemoteAddr
}

func (s *SetupStarter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	if req.URL.RawQuery != "" {
		pickupJSON(w, http.StatusBadRequest, map[string]string{"error": "this route takes no query string"})
		return
	}
	if s.d.SetupStarts == nil || s.d.SignOffs == nil {
		pickupJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "this forge takes no setup starts"})
		return
	}
	now := s.now()
	ip := s.client(req)
	// An address is its IPv4 or its IPv6 /64: one subscriber holds a whole
	// /64, so counting single addresses there would be no limit.
	ip = oltp.AddrGroup(ip)
	// Every call spends its address's budget, well formed or not.
	if ok, wait := s.rates.allow("s:"+ip, setupStartPerAddress, now); !ok {
		pickupBusy(w, wait)
		return
	}
	var in setupStartIn
	dec := json.NewDecoder(http.MaxBytesReader(w, req.Body, 2048))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&in); err != nil {
		pickupJSON(w, http.StatusBadRequest, map[string]string{"error": "send a JSON object with name, kind and pickup, and nothing else: the person names the repositories"})
		return
	}
	in.Kind = strings.ToLower(strings.TrimSpace(in.Kind))
	if len(in.Name) > 64 || !oltp.ValidAccountName(in.Name) {
		pickupJSON(w, http.StatusBadRequest, map[string]string{"error": "name is letters, digits, '.', '-' or '_', starting with a letter or digit, at most 64 characters"})
		return
	}
	if in.Kind != oltp.SetupPush && in.Kind != oltp.SetupRead {
		pickupJSON(w, http.StatusBadRequest, map[string]string{"error": "kind is push or read"})
		return
	}
	if !validPickupCode(in.Pickup) {
		pickupJSON(w, http.StatusBadRequest, map[string]string{"error": "a pickup code is the lowercase hex SHA-256 of the 32-byte pickup key"})
		return
	}
	// A start that cannot be held is refused on a read, before any write
	// lock is taken.
	if total, mine, err := s.d.SetupStarts.SetupStartLoad(ip, now); err == nil && (total >= setupStartMax || mine >= setupStartPerShare) {
		w.Header().Set("Retry-After", "60")
		pickupJSON(w, http.StatusTooManyRequests, map[string]string{"error": "no room for another start from here; try again in a minute"})
		return
	}
	// Whether the name is taken is not looked at: a start for a name that
	// is an account answers as any other, and the person who claims it is
	// told.
	code, err := s.d.SetupStarts.StartSetup(oltp.SetupStart{Pickup: in.Pickup, Name: in.Name, Kind: in.Kind, Addr: ip}, setupStartMax, setupStartPerShare, now)
	if errors.Is(err, oltp.ErrSetupStartsFull) || errors.Is(err, oltp.ErrSetupAddrFull) {
		w.Header().Set("Retry-After", "60")
		pickupJSON(w, http.StatusTooManyRequests, map[string]string{"error": "this forge holds as many unclaimed starts as it may; try again in a minute"})
		return
	}
	if err != nil {
		pickupJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	pickupJSON(w, http.StatusOK, setupStartOut{Code: formatSetupCode(code), Link: s.d.link("/setup"),
		Expires: now.UTC().Add(oltp.SetupStartLife).Format(time.RFC3339)})
}

type setupClaimLookIn struct {
	Code string `json:"code" validate:"max=64"`
}

type setupClaimLookOut struct {
	// Code is the code as the client shows it, XXXX-XXXX.
	Code string `json:"code"`
	Name string `json:"name"`
	Kind string `json:"kind"`
	// Address is where the start came from, Created when.
	Address string `json:"address"`
	Created string `json:"created"`
	// Repos are the repositories this person may set the agent up on,
	// Roles the push roles to choose from.
	Repos []setupRepoChoice `json:"repos"`
	Roles []string          `json:"roles"`
	// Problem says, in a sentence, why the claim cannot go on.
	Problem string `json:"problem,omitempty"`
}

// setupRepoChoice is one repository a person may pick: its key, what a
// person calls it, whether it is private or public, and whose it is.
type setupRepoChoice struct {
	Name       string `json:"name"`
	Handle     string `json:"handle"`
	Visibility string `json:"visibility"`
	Namespace  string `json:"namespace"`
}

type setupClaimIn struct {
	Code string `json:"code" validate:"required,max=64"`
	// Repos is repository names, comma-separated, from the list the
	// code's page offered: one for push, one to twenty for read.
	Repos string `json:"repos" validate:"required,max=2000"`
	// Role is for push only; developer when empty.
	Role string `json:"role" validate:"max=20"`
}

type setupClaimOut struct {
	// Request is the id of the request now waiting on this person.
	Request string `json:"request"`
}

type setupDiscardOut struct{}

// noSuchSetupCode is the one answer for a code that is not held: unknown,
// lapsed, malformed, already claimed or discarded.
func noSuchSetupCode() error { return fmt.Errorf("no such code: %w", ops.ErrNotFound) }

// setupChoices is what the claiming person may do with a start: the
// repositories agent.setup would accept from them for it (the rule the
// request's own preflight applies to its approver), or, in problem, why
// nothing can be done.
func (d Deps) setupChoices(who string, st oltp.SetupStart) (repos []setupRepoChoice, problem string) {
	if err := d.setupNameProblem(st.Name); err != nil {
		return nil, strings.TrimRight(ops.Sentence(err), ".") + ". The client must start again under another name."
	}
	octx := ops.WithUser(context.Background(), who)
	for _, name := range d.Repos.Names() {
		if d.companionParent(name) != "" {
			continue
		}
		if d.CanRead != nil && !d.CanRead(octx, name) {
			continue
		}
		if _, err := d.planSetupAs(who, agentSetupIn{Name: st.Name, Kind: st.Kind, Repos: name}, false); err == nil {
			h, ns, _ := d.repoIdentity(name)
			c := setupRepoChoice{Name: name, Handle: h, Namespace: ns}
			if d.Visibility != nil {
				c.Visibility = d.Visibility(name)
			}
			repos = append(repos, c)
		}
	}
	sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
	if len(repos) == 0 {
		problem = "There is no repository you may set this agent up on; the client can start again once you hold one."
	}
	return repos, problem
}

// claimant is the person a claim call is made by: signed in with the
// account itself, never a token, and not an agent.
func (d Deps) claimant(ctx context.Context, op string) (string, error) {
	if err := refuseToken(ctx, op); err != nil {
		return "", err
	}
	who := ops.User(ctx)
	if who == "" || d.operatorOf(who) != "" {
		return "", fmt.Errorf("%s is for a person signed in: %w", op, ops.ErrForbidden)
	}
	if d.SetupStarts == nil || d.SignOffs == nil {
		return "", fmt.Errorf("this forge takes no setup starts: %w", ops.ErrUnavailable)
	}
	return who, nil
}

// setupMisses is a sliding window of misses per key, in memory.
type setupMisses struct {
	mu   sync.Mutex
	hits map[string][]time.Time
}

// wait is how long key must wait to try again, zero when it may.
func (m *setupMisses) wait(key string, limit int, now time.Time) time.Duration {
	m.mu.Lock()
	defer m.mu.Unlock()
	cut := now.Add(-setupMissWindow)
	kept := m.hits[key][:0]
	for _, t := range m.hits[key] {
		if t.After(cut) {
			kept = append(kept, t)
		}
	}
	if len(kept) == 0 {
		delete(m.hits, key)
		return 0
	}
	m.hits[key] = kept
	if len(kept) >= limit {
		return kept[0].Add(setupMissWindow).Sub(now)
	}
	return 0
}

func (m *setupMisses) miss(key string, now time.Time) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.hits == nil {
		m.hits = map[string][]time.Time{}
	}
	if len(m.hits) > 4096 {
		cut := now.Add(-setupMissWindow)
		for k, v := range m.hits {
			if len(v) == 0 || !v[len(v)-1].After(cut) {
				delete(m.hits, k)
			}
		}
	}
	m.hits[key] = append(m.hits[key], now)
}

func registerSetupClaim(r *ops.Registry, d Deps) error {
	var misses setupMisses
	// spend refuses a caller whose account or address has missed too often
	// with a code that is not held: the same limit on every door.
	spend := func(ctx context.Context, who string) error {
		now := time.Now()
		wait := misses.wait("u:"+who, setupMissesPerUser, now)
		if a := ops.AddrOf(ctx); a != "" {
			if w := misses.wait("a:"+oltp.AddrGroup(a), setupMissesPerAddr, now); w > wait {
				wait = w
			}
		}
		if wait > 0 {
			return fmt.Errorf("too many codes were tried that are not held; wait a few minutes and try again: %w", ops.ErrRateLimited)
		}
		return nil
	}
	// noCode records the miss and answers the one not-found.
	noCode := func(ctx context.Context, who string) error {
		now := time.Now()
		misses.miss("u:"+who, now)
		if a := ops.AddrOf(ctx); a != "" {
			misses.miss("a:"+oltp.AddrGroup(a), now)
		}
		return noSuchSetupCode()
	}
	if err := ops.Register(r, ops.Op[setupClaimLookIn, setupClaimLookOut]{
		Name: "setup.start.look",
		Doc: "What a client's start asked for, by the short code its client shows (RFC 0030, Starting without a credential): the agent name and kind, where the start came from and when, " +
			"and the repositories this person may set the agent up on. For a person signed in, never a token. A code that is unknown, lapsed, claimed, discarded or malformed is the one answer, not found.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/setup/look",
		Run: func(ctx context.Context, in setupClaimLookIn) (setupClaimLookOut, error) {
			who, err := d.claimant(ctx, "setup.start.look")
			if err != nil {
				return setupClaimLookOut{}, err
			}
			if err := spend(ctx, who); err != nil {
				return setupClaimLookOut{}, err
			}
			st, ok, err := d.SetupStarts.SetupStartByCode(in.Code, time.Now())
			if err != nil {
				return setupClaimLookOut{}, err
			}
			if !ok {
				return setupClaimLookOut{}, noCode(ctx, who)
			}
			norm, _ := oltp.NormalizeSetupCode(in.Code)
			out := setupClaimLookOut{Code: formatSetupCode(norm), Name: st.Name, Kind: st.Kind, Address: st.Addr, Created: st.Created, Roles: []string{}}
			out.Repos, out.Problem = d.setupChoices(who, st)
			if out.Repos == nil {
				out.Repos = []setupRepoChoice{}
			}
			if st.Kind == oltp.SetupPush {
				out.Roles = []string{"developer", "publisher", "maintainer"}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[setupClaimIn, setupClaimOut]{
		Name: "setup.start.claim",
		Doc: "Claim a client's start with its short code, and file the request it asked for as an ordinary agent.setup request waiting on this person (RFC 0030, Starting without a credential). " +
			"The person picks the repositories, from those they may set an agent up on: exactly one for a push start, one to twenty for a read start, and for push a role (developer, publisher or maintainer). " +
			"Every check agent.setup's own asking runs, runs as this person, with the same refusals. The request names the client as its asker and carries the start's pickup code and a reason the forge writes; " +
			"nothing the client sent but the agent name reaches the page. One claim wins; the start is gone after it. For a person signed in, never a token. Nothing is minted until the person approves the request.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/setup/claim",
		Run: func(ctx context.Context, in setupClaimIn) (setupClaimOut, error) {
			who, err := d.claimant(ctx, "setup.start.claim")
			if err != nil {
				return setupClaimOut{}, err
			}
			if err := spend(ctx, who); err != nil {
				return setupClaimOut{}, err
			}
			now := time.Now()
			st, ok, err := d.SetupStarts.SetupStartByCode(in.Code, now)
			if err != nil {
				return setupClaimOut{}, err
			}
			if !ok {
				return setupClaimOut{}, noCode(ctx, who)
			}
			si := agentSetupIn{Name: st.Name, Kind: st.Kind, Repos: in.Repos, Role: in.Role}
			_, repos, _, err := setupShape(si)
			if err != nil {
				return setupClaimOut{}, err
			}
			choices, problem := d.setupChoices(who, st)
			if problem != "" {
				return setupClaimOut{}, fmt.Errorf("%s: %w", problem, ops.ErrInvalid)
			}
			offered := map[string]bool{}
			for _, c := range choices {
				offered[c.Name] = true
			}
			for _, repo := range repos {
				if !offered[repo] {
					return setupClaimOut{}, fmt.Errorf("%s is not a repository you may set this agent up on: %w", repo, ops.ErrInvalid)
				}
			}
			// The ask-time checks, as this person: the same plan agent.setup
			// resolves, and the letters it stores in the request.
			plan, err := d.planSetup(who, si)
			if err != nil {
				return setupClaimOut{}, err
			}
			si.BelongsTo, si.Ceiling = plan.Belongs, plan.Ceiling
			if plan.Kind == oltp.SetupPush {
				si.Role = plan.Role
			}
			if desc, ok := r.Lookup(setupOp); !ok {
				return setupClaimOut{}, fmt.Errorf("no operation %q: %w", setupOp, ops.ErrInvalid)
			} else if err := pickupAllowedFor(desc); err != nil {
				return setupClaimOut{}, err
			}
			if err := checkPickupCode(st.Pickup); err != nil {
				return setupClaimOut{}, err
			}
			raw, err := json.Marshal(si)
			if err != nil {
				return setupClaimOut{}, err
			}
			// Taken for good before anything is filed: of two claims at once
			// one gets the row and files, the other finds nothing.
			held, ok, err := d.SetupStarts.ClaimSetupStart(in.Code, now)
			if err != nil {
				return setupClaimOut{}, err
			}
			if !ok {
				return setupClaimOut{}, noCode(ctx, who)
			}
			s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: clientAsker(who), Owner: who, Op: setupOp,
				Input: string(raw), Summary: setupSummary(si), Reason: setupClaimReason, Pickup: held.Pickup})
			if err != nil {
				// Nothing was filed, so the start goes back as it was.
				if rerr := d.SetupStarts.RestoreSetupStart(in.Code, held); rerr != nil {
					log.Printf("setup claim by %s: filing failed (%v) and the start could not be put back: %v", who, err, rerr)
					return setupClaimOut{}, fmt.Errorf("the request could not be filed and the start was lost, so the client must start again: %w", ops.ErrUnavailable)
				}
				if errors.Is(err, ErrPickupBound) || errors.Is(err, ErrPickupMismatch) {
					return setupClaimOut{}, pickupAskError(err)
				}
				if errors.Is(err, ErrTooManySignOffs) {
					return setupClaimOut{}, fmt.Errorf("you already have as many requests waiting from clients as you may; answer one first: %w", ops.ErrInvalid)
				}
				return setupClaimOut{}, err
			}
			return setupClaimOut{Request: s.ID}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[setupClaimLookIn, setupDiscardOut]{
		Name: "setup.start.discard",
		Doc: "Throw away a client's start by its short code (RFC 0030, Starting without a credential): the client's code stops working and it must start again. " +
			"For a person signed in, never a token. A code that is not held is not found.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/setup/discard",
		Run: func(ctx context.Context, in setupClaimLookIn) (setupDiscardOut, error) {
			who, err := d.claimant(ctx, "setup.start.discard")
			if err != nil {
				return setupDiscardOut{}, err
			}
			if err := spend(ctx, who); err != nil {
				return setupDiscardOut{}, err
			}
			if _, ok, err := d.SetupStarts.ClaimSetupStart(in.Code, time.Now()); err != nil {
				return setupDiscardOut{}, err
			} else if !ok {
				return setupDiscardOut{}, noCode(ctx, who)
			}
			return setupDiscardOut{}, nil
		},
	})
}