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
}