149 lines
3.7 KiB
Go
149 lines
3.7 KiB
Go
|
|
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
|
||
|
|
}
|