Files

272 lines
8.1 KiB
Go

// notice.go implements `nodectl notice add/list/revoke` — the operator CLI
// front-end for the notices package (system notifications: broadcast
// announcements with an optional important-only email fallback).
//
// DB access reuses the same env-driven open pattern as buildService
// (db.Open resolves driver via DB_DRIVER, defaulting to mysql, and reads
// DB_DSN — see internal/db/db.go — so this also works against the sqlite
// single-node deploy without any extra env here).
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"io"
"net/mail"
"net/smtp"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/wangjia/pangolin/server/internal/db"
"github.com/wangjia/pangolin/server/internal/notices"
)
// noticeTypes mirrors notices.validNoticeTypes (unexported in that package,
// kept in sync manually — see migrations/sqlite/000026_notices.up.sql CHECK).
var noticeTypes = map[string]bool{
"important": true,
"feature": true,
"news": true,
"reward": true,
"version": true,
"promo": true,
}
func cmdNotice(ctx context.Context, args []string) error {
if len(args) == 0 {
return fmt.Errorf("notice: subcommand required (add|list|revoke)")
}
sub := args[0]
rest := args[1:]
switch sub {
case "add":
return cmdNoticeAdd(ctx, rest)
case "list":
return cmdNoticeList(ctx, rest)
case "revoke":
return cmdNoticeRevoke(ctx, rest)
default:
return fmt.Errorf("notice: unknown subcommand %q (want add|list|revoke)", sub)
}
}
// parseNoticeAddFlags parses `nodectl notice add` flags into a
// notices.PublishInput. Kept separate from cmdNoticeAdd so validation is
// unit-testable without a DB: invalid type, missing titles, --email on a
// non-important type, and malformed --expires all fail here, before any
// Service/Store call.
func parseNoticeAddFlags(args []string) (notices.PublishInput, error) {
fs := flag.NewFlagSet("notice add", flag.ContinueOnError)
fs.SetOutput(io.Discard)
typ := fs.String("type", "", "notice type: important|feature|news|reward|version|promo (required)")
titleZH := fs.String("title-zh", "", "Chinese title (required)")
titleEN := fs.String("title-en", "", "English title (required)")
bodyZH := fs.String("body-zh", "", "Chinese body (optional)")
bodyEN := fs.String("body-en", "", "English body (optional)")
link := fs.String("link", "", "deep link / URL (optional)")
expires := fs.String("expires", "", "expiry date YYYY-MM-DD (expires at 23:59:59 UTC that day; optional)")
email := fs.Bool("email", false, "also send an email fallback (type=important only)")
if err := fs.Parse(args); err != nil {
return notices.PublishInput{}, fmt.Errorf("notice add: %w", err)
}
if !noticeTypes[*typ] {
return notices.PublishInput{}, fmt.Errorf("notice add: invalid -type %q (want important|feature|news|reward|version|promo)", *typ)
}
if strings.TrimSpace(*titleZH) == "" {
return notices.PublishInput{}, fmt.Errorf("notice add: -title-zh is required")
}
if strings.TrimSpace(*titleEN) == "" {
return notices.PublishInput{}, fmt.Errorf("notice add: -title-en is required")
}
if *email && *typ != "important" {
return notices.PublishInput{}, fmt.Errorf("notice add: -email is only allowed for -type=important, got %q", *typ)
}
in := notices.PublishInput{
Type: *typ,
TitleZH: *titleZH,
TitleEN: *titleEN,
BodyZH: *bodyZH,
BodyEN: *bodyEN,
Link: *link,
Email: *email,
}
if strings.TrimSpace(*expires) != "" {
day, err := time.Parse("2006-01-02", *expires)
if err != nil {
return notices.PublishInput{}, fmt.Errorf("notice add: invalid -expires %q (want YYYY-MM-DD): %w", *expires, err)
}
exp := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, time.UTC)
in.ExpiresAt = &exp
}
return in, nil
}
func cmdNoticeAdd(ctx context.Context, args []string) error {
in, err := parseNoticeAddFlags(args)
if err != nil {
return err
}
conn, closeFn, err := openNoticeDB()
if err != nil {
return err
}
defer closeFn()
st := notices.NewStore(conn)
svc := notices.NewService(st, buildNoticeMailer(), conn)
id, err := svc.Publish(ctx, in)
if err != nil {
return err
}
fmt.Printf("published notice id=%d type=%s\n", id, in.Type)
return nil
}
func cmdNoticeList(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("notice list", flag.ExitOnError)
all := fs.Bool("all", false, "include revoked/expired notices")
limit := fs.Int("limit", 20, "max rows")
_ = fs.Parse(args)
conn, closeFn, err := openNoticeDB()
if err != nil {
return err
}
defer closeFn()
st := notices.NewStore(conn)
rows, err := st.ListAdmin(ctx, *all, *limit)
if err != nil {
return err
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tTYPE\tTITLE_ZH\tPUBLISHED_AT\tSTATUS")
for _, r := range rows {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", r.ID, r.Type, r.TitleZH, r.PublishedAt.Format(time.RFC3339), r.Status)
}
return w.Flush()
}
func cmdNoticeRevoke(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("notice revoke", flag.ExitOnError)
_ = fs.Parse(args)
if fs.NArg() != 1 {
return fmt.Errorf("notice revoke: exactly one positional argument <id> is required")
}
id, err := strconv.ParseInt(fs.Arg(0), 10, 64)
if err != nil {
return fmt.Errorf("notice revoke: invalid id %q: %w", fs.Arg(0), err)
}
conn, closeFn, err := openNoticeDB()
if err != nil {
return err
}
defer closeFn()
st := notices.NewStore(conn)
svc := notices.NewService(st, buildNoticeMailer(), conn)
if err := svc.RevokeByID(ctx, id); err != nil {
return err
}
fmt.Printf("revoked notice id=%d\n", id)
return nil
}
// openNoticeDB opens the control-plane DB the same way buildService does:
// db.Open(dsn) resolves the driver internally via DB_DRIVER (defaulting to
// mysql; sqlite for single-node deploys) — see internal/db/db.go — so no
// extra driver plumbing is needed here.
func openNoticeDB() (*sql.DB, func(), error) {
dsn := os.Getenv("DB_DSN")
if dsn == "" {
return nil, nil, fmt.Errorf("DB_DSN is required")
}
conn, err := db.Open(dsn)
if err != nil {
return nil, nil, err
}
return conn, func() { conn.Close() }, nil
}
// buildNoticeMailer constructs a notices.EmailSender from the same SMTP_*
// env vars cmd/server/main.go uses to wire auth.SMTPMailer (SMTP_HOST,
// SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM). auth.SMTPMailer
// itself isn't reused because it only implements auth.Mailer's
// SendCode/SendAlreadyRegistered, not notices.EmailSender's generic
// Send(to, subject, body) — so this is a small parallel net/smtp sender.
// Returns a nil (untyped) notices.EmailSender when SMTP_HOST is unset;
// Service.Publish then explicitly rejects -email requests ("no mailer
// configured") rather than silently dropping them.
func buildNoticeMailer() notices.EmailSender {
host := os.Getenv("SMTP_HOST")
if host == "" {
return nil
}
port := 587
if p := os.Getenv("SMTP_PORT"); p != "" {
if v, err := strconv.Atoi(p); err == nil {
port = v
}
}
from := os.Getenv("SMTP_FROM")
if from == "" {
from = "no-reply@pangolin.app"
}
return &smtpNoticeMailer{
host: host,
port: port,
username: os.Getenv("SMTP_USERNAME"),
password: os.Getenv("SMTP_PASSWORD"),
from: from,
}
}
// smtpNoticeMailer is a minimal notices.EmailSender over net/smtp, mirroring
// auth.SMTPMailer's STARTTLS/PlainAuth send path (internal/auth/mailer.go)
// but with a generic subject/body instead of fixed verification-code copy.
type smtpNoticeMailer struct {
host, username, password, from string
port int
}
func (m *smtpNoticeMailer) envelopeFrom() string {
if a, err := mail.ParseAddress(m.from); err == nil {
return a.Address
}
return m.from
}
func (m *smtpNoticeMailer) Send(_ context.Context, to, subject, body string) error {
msg := strings.Join([]string{
"From: " + m.from,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"",
body,
}, "\r\n")
addr := fmt.Sprintf("%s:%d", m.host, m.port)
auth := smtp.PlainAuth("", m.username, m.password, m.host)
if err := smtp.SendMail(addr, auth, m.envelopeFrom(), []string{to}, []byte(msg)); err != nil {
return fmt.Errorf("nodectl: smtp send: %w", err)
}
return nil
}