package api
import (
"context"
"fmt"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// RedactionStore withholds the text of one version of a wiki page or
// technote. Display state, like a hidden forum post: the artifact is
// untouched and every clone still carries it.
type RedactionStore interface {
Redact(repo, artifact, reason, actor string) error
Unredact(repo, artifact string) error
Redacted(repo string) (map[string]string, error)
}
type redactIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Name is the wiki page's name, or the technote's event id (prefix).
Name string `json:"name" validate:"required,max=100"`
Rev string `json:"rev" validate:"required,min=4,max=64"` // the version's hash prefix
Reason string `json:"reason" validate:"max=500"`
Undo bool `json:"undo"`
}
type redactOut struct {
Hash string `json:"hash"`
Redacted bool `json:"redacted"`
}
// docVersion is one entry of a document's history, whichever kind.
type docVersion struct{ hash, user string }
// docKind is one kind of document with a version history: how to read
// that history, and how the log, the notice and the link name it.
type docKind struct {
kind, noun string
read func(repo *Repo, name string) (hist []docVersion, target, history string, err error)
}
var docKinds = []docKind{
{kind: "wiki", noun: "wiki page", read: func(repo *Repo, name string) ([]docVersion, string, string, error) {
hist, err := repo.Index.WikiHistory(name)
if err != nil {
return nil, "", "", err
}
vs := make([]docVersion, len(hist))
for i, h := range hist {
vs[i] = docVersion{h.Hash, h.User}
}
return vs, "wiki:" + name, "/wiki/" + name + "/history", nil
}},
{kind: "technote", noun: "technote", read: func(repo *Repo, name string) ([]docVersion, string, string, error) {
n, ok, err := repo.Index.TechnoteByEvent(name)
if err != nil {
return nil, "", "", err
}
if !ok {
return nil, "", "", fmt.Errorf("no technote %q: %w", name, ops.ErrNotFound)
}
hist, err := repo.Index.TechnoteHistory(n.Event)
if err != nil {
return nil, "", "", err
}
vs := make([]docVersion, len(hist))
for i, h := range hist {
vs[i] = docVersion{h.Hash, h.User}
}
ev := n.Event[:min(16, len(n.Event))]
return vs, "technote:" + ev[:min(12, len(ev))], "/technote/" + ev + "/history", nil
}},
}
func registerRedactions(r *ops.Registry, d Deps) error {
for _, k := range docKinds {
if err := ops.Register(r, ops.Op[redactIn, redactOut]{
Name: k.kind + ".redact",
Caps: "5",
Doc: "Withhold the text of one past version of a " + k.noun + " from everyone but moderators, behind a " +
"public reason; `undo` restores it. The history keeps the version's row. The current version " +
"cannot be redacted: revert first. Display state only — every clone still has the text.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/" + k.kind + "/{name}/redact",
Run: func(ctx context.Context, in redactIn) (redactOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return redactOut{}, err
}
vs, target, history, err := k.read(repo, in.Name)
if err != nil {
return redactOut{}, err
}
return d.redact(ctx, in, vs, target, "your edit of a "+k.noun, "/r/"+in.Repo+history)
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[purgeIn, purgeOut]{
Name: k.kind + ".purge",
// The gate is mayPurge's, not a letter: a person who is the
// repository's admin, or its curator holding a purge grant.
Caps: "a",
Doc: "Permanently remove one redacted version of a " + k.noun + " from this forge: the artifact is " +
"shunned, so no sync serves it or brings it back, and its row leaves the history. Irreversible. " +
"Only a redacted version can be purged. Clones and mirrors made before keep their copy.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/" + k.kind + "/{name}/purge",
Run: func(ctx context.Context, in purgeIn) (purgeOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return purgeOut{}, err
}
vs, target, _, err := k.read(repo, in.Name)
if err != nil {
return purgeOut{}, err
}
return d.purge(ctx, repo, in, vs, target)
},
}); err != nil {
return err
}
}
return registerPurgeGrant(r, d)
}
// redact is both ops once the history is read: find the version, refuse
// the current one, record or clear the redaction, log it, tell its author.
func (d Deps) redact(ctx context.Context, in redactIn, hist []docVersion, target, what, history string) (redactOut, error) {
if d.Redactions == nil {
return redactOut{}, fmt.Errorf("redaction is not configured on this forge: %w", ops.ErrUnavailable)
}
if len(hist) == 0 {
return redactOut{}, fmt.Errorf("no such document: %w", ops.ErrNotFound)
}
at := -1
for i, v := range hist {
if strings.HasPrefix(v.hash, in.Rev) {
if at >= 0 {
return redactOut{}, fmt.Errorf("version %q is ambiguous; give more of its hash: %w", in.Rev, ops.ErrInvalid)
}
at = i
}
}
if at < 0 {
return redactOut{}, fmt.Errorf("no version %q in this history: %w", in.Rev, ops.ErrNotFound)
}
// The current version is what every reader sees; withholding it would
// leave the page blank for them. Reverting replaces it with text that
// may be shown, and then this version can be redacted.
if at == 0 && !in.Undo {
return redactOut{}, fmt.Errorf("the current version cannot be redacted; revert to an earlier one first: %w", ops.ErrInvalid)
}
v, actor := hist[at], author(ctx)
target += "@" + v.hash[:min(12, len(v.hash))]
if in.Undo {
if err := d.Redactions.Unredact(in.Repo, v.hash); err != nil {
return redactOut{}, err
}
if d.Forum != nil {
d.Forum.AppendModLog(in.Repo, actor, "unredact", target, in.Reason, v.user)
}
return redactOut{Hash: v.hash}, nil
}
if strings.TrimSpace(in.Reason) == "" {
return redactOut{}, fmt.Errorf("redacting needs a reason — it is shown in place of the text: %w", ops.ErrInvalid)
}
if err := d.Redactions.Redact(in.Repo, v.hash, in.Reason, actor); err != nil {
return redactOut{}, err
}
if d.Forum != nil {
d.Forum.AppendModLog(in.Repo, actor, "redact", target, in.Reason, v.user)
}
if v.user != actor {
d.notifyModerated(in.Repo, v.user, actor, "edit", "redacted", what, in.Reason,
d.link(history), "redact:"+v.hash)
}
return redactOut{Hash: v.hash, Redacted: true}, nil
}
// redactedIn is the redactions of one repository restricted to the given
// versions, for a history to mark them. Nil when there are none, so the
// field stays out of the answer.
func (d Deps) redactedIn(repo string, hashes []string) map[string]string {
if d.Redactions == nil {
return nil
}
all, err := d.Redactions.Redacted(repo)
if err != nil || len(all) == 0 {
return nil
}
var out map[string]string
for _, h := range hashes {
if reason, ok := all[h]; ok {
if out == nil {
out = map[string]string{}
}
out[h] = reason
}
}
return out
}
// withheld says whether a reader must not see this version's text, and
// why it was redacted. A moderator reads it, with the reason beside it.
func (d Deps) withheld(ctx context.Context, repo, hash string) (reason string, hide bool) {
red := d.redactedIn(repo, []string{hash})
reason, ok := red[hash]
if !ok {
return "", false
}
return reason, !d.holds(ctx, repo, "5")
}