Skip to content
quarry Artifact [fe015892a7]
Login

Artifact [fe015892a7]

Artifact fe015892a7f0310ff71bdfdaf65c1a28902b21af986e5fece90c230a953b1416:


package api

import (
	"context"
	"fmt"
	"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): the author edits and deletes their
// own post; an admin may delete anyone's. Narrower in one place: fossil
// also lets an admin edit someone else's words, and here an admin can
// only remove them, so a post never reads as its author's when it is not.
// Forum moderators keep Hide, which leaves the post untouched.
func registerForumEdits(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[forumEditIn, forumEditOut]{
		Name: "forum.edit",
		Caps: "3",
		Doc: "Edit your own forum post: fossil's edit artifact, a new version that replaces the newest one. " +
			"Every earlier version stays in the post's history. Only the post's author may edit it; on a " +
			"moderated forum, only an author who may 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
			}
			if original.User != author(ctx) {
				return forumEditOut{}, fmt.Errorf("only its author 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.
			if 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, author(ctx), in.Title, mt, []byte(in.Body))
				return err
			}); err != nil {
				return forumEditOut{}, err
			}
			if err := repo.synced(); err != nil {
				return forumEditOut{}, err
			}
			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
}