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"`
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})
}
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
},
})
}