package api
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// takedown.go is RFC 0023's operator acts: disable a repository on a
// notice, put one on legal hold, and end either. A site admin's, at the
// forge, since a site admin holds no letters on other people's
// repositories (#318). A site token may disable and restore, because a
// host complaint needs an answer in minutes; placing or lifting a hold
// is a judgment about evidence and stays with a person.
// TakedownStore is the takedown table (oltp/takedown.go).
type TakedownStore interface {
TakedownOf(repo string) (oltp.Takedown, bool, error)
SetTakedown(oltp.Takedown) error
EndTakedown(repo, strength, actor, via, reason string) error
TakedownLog(repo string, limit int) ([]oltp.TakedownAct, error)
Takedowns() (map[string]oltp.Takedown, error)
}
type takedownIn struct {
Repo string `json:"repo" validate:"required,max=200"`
Category string `json:"category" validate:"required,max=40"`
Notice string `json:"notice" validate:"max=200"`
Reason string `json:"reason" validate:"max=4000"`
}
type takedownEndIn struct {
Repo string `json:"repo" validate:"required,max=200"`
Reason string `json:"reason" validate:"max=4000"`
}
type takedownOut struct {
Repo string `json:"repo"`
Strength string `json:"strength,omitempty"`
}
type takedownLogIn struct {
Repo string `json:"repo" validate:"max=200"`
Limit int `json:"limit"`
}
type takedownActOut struct {
Repo string `json:"repo"`
Owner string `json:"owner"`
Act string `json:"act"`
Strength string `json:"strength"`
Category string `json:"category"`
Notice string `json:"notice"`
Reason string `json:"reason"`
Actor string `json:"actor"`
Via string `json:"via"`
At string `json:"at"`
}
type takedownCurOut struct {
Repo string `json:"repo"`
Strength string `json:"strength"`
Category string `json:"category"`
Notice string `json:"notice"`
At string `json:"at"`
}
type takedownLogOut struct {
Acts []takedownActOut `json:"acts"`
// Current is every takedown in force, for the fleet's rows.
Current []takedownCurOut `json:"current"`
}
// takedownReach is the line every takedown act holds before it writes:
// a token reaches only a repository it can read, so a private one it
// holds no grant on answers as a name nobody serves; and a forge
// agent's s stops at a deed a site admin or another forge agent holds.
func (d Deps) takedownReach(ctx context.Context, repo string) error {
if d.Takedowns == nil {
return fmt.Errorf("this deployment keeps no takedowns: %w", ops.ErrUnavailable)
}
if _, err := d.repo(repo); err != nil || !d.siteReaches(ctx, repo) {
return fmt.Errorf("no repository %q: %w", repo, ops.ErrNotFound)
}
if d.Owners != nil {
owner, err := d.Owners.OwnerOf(repo)
if err != nil {
return err
}
if owner != "" {
if err := d.refuseForgeAgentOn(ctx, owner); err != nil {
return err
}
}
}
return nil
}
// refuseHeldRepo is the router's takedown gate for an op that names its
// repository in the body rather than the path, so the router never sees
// it: moving a repository between teams, accepting an offer, granting an
// agent. Silent for a repository the caller cannot read, which the op's
// own not-found answers.
func (d Deps) refuseHeldRepo(ctx context.Context, repo string) error {
if d.Held == nil || repo == "" || (d.CanRead != nil && !d.CanRead(ctx, repo)) {
return nil
}
if why := d.Held(ctx, repo); why != "" {
return fmt.Errorf("%s: %w", why, ops.ErrForbidden)
}
return nil
}
func refuseTokenHold(ctx context.Context) error {
if _, byToken := ops.TokenOf(ctx); byToken {
return fmt.Errorf("a legal hold is a judgment about evidence and reporting; placing or lifting one needs a person signed in, not a token: %w", ops.ErrForbidden)
}
return nil
}
func takedownErr(err error) error {
if errors.Is(err, oltp.ErrTakedownState) {
return fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
return err
}
func (d Deps) takedownChanged() {
if d.TakedownsChanged != nil {
d.TakedownsChanged()
}
}
func (d Deps) registerTakedowns(r *ops.Registry) error {
set := func(name, strength, route, doc string) error {
return ops.Register(r, ops.Op[takedownIn, takedownOut]{
Name: name, Caps: "s", Scope: ops.ScopeFleet, Kind: ops.Command, Auth: ops.Admin,
Route: route, Doc: doc,
Run: func(ctx context.Context, in takedownIn) (takedownOut, error) {
if strength == oltp.LegalHold {
if err := refuseTokenHold(ctx); err != nil {
return takedownOut{}, err
}
}
if err := d.takedownReach(ctx, in.Repo); err != nil {
return takedownOut{}, err
}
if err := d.Takedowns.SetTakedown(oltp.Takedown{Repo: in.Repo, Strength: strength,
Category: in.Category, Notice: in.Notice, Reason: in.Reason,
Actor: ops.User(ctx), Via: ops.Via(ctx)}); err != nil {
return takedownOut{}, takedownErr(err)
}
d.takedownChanged()
return takedownOut{Repo: in.Repo, Strength: strength}, nil
},
})
}
end := func(name, strength, route, doc string) error {
return ops.Register(r, ops.Op[takedownEndIn, takedownOut]{
Name: name, Caps: "s", Scope: ops.ScopeFleet, Kind: ops.Command, Auth: ops.Admin,
Route: route, Doc: doc,
Run: func(ctx context.Context, in takedownEndIn) (takedownOut, error) {
if strength == oltp.LegalHold {
if err := refuseTokenHold(ctx); err != nil {
return takedownOut{}, err
}
}
if err := d.takedownReach(ctx, in.Repo); err != nil {
return takedownOut{}, err
}
if err := d.Takedowns.EndTakedown(in.Repo, strength, ops.User(ctx), ops.Via(ctx), in.Reason); err != nil {
return takedownOut{}, takedownErr(err)
}
d.takedownChanged()
return takedownOut{Repo: in.Repo}, nil
},
})
}
if err := set("repo.disable", oltp.Disabled, "POST /api/v1/repos/disable",
"Disable a repository on a notice (RFC 0023): every surface answers 451 to everybody but its own admins, "+
"who keep access to remove what was reported. The store and settings are kept; delete and stop serving "+
"are refused until it is restored. `category` is one of copyright, host, unlawful, terms, terrorism, "+
"child-abuse, preservation, law-enforcement."); err != nil {
return err
}
if err := set("repo.hold", oltp.LegalHold, "POST /api/v1/repos/hold",
"Put a repository on legal hold (RFC 0023): 451 to everybody, its owner included, and no writes, so what "+
"is held stays as it was. May follow a disable. A person's act; a token is refused."); err != nil {
return err
}
if err := end("repo.restore", oltp.Disabled, "POST /api/v1/repos/restore",
"Restore a disabled repository: every surface answers as before. Does not lift a legal hold."); err != nil {
return err
}
if err := end("repo.lift", oltp.LegalHold, "POST /api/v1/repos/lift",
"Lift a legal hold. A person's act; a token is refused."); err != nil {
return err
}
return ops.Register(r, ops.Op[takedownLogIn, takedownLogOut]{
Name: "repo.takedowns", Caps: "s", Scope: ops.ScopeFleet, Kind: ops.Query, Auth: ops.Admin,
Route: "GET /api/v1/fleet/takedowns",
Doc: "The takedowns in force, and the takedown log newest first: every disable, hold, restore and lift, for one repository or the whole forge.",
Run: func(ctx context.Context, in takedownLogIn) (takedownLogOut, error) {
if d.Takedowns == nil {
return takedownLogOut{}, fmt.Errorf("this deployment keeps no takedowns: %w", ops.ErrUnavailable)
}
limit := in.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
acts, err := d.Takedowns.TakedownLog(in.Repo, limit)
if err != nil {
return takedownLogOut{}, err
}
cur, err := d.Takedowns.Takedowns()
if err != nil {
return takedownLogOut{}, err
}
out := takedownLogOut{Acts: []takedownActOut{}, Current: []takedownCurOut{}}
for _, t := range cur {
if (in.Repo == "" || t.Repo == in.Repo) && d.siteReaches(ctx, t.Repo) {
out.Current = append(out.Current, takedownCurOut{Repo: t.Repo, Strength: t.Strength,
Category: t.Category, Notice: t.Notice, At: t.At})
}
}
slices.SortFunc(out.Current, func(a, b takedownCurOut) int { return strings.Compare(a.Repo, b.Repo) })
for _, a := range acts {
// A token sees only the rows of repositories it can read,
// the private line every other site-token read holds.
if !d.siteReaches(ctx, a.Repo) {
continue
}
out.Acts = append(out.Acts, takedownActOut{Repo: a.Repo, Owner: a.Owner, Act: a.Act,
Strength: a.Strength, Category: a.Category, Notice: a.Notice, Reason: a.Reason,
Actor: a.Actor, Via: a.Via, At: a.At})
}
return out, nil
},
})
}