fluid-telegram/internal/compose/queue.go

149 lines
3.7 KiB
Go
Raw Normal View History

package compose
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Queue is the review queue. Drafts are files under a directory the caller
// passes; this package never chooses a location of its own.
type Queue struct {
dir string
}
// Open opens a review queue at dir, creating the directory if needed.
func Open(dir string) (*Queue, error) {
if strings.TrimSpace(dir) == "" {
return nil, errors.New("queue directory is required")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
info, err := os.Stat(dir)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", dir)
}
return &Queue{dir: dir}, nil
}
// Put stores a new draft. Review fields are cleared: approval only happens
// through Approve, and a caller cannot smuggle a consent basis in.
// Putting the same post_id again keeps the copy already queued.
func (q *Queue) Put(d Draft) error {
if _, err := q.path(d.PostID); err != nil {
return err
}
existing, err := q.Get(d.PostID)
if err == nil {
if existing.Body != d.Body || existing.SourceEntryID != d.SourceEntryID || existing.SourceEntryURL != d.SourceEntryURL || existing.Variant != d.Variant {
return fmt.Errorf("post_id %s is already queued with different content", d.PostID)
}
return nil
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
d.ReviewedBy = ""
d.SubjectConsent = ""
d.Approved = false
d.Publication = nil
return q.save(d)
}
// Get reads one draft from the queue.
func (q *Queue) Get(postID string) (Draft, error) {
path, err := q.path(postID)
if err != nil {
return Draft{}, err
}
raw, err := os.ReadFile(path)
if err != nil {
return Draft{}, err
}
var d Draft
if err := json.Unmarshal(raw, &d); err != nil {
return Draft{}, fmt.Errorf("read draft %s: %w", postID, err)
}
return d, nil
}
// Approve records the reviewer and a consent basis supplied by the caller.
// consent is not defaulted. not-required is recorded only when the caller
// passes that value; absence of objection is not treated as consent.
func (q *Queue) Approve(postID, reviewer, consent string) (Draft, error) {
reviewer = strings.TrimSpace(reviewer)
consent = strings.TrimSpace(consent)
if reviewer == "" {
return Draft{}, ErrReviewer
}
if _, ok := consentBases[consent]; !ok {
return Draft{}, fmt.Errorf("%w: got %q", ErrConsent, consent)
}
d, err := q.Get(postID)
if err != nil {
return Draft{}, err
}
d.ReviewedBy = reviewer
d.SubjectConsent = consent
d.Approved = true
if err := q.save(d); err != nil {
return Draft{}, err
}
return d, nil
}
func (q *Queue) save(d Draft) error {
path, err := q.path(d.PostID)
if err != nil {
return err
}
raw, err := marshalDraft(d)
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func marshalDraft(d Draft) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(d); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (q *Queue) path(postID string) (string, error) {
if postID == "" || strings.Contains(postID, "/") || strings.Contains(postID, "\\") || strings.Contains(postID, "..") {
return "", fmt.Errorf("invalid post_id %q", postID)
}
path := filepath.Join(q.dir, postID+".json")
absDir, err := filepath.Abs(q.dir)
if err != nil {
return "", err
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
rel, err := filepath.Rel(absDir, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("invalid post_id %q", postID)
}
return path, nil
}