Skip to content
quarry Artifact [d8ec01b41c]
Login

Artifact [d8ec01b41c]

Artifact d8ec01b41cce8cc877dd04be585a4d73a2e5581a9cddc8796231510c27afbadb:


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