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 }
func registerRedactions(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[redactIn, redactOut]{
Name: "wiki.redact",
Caps: "5",
Doc: "Withhold the text of one past version of a wiki page 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}/wiki/{name}/redact",
Run: func(ctx context.Context, in redactIn) (redactOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return redactOut{}, err
}
hist, err := repo.Index.WikiHistory(in.Name)
if err != nil {
return redactOut{}, err
}
vs := make([]docVersion, len(hist))
for i, h := range hist {
vs[i] = docVersion{h.Hash, h.User}
}
return d.redact(ctx, in, vs, "wiki:"+in.Name, "your edit of wiki page "+in.Name,
"/r/"+in.Repo+"/wiki/"+in.Name+"/history")
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[redactIn, redactOut]{
Name: "technote.redact",
Caps: "5",
Doc: "Withhold the text of one past version of a technote from everyone but moderators, behind a " +
"public reason; `undo` restores it. `name` is the technote's event id. 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}/technote/{name}/redact",
Run: func(ctx context.Context, in redactIn) (redactOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return redactOut{}, err
}
n, ok, err := repo.Index.TechnoteByEvent(in.Name)
if err != nil {
return redactOut{}, err
}
if !ok {
return redactOut{}, fmt.Errorf("no technote %q: %w", in.Name, ops.ErrNotFound)
}
hist, err := repo.Index.TechnoteHistory(n.Event)
if err != nil {
return redactOut{}, 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 d.redact(ctx, in, vs, "technote:"+ev[:min(12, len(ev))], "your edit of a technote",
"/r/"+in.Repo+"/technote/"+ev+"/history")
},
})
}
// 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")
}