Skip to content
quarry Artifact [5e74d6290c]
Login

Artifact [5e74d6290c]

Artifact 5e74d6290ce4d1edc30ca7b23bd887f835a25c891d7ebd2252a6e4ee5a777f20:


package api

import (
	"context"
	"fmt"
	"slices"
	"strings"

	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/lease"
	"craftdesign.group/quarry/internal/ops"
)

// RemovalStore keeps the reason an admin gave for deleting someone
// else's forum post, keyed by the deleting edit's hash. The deletion
// itself is fossil's, in the DAG; the reason is the forge's.
type RemovalStore interface {
	RecordRemoval(repo, post, reason, actor string) error
	Removals(repo string) (map[string]string, error)
}

type forumEditIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Post names any version of the post (hash prefix); the edit always
	// replaces the newest one.
	Post     string `json:"post" validate:"required,min=4,max=64"`
	Body     string `json:"body" validate:"required,max=1048576"`
	Title    string `json:"title" validate:"max=200"` // a thread opener's title; empty keeps it
	Mimetype string `json:"mimetype" validate:"max=64"`
}

type forumDeleteIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Post   string `json:"post" validate:"required,min=4,max=64"`
	Reason string `json:"reason" validate:"max=500"`
}

type forumEditOut struct {
	Hash string `json:"hash"`
}

