diff --git a/cmd/discourse-go/commands.go b/cmd/discourse-go/commands.go index ec35d16..64e421e 100644 --- a/cmd/discourse-go/commands.go +++ b/cmd/discourse-go/commands.go @@ -144,6 +144,60 @@ func runPosts(ctx context.Context, c *discourse.Client, args []string) (any, err return nil, fmt.Errorf("unknown posts subcommand %q", args[0]) } +func runSearch(ctx context.Context, c *discourse.Client, args []string) (any, error) { + fs := flag.NewFlagSet("search", flag.ContinueOnError) + page := fs.Int("page", 1, "result page, 1-based") + if err := fs.Parse(flagsFirst(args)); err != nil { + return nil, err + } + if fs.NArg() != 1 { + return nil, fmt.Errorf("search needs exactly one TERM (quote it; @user, #category and order:latest operators pass through)") + } + return c.Search(ctx, discourse.SearchRequest{Term: fs.Arg(0), Page: *page}) +} + +func runMessages(ctx context.Context, c *discourse.Client, args []string) (any, error) { + if len(args) == 0 { + return nil, fmt.Errorf("messages needs a subcommand (send)") + } + switch args[0] { + case "send": + fs := flag.NewFlagSet("messages send", flag.ContinueOnError) + title := fs.String("title", "", "message title (required)") + to := fs.String("to", "", "recipient usernames, comma-separated (repeatable targets below)") + group := fs.String("group", "", "recipient group names, comma-separated") + email := fs.String("email", "", "recipient emails, comma-separated (invite external users)") + raw := fs.String("raw", "", "markdown body inline") + file := fs.String("file", "", "markdown body from file (overrides -raw)") + if err := fs.Parse(flagsFirst(args[1:])); err != nil { + return nil, err + } + body, err := bodyFrom(*file, *raw) + if err != nil { + return nil, err + } + return c.SendMessage(ctx, discourse.SendMessageRequest{ + Title: *title, + Raw: body, + TargetUsernames: splitComma(*to), + TargetGroupNames: splitComma(*group), + TargetEmails: splitComma(*email), + }) + } + return nil, fmt.Errorf("unknown messages subcommand %q", args[0]) +} + +// splitComma splits a comma-separated flag value, dropping empties. +func splitComma(s string) []string { + var out []string + for _, v := range strings.Split(s, ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out +} + func runRaw(ctx context.Context, c *discourse.Client, args []string) (any, error) { if len(args) < 2 { return nil, fmt.Errorf("raw needs METHOD and PATH") diff --git a/cmd/discourse-go/main.go b/cmd/discourse-go/main.go index ce2c794..db0b11a 100644 --- a/cmd/discourse-go/main.go +++ b/cmd/discourse-go/main.go @@ -29,12 +29,18 @@ Usage: discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT] discourse-go posts create -topic ID [-file PATH | -raw TEXT] discourse-go posts update ID [-file PATH | -raw TEXT] [-reason TEXT] + discourse-go search TERM [-page N] + discourse-go messages send -title TITLE -to USER[,USER...] [-file PATH | -raw TEXT] + discourse-go webhook verify SIG (delivery body on stdin) + discourse-go sso verify SSO SIG (Discourse Connect payload check) discourse-go raw METHOD PATH [-data JSON] Environment (0600 env file, sourced before the call): DISCOURSE_URL instance root, e.g. https://community.turnsys.com - DISCOURSE_API_KEY API key (never a flag, never logged) + DISCOURSE_API_KEY API key (DISCOURSE_KEY works too; never a flag, never logged) DISCOURSE_API_USERNAME the user the key acts as (default: system) + DISCOURSE_WEBHOOK_SECRET webhook signature secret (webhook verify) + DISCOURSE_SSO_SECRET Discourse Connect secret (sso verify) Perm levels: 1 = reply/see, 2 = create posts, 3 = full. raw PATH is everything after the instance root, e.g. /groups.json. @@ -54,6 +60,15 @@ func run(args []string) int { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // Secret-helper commands never touch an instance: run them before + // client construction so they work without DISCOURSE_URL/DISCOURSE_API_KEY. + switch args[0] { + case "webhook": + return runWebhook(args[1:]) + case "sso": + return runSSO(args[1:]) + } + client, err := discourse.NewFromEnv() if err != nil { fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err) @@ -74,6 +89,10 @@ func run(args []string) int { out, err = runTopics(ctx, client, args[1:]) case "posts": out, err = runPosts(ctx, client, args[1:]) + case "search": + out, err = runSearch(ctx, client, args[1:]) + case "messages": + out, err = runMessages(ctx, client, args[1:]) case "raw": out, err = runRaw(ctx, client, args[1:]) case "help", "-h", "--help": diff --git a/cmd/discourse-go/secretscmd.go b/cmd/discourse-go/secretscmd.go new file mode 100644 index 0000000..da9bc3c --- /dev/null +++ b/cmd/discourse-go/secretscmd.go @@ -0,0 +1,70 @@ +// Secret-helper commands: they never touch an instance, so they run +// before client construction and read only their own env secrets. +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "git.knownelement.com/ukrrs/mopac-discourse-go" +) + +// runWebhook implements `webhook verify SIG`: the raw delivery body on +// stdin, the X-Discourse-Event-Signature value as the argument, the +// secret from DISCOURSE_WEBHOOK_SECRET. Exit 0 = signature valid. +func runWebhook(args []string) int { + if len(args) != 2 || args[0] != "verify" { + fmt.Fprintf(os.Stderr, "webhook needs: verify SIG (delivery body on stdin)\n\n%s", usage) + return 1 + } + secret, err := discourse.WebhookSecretFromEnv() + if err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err) + return 1 + } + body, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: read stdin: %v\n", err) + return 1 + } + if err := discourse.VerifyWebhook(secret, body, args[1]); err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err) + return 2 + } + fmt.Printf("verified %d bytes\n", len(body)) + return 0 +} + +// runSSO implements `sso verify SSO SIG`: checks a Discourse Connect +// login redirect's sso/sig pair against DISCOURSE_SSO_SECRET and prints +// the decoded payload as JSON. Exit 0 = signature valid. +func runSSO(args []string) int { + if len(args) != 3 || args[0] != "verify" { + fmt.Fprintf(os.Stderr, "sso needs: verify SSO SIG (sso+sig from the login URL)\n\n%s", usage) + return 1 + } + secret, err := discourse.SSOSecretFromEnv() + if err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err) + return 1 + } + vals, err := discourse.VerifySSO(secret, args[1], args[2]) + if err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err) + return 2 + } + obj := map[string]string{} + for k, v := range vals { + obj[k] = strings.Join(v, ",") + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(obj); err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: encode payload: %v\n", err) + return 1 + } + return 0 +}