Skip to content
quarry Artifact [bb6e9c7838]
Login

Artifact [bb6e9c7838]

Artifact bb6e9c7838898b13097bb3459c6efbbbd50ac8ddd9b80186206484a0a60fbaa2:


package api

import (
	"context"
	"craftdesign.group/quarry/internal/outbound"
	"errors"
	"fmt"
	"strings"

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

// FossilUserStore keeps a repository's fossil user table as a reference
// list (oltp.ReplaceFossilUsers). nil disables the fossil user ops.
type FossilUserStore interface {
	ReplaceFossilUsers(repo string, users []oltp.FossilUser) (rows, categories int, err error)
	FossilUsers(repo string) ([]oltp.FossilUser, error)
	// LinkFossilUser judges the grant the link makes for the ACTOR
	// under the RFC 0008 guard and writes both under one lock.
	LinkFossilUser(actor, repo, login, account string) (warning string, err error)
}

type fossilUsersIn struct {
	Repo string `json:"repo" validate:"required"`
}

type fossilUserRow struct {
	Login   string   `json:"login"`
	Caps    string   `json:"caps"`
	Decoded []string `json:"decoded"`
	Info    string   `json:"info"`
	Linked  string   `json:"linked,omitempty"`
	Suggest bool     `json:"suggest"`
	// Pinned: the linked account holds Held, set by hand, and a sync
	// leaves it alone though upstream now says Caps.
	Held     string `json:"held,omitempty"`
	Pinned   bool   `json:"pinned,omitempty"`
	SyncedAt string `json:"syncedAt"`
}

type fossilUsersOut struct {
	// Upstream is the fossil server this repository records, which a
	// repository admin may sync from; empty when there is none.
	Upstream string          `json:"upstream,omitempty"`
	Users    []fossilUserRow `json:"users"`
}

type fossilUsersSyncIn struct {
	Repo string `json:"repo" validate:"required"`
	// URL defaults to the recorded upstream. Any other URL takes a site
	// administrator: the server makes the request, so a repository
	// admin must not be able to point it anywhere they like.
	URL      string `json:"url" validate:"max=2048"`
	Login    string `json:"login" validate:"required,max=128"`
	Password string `json:"password" validate:"required,max=1024"`
}

type fossilUsersSyncOut struct {
	Users      int `json:"users"`
	Categories int `json:"categories"`
	// Warning is the guard's remark on a link it allowed.
	Warning string `json:"warning,omitempty"`
}

type fossilUserLinkIn struct {
	Repo  string `json:"repo" validate:"required"`
	Login string `json:"login" validate:"required,max=128"`
	// Account empty unlinks.
	Account string `json:"account" validate:"max=64"`
}

// upstreamOf is the fossil server a repository records: a tracking
// mirror's URL, or the one a detached store came from.
func (d Deps) upstreamOf(repo string) string {
	m, detached := d.posture(repo)
	if m.Tracking {
		return m.URL
	}
	return detached
}

// errNoFossilUsers is every fossil user op on a forge that keeps no
// such list. The ops still register, as the mirror ops do, so the API
// reference describes one surface across every deployment.
var errNoFossilUsers = fmt.Errorf("this forge keeps no fossil user list: %w", ops.ErrInvalid)

func registerFossilUsers(r *ops.Registry, d Deps) error {
	fetch := d.FetchUsers
	if fetch == nil {
		fetch = func(peer, code, login, password string) ([]xfer.RemoteUser, error) {
			return xfer.FetchUsers(nil, peer, code, login, password)
		}
	}
	if err := ops.Register(r, ops.Op[fossilUsersIn, fossilUsersOut]{
		Name:  "repo.fossil.users",
		Caps:  "a",
		Doc:   "List this repository's synced fossil users: each login, its capabilities, and the quarry account an admin linked it to.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/fossil/users",
		Run: func(ctx context.Context, in fossilUsersIn) (fossilUsersOut, error) {
			if d.FossilUsers == nil {
				return fossilUsersOut{}, errNoFossilUsers
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return fossilUsersOut{}, err
			}
			users, err := d.FossilUsers.FossilUsers(in.Repo)
			if err != nil {
				return fossilUsersOut{}, err
			}
			out := fossilUsersOut{Upstream: d.upstreamOf(in.Repo), Users: []fossilUserRow{}}
			for _, u := range users {
				out.Users = append(out.Users, fossilUserRow{Login: u.Login, Caps: u.Caps,
					Decoded: oltp.Decode(u.Caps), Info: u.Info, Linked: u.Linked,
					Suggest: u.Suggest, SyncedAt: u.SyncedAt, Held: u.Held, Pinned: u.Pinned})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fossilUsersSyncIn, fossilUsersSyncOut]{
		Name:  "repo.fossil.users.sync",
		Caps:  "a",
		Doc:   "Fetch the user table from this repository's fossil server with an admin login there, apply its categories, and keep its users as a reference list. The login is used for this one request and not stored; no account is created and no password hash is kept.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/fossil/users/sync",
		Run: func(ctx context.Context, in fossilUsersSyncIn) (fossilUsersSyncOut, error) {
			if d.FossilUsers == nil {
				return fossilUsersSyncOut{}, errNoFossilUsers
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return fossilUsersSyncOut{}, err
			}
			upstream := d.upstreamOf(in.Repo)
			peer := strings.TrimSpace(in.URL)
			if peer == "" {
				peer = upstream
			}
			if peer == "" {
				return fossilUsersSyncOut{}, fmt.Errorf("this repository records no fossil server; a site administrator can give its URL: %w", ops.ErrInvalid)
			}
			if _, err := outbound.Check(peer); err != nil {
				return fossilUsersSyncOut{}, fmt.Errorf("the fossil server url %v: %w", err, ops.ErrInvalid)
			}
			// The account flag, not a letter here: a site admin holds s
			// at forge scope only (#318), and aiming the server's own
			// request is a forge decision.
			if peer != upstream && !d.isSiteAdmin(ops.User(ctx)) {
				return fossilUsersSyncOut{}, fmt.Errorf("only a site administrator may sync from a server other than this repository's own: %w", ops.ErrForbidden)
			}
			if d.ProjectCode == nil {
				return fossilUsersSyncOut{}, fmt.Errorf("this forge cannot sync fossil users: %w", ops.ErrInvalid)
			}
			code, err := d.ProjectCode(in.Repo)
			if err != nil {
				return fossilUsersSyncOut{}, err
			}
			remote, err := fetch(peer, code, in.Login, in.Password)
			if err != nil {
				// The peer's own words: "not authorized" is actionable.
				return fossilUsersSyncOut{}, fmt.Errorf("the fossil server refused: %v: %w", err, ops.ErrInvalid)
			}
			users := make([]oltp.FossilUser, 0, len(remote))
			for _, u := range remote {
				// PWHash is dropped here, deliberately.
				users = append(users, oltp.FossilUser{Login: u.Login, Caps: u.Caps, Info: u.Info})
			}
			rows, cats, err := d.FossilUsers.ReplaceFossilUsers(in.Repo, users)
			if err != nil {
				return fossilUsersSyncOut{}, err
			}
			return fossilUsersSyncOut{Users: rows, Categories: cats}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[fossilUserLinkIn, fossilUsersSyncOut]{
		Name:  "repo.fossil.users.link",
		Caps:  "a",
		Doc:   "Link a synced fossil login to a quarry account, which then holds that login's capabilities here; an empty account unlinks and leaves the account's capabilities as they are.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/fossil/users/link",
		Run: func(ctx context.Context, in fossilUserLinkIn) (fossilUsersSyncOut, error) {
			if d.FossilUsers == nil {
				return fossilUsersSyncOut{}, errNoFossilUsers
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return fossilUsersSyncOut{}, err
			}
			warn, err := d.FossilUsers.LinkFossilUser(author(ctx), in.Repo, in.Login, strings.TrimSpace(in.Account))
			if errors.Is(err, oltp.ErrNoFossilUser) || errors.Is(err, oltp.ErrNoAccount) {
				return fossilUsersSyncOut{}, fmt.Errorf("%v: %w", err, ops.ErrNotFound)
			}
			if err != nil {
				return fossilUsersSyncOut{}, refusalAsInvalid(err)
			}
			return fossilUsersSyncOut{Warning: warn}, nil
		},
	})
}