Skip to content
quarry Artifact [a2c1948447]
Login

Artifact [a2c1948447]

Artifact a2c194844765a5640828612950a8eefb906b78f8dd9efbff8909b164dfe0bdb2:


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