feat(cli): search, messages send, webhook verify, sso verify
The CLI now covers the whole Redmine 507 surface: `search TERM [-page N]` (operators pass through quoted), `messages send -title -to user1,user2 [-group/-email] [-file|-raw]`, plus the two secret helpers `webhook verify SIG` (delivery body on stdin, exit 0 only on a valid HMAC) and `sso verify SSO SIG` (prints the decoded Discourse Connect payload as JSON). The helpers run before client construction so they work with only their secret env set, no instance credentials needed. Usage text lists the new env vars. Part of Redmine 507 (Discourse Go client).
This commit is contained in:
@@ -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])
|
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) {
|
func runRaw(ctx context.Context, c *discourse.Client, args []string) (any, error) {
|
||||||
if len(args) < 2 {
|
if len(args) < 2 {
|
||||||
return nil, fmt.Errorf("raw needs METHOD and PATH")
|
return nil, fmt.Errorf("raw needs METHOD and PATH")
|
||||||
|
|||||||
@@ -29,12 +29,18 @@ Usage:
|
|||||||
discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT]
|
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 create -topic ID [-file PATH | -raw TEXT]
|
||||||
discourse-go posts update ID [-file PATH | -raw TEXT] [-reason 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]
|
discourse-go raw METHOD PATH [-data JSON]
|
||||||
|
|
||||||
Environment (0600 env file, sourced before the call):
|
Environment (0600 env file, sourced before the call):
|
||||||
DISCOURSE_URL instance root, e.g. https://community.turnsys.com
|
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_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.
|
Perm levels: 1 = reply/see, 2 = create posts, 3 = full.
|
||||||
raw PATH is everything after the instance root, e.g. /groups.json.
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
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()
|
client, err := discourse.NewFromEnv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err)
|
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:])
|
out, err = runTopics(ctx, client, args[1:])
|
||||||
case "posts":
|
case "posts":
|
||||||
out, err = runPosts(ctx, client, args[1:])
|
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":
|
case "raw":
|
||||||
out, err = runRaw(ctx, client, args[1:])
|
out, err = runRaw(ctx, client, args[1:])
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user