Skip to content
quarry Artifact [86cfb67586]
Login

Artifact [86cfb67586]

Artifact 86cfb67586459920011fb1e3cceb2022c3edca716ca7cb0696150eb32c817508:


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