package api
import (
"context"
"fmt"
"strings"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/ops"
)
// PurgeGrantStore records which curators may purge in a repository.
// A person who administers the repository needs no grant.
type PurgeGrantStore interface {
GrantPurge(repo, account, by string) error
RevokePurge(repo, account string) error
HasPurgeGrant(repo, account string) bool
}
type purgeIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Name string `json:"name" validate:"required,max=100"`
Rev string `json:"rev" validate:"required,min=4,max=64"`
Reason string `json:"reason" validate:"max=500"`
}
type purgeOut struct {
Hash string `json:"hash"`
// Shun is zit's own account of the removal, one "ACTION HASH" line
// per artifact (reclaimed, deferred, recorded, gone).
Shun []string `json:"shun"`
}
// mayPurge answers who may purge in a repository: a person who holds its
// admin letter, or its curator (an agent run by a site admin) once a
// repository admin has granted it. No other agent purges, a forge agent
// included: removal is permanent and is a person's decision, given to a
// machine only on request.
func (d Deps) mayPurge(ctx context.Context, repo string) bool {
who := author(ctx)
if who == "" || !d.holds(ctx, repo, "a") {
return false
}
a, ok := d.accountNamed(who)
if !ok {
return false
}
if a.Operator == "" {
return true
}
return d.isCurator(a) && d.PurgeGrants != nil && d.PurgeGrants.HasPurgeGrant(repo, who)
}
// isCurator is an agent whose operator is a site admin: a repository's
// curator (RFC 0022). A forge agent is not one.
func (d Deps) isCurator(a Account) bool {
if a.Operator == "" || a.Operator == forgeOperator {
return false
}
op, ok := d.accountNamed(a.Operator)
return ok && op.Admin
}
// purge shuns one redacted version. Redaction first, always: the second
// step of a two-step removal, so the current version of a page can never
// be purged and nothing is removed that a moderator has not already
// withheld behind a reason.
func (d Deps) purge(ctx context.Context, repo *Repo, in purgeIn, hist []docVersion, target string) (purgeOut, error) {
if !d.mayPurge(ctx, in.Repo) {
return purgeOut{}, fmt.Errorf("purging needs a person who administers this repository, or its curator with a purge grant: %w", ops.ErrForbidden)
}
if d.Redactions == nil {
return purgeOut{}, fmt.Errorf("redaction is not configured on this forge: %w", ops.ErrUnavailable)
}
if strings.TrimSpace(in.Reason) == "" {
return purgeOut{}, fmt.Errorf("purging needs a reason; it is kept in the moderation log and the store's shun list: %w", ops.ErrInvalid)
}
var v docVersion
for _, h := range hist {
if strings.HasPrefix(h.hash, in.Rev) {
if v.hash != "" {
return purgeOut{}, fmt.Errorf("version %q is ambiguous; give more of its hash: %w", in.Rev, ops.ErrInvalid)
}
v = h
}
}
if v.hash == "" {
return purgeOut{}, fmt.Errorf("no version %q in this history: %w", in.Rev, ops.ErrNotFound)
}
if _, ok := d.redactedIn(in.Repo, []string{v.hash})[v.hash]; !ok {
return purgeOut{}, fmt.Errorf("only a redacted version can be purged; redact it first: %w", ops.ErrInvalid)
}
var shun []string
if err := repo.write(func(tok lease.Token) (err error) {
shun, err = repo.Zit.Shun(tok, in.Reason, v.hash)
return err
}); err != nil {
return purgeOut{}, err
}
// The rewrite is cursor loss to the ingest, which rebuilds the index
// without the artifact; synced waits for that, so the history read
// next has no row for it.
if err := repo.synced(); err != nil {
return purgeOut{}, err
}
if err := d.Redactions.Unredact(in.Repo, v.hash); err != nil {
return purgeOut{}, err
}
if d.Forum != nil {
d.Forum.AppendModLog(in.Repo, author(ctx), "purge", target+"@"+v.hash[:min(12, len(v.hash))], in.Reason, v.user)
}
return purgeOut{Hash: v.hash, Shun: shun}, nil
}
type purgeGrantIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Account string `json:"account" validate:"required,max=64"`
Revoke bool `json:"revoke"`
}
type purgeGrantOut struct {
Account string `json:"account"`
Granted bool `json:"granted"`
}
func registerPurgeGrant(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[purgeGrantIn, purgeGrantOut]{
Name: "purge.grant",
Caps: "a",
Doc: "Let this repository's curator (an agent run by a site admin) purge redacted versions, or " +
"`revoke` it. Given by a person who administers the repository; an agent cannot grant it, and no " +
"other agent can receive it.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/purge-grant",
Run: func(ctx context.Context, in purgeGrantIn) (purgeGrantOut, error) {
if d.PurgeGrants == nil {
return purgeGrantOut{}, fmt.Errorf("purge grants are not configured on this forge: %w", ops.ErrUnavailable)
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return purgeGrantOut{}, err
}
me, ok := d.accountNamed(author(ctx))
if !ok || me.Operator != "" {
return purgeGrantOut{}, fmt.Errorf("only a person may grant purge: %w", ops.ErrForbidden)
}
if in.Revoke {
if err := d.PurgeGrants.RevokePurge(in.Repo, in.Account); err != nil {
return purgeGrantOut{}, err
}
if d.Forum != nil {
d.Forum.AppendModLog(in.Repo, me.Name, "purge-revoke", in.Account, "", in.Account)
}
return purgeGrantOut{Account: in.Account}, nil
}
a, ok := d.accountNamed(in.Account)
if !ok {
return purgeGrantOut{}, fmt.Errorf("no account %q: %w", in.Account, ops.ErrNotFound)
}
if !d.isCurator(a) {
return purgeGrantOut{}, fmt.Errorf("%s is not a curator (an agent run by a site admin); only a curator may be granted purge: %w", in.Account, ops.ErrInvalid)
}
if err := d.PurgeGrants.GrantPurge(in.Repo, in.Account, me.Name); err != nil {
return purgeGrantOut{}, err
}
if d.Forum != nil {
d.Forum.AppendModLog(in.Repo, me.Name, "purge-grant", in.Account, "", in.Account)
}
return purgeGrantOut{Account: in.Account, Granted: true}, nil
},
})
}