Skip to content
quarry Artifact [0d7005f81b]
Login

Artifact [0d7005f81b]

Artifact 0d7005f81bba40282c20b1fd7856af905a988d092736534e38d831541b939f60:


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