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 }