// 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 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 }