// The rules are fossil's (forum.c), with the edit widened two ways and
// marked: the author edits and deletes their own post; the person who
// answers for an agent edits the agent's posts, since they answer for
// its words; a forum moderator edits anyone's. An edit by anyone but the
// author reads "edited by NAME" on the post, every version stays in its
// history, and a moderator's lands in the moderation log with the
// author told. An admin may delete anyone's post with a reason.
func registerForumEdits(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[forumEditIn, forumEditOut]{
		Name: "forum.edit",
		Caps: "3",
		Doc: "Edit a forum post: fossil's edit artifact, a new version that replaces the newest one. " +
			"Every earlier version stays in the post's history. An edit that changes nothing (same text, format and title) writes nothing and returns the newest version's hash. The author may edit it, as may the person " +
			"who answers for an agent author, and a forum moderator (letter 5) may edit anyone's, which the " +
			"moderation log records and the author is told of. On a moderated forum an edit by anyone but a " +
			"moderator needs permission to post without moderation.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/forum-post/{post}/edit",
		Run: func(ctx context.Context, in forumEditIn) (forumEditOut, error) {
			if err := refuseSecondPoll(in.Body); err != nil {
				return forumEditOut{}, err
			}
			repo, original, newest, err := d.forumChain(ctx, in.Repo, in.Post)
			if err != nil {
				return forumEditOut{}, err
			}
			who := author(ctx)
			answers := d.answersFor(who, original.User)
			moderator := !answers && d.holds(ctx, in.Repo, "5")
			if !answers && !moderator {
				return forumEditOut{}, fmt.Errorf("only its author, whoever answers for an agent author, or a forum moderator may edit a post: %w", ops.ErrForbidden)
			}
			// An edit is new text. On a moderated forum it would skip the
			// queue the first version went through, so it needs the same
			// letter that lets a post skip it; a moderator is the queue.
			if !moderator && d.Forum != nil && d.Forum.ModerationEnabled(in.Repo) && !d.holds(ctx, in.Repo, "4") {
				return forumEditOut{}, fmt.Errorf("this forum is moderated; editing a post needs permission to post without moderation: %w", ops.ErrForbidden)
			}
			// The version's own format unless the caller names one: an
			// empty N card is fossil wiki, and defaulting it to markdown
			// would re-render a fossil post's text in another syntax.
			mt := in.Mimetype
			if mt == "" {
				mt = newest.Mimetype
			}
			// A save that changes nothing writes nothing (quarry#377): a
			// version identical to the one before it is history with no
			// event in it, and an edit notice nobody can find a change in.
			if unchangedPost(repo, newest, in.Title, mt, in.Body) {
				return forumEditOut{Hash: newest.Hash}, nil
			}
			var hash string
			if err := repo.write(func(tok lease.Token) (err error) {
				hash, err = repo.Zit.ForumEdit(tok, newest.Hash, who, in.Title, mt, []byte(in.Body))
				return err
			}); err != nil {
				return forumEditOut{}, err
			}
			if err := repo.synced(); err != nil {
				return forumEditOut{}, err
			}
			if moderator && d.Forum != nil {
				d.Forum.AppendModLog(in.Repo, who, "edit", original.Hash[:min(12, len(original.Hash))], "", original.User)
				thread := original.Hash
				if _, t, ok, terr := repo.Index.ForumPostThread(original.Hash); terr == nil && ok {
					thread = t
				}
				d.notifyModerated(in.Repo, original.User, who, "post", "edited", "your forum post", "",
					d.link("/r/"+in.Repo+"/forum/"+thread[:min(16, len(thread))]), "edit:"+hash)
			}
			return forumEditOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[forumDeleteIn, forumEditOut]{
		Name: "forum.delete",
		Caps: "3",
		Doc: "Delete a forum post: fossil's deletion, an empty edit, so the post reads as deleted and its " +
			"history keeps every version. The author may delete their own; a repository admin may delete " +
			"anyone's with a reason, which the tombstone shows, the moderation log records, and the author is told.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/forum-post/{post}/delete",
		Run: func(ctx context.Context, in forumDeleteIn) (forumEditOut, error) {
			repo, original, newest, err := d.forumChain(ctx, in.Repo, in.Post)
			if err != nil {
				return forumEditOut{}, err
			}
			if newest.Body == "" && newest.Prior != "" {
				return forumEditOut{}, fmt.Errorf("that post is already deleted: %w", ops.ErrInvalid)
			}
			who := author(ctx)
			own := original.User == who
			if !own {
				if !d.holds(ctx, in.Repo, "a") {
					return forumEditOut{}, fmt.Errorf("only its author or a repository admin may delete a post: %w", ops.ErrForbidden)
				}
				if strings.TrimSpace(in.Reason) == "" {
					return forumEditOut{}, fmt.Errorf("removing someone else's post needs a reason — it is shown in its place: %w", ops.ErrInvalid)
				}
			}
			var hash string
			if err := repo.write(func(tok lease.Token) (err error) {
				hash, err = repo.Zit.ForumDelete(tok, newest.Hash, who)
				return err
			}); err != nil {
				return forumEditOut{}, err
			}
			if err := repo.synced(); err != nil {
				return forumEditOut{}, err
			}
			if own {
				return forumEditOut{Hash: hash}, nil
			}
			if d.Removals != nil {
				if err := d.Removals.RecordRemoval(in.Repo, hash, in.Reason, who); err != nil {
					return forumEditOut{}, err
				}
			}
			if d.Forum != nil {
				d.Forum.AppendModLog(in.Repo, who, "remove", original.Hash[:min(12, len(original.Hash))], in.Reason, original.User)
			}
			thread := original.Hash
			if _, t, ok, terr := repo.Index.ForumPostThread(original.Hash); terr == nil && ok {
				thread = t
			}
			d.notifyModerated(in.Repo, original.User, who, "post", "removed", "your forum post", in.Reason,
				d.link("/r/"+in.Repo+"/forum/"+thread[:min(16, len(thread))]), "remove:"+original.Hash)
			return forumEditOut{Hash: hash}, nil
		},
	})
}

// forumChain resolves a post prefix to its repository, its original
// version (whose author is the post's author) and its newest version
// (what an edit replaces).
func (d Deps) forumChain(ctx context.Context, repoName, post string) (*Repo, index.ForumPost, index.ForumPost, error) {
	var none index.ForumPost
	if err := d.refuseUnlessWritable(repoName); err != nil {
		return nil, none, none, err
	}
	repo, err := d.forumRepoFor(ctx, repoName)
	if err != nil {
		return nil, none, none, err
	}
	versions, ok, err := repo.Index.ForumPostVersions(post)
	if err != nil {
		return nil, none, none, err
	}
	if !ok || len(versions) == 0 {
		return nil, none, none, fmt.Errorf("no forum post %q: %w", post, ops.ErrNotFound)
	}
	return repo, versions[0], versions[len(versions)-1], nil
}

// answersFor says who may write as author: it is them, or an agent they
// operate or answer for.
func (d Deps) answersFor(who, author string) bool {
	return who != "" && who != "anonymous" && author != "" && slices.Contains(d.agentsAnd(who), author)
}

// mayEditPost says whether the caller may edit a post by author on repo:
// the rule forum.edit enforces, asked by forum.get so a page offers Edit
// to exactly the people the op will let through.
func (d Deps) mayEditPost(ctx context.Context, repo, author string) bool {
	return d.answersFor(ops.User(ctx), author) || d.holds(ctx, repo, "5")
}

// unchangedPost says whether an edit would write the newest version
// again: the same text in the same format, and no new title, or the
// title the thread already has.
func unchangedPost(repo *Repo, newest index.ForumPost, title, mimetype, body string) bool {
	if body != newest.Body || mimetype != newest.Mimetype {
		return false
	}
	if title == "" {
		return true
	}
	_, thread, ok, err := repo.Index.ForumPostThread(newest.Hash)
	if err != nil || !ok {
		return false
	}
	have, _, ok, err := repo.Index.ForumThreadPosts(thread)
	return err == nil && ok && have == title
}