272 lines
8.1 KiB
Go
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
|
|
}
|