8 Commits
Author SHA1 Message Date
mrcharles bb96849641 docs: README + env.example for search, PMs, webhook/SSO helpers
Wire table gains the search, private-message, webhook and SSO
rows; the library surface shows Search/SendMessage calls and a
server-side helper section (WebhookHandler, SSOLogin, secret
loaders); the CLI reference lists the four new commands including
their secret env vars. env.example documents the DISCOURSE_KEY
alias and the two commented-out secrets.

Part of Redmine 507 (Discourse Go client).
2026-08-29 16:55:31 -05:00
mrcharles 445df7a836 test: smoke coverage for search, private messages, webhook/SSO
The fake smoke Discourse now serves /search.json (title substring
matches over created topics), accepts private-message creation on
POST /posts.json (targets comma-joined on the wire, deliveries
recorded and exposed via /__pms.json), and the smoke script drives
the real CLI through it: search hits, PM send + recipient
assertion, webhook verify with a python-computed HMAC (good sig
exits 0, bad sig exits 2 with the typed message), and sso verify
decoding a signed Discourse Connect payload (tamper case exits 2).
No live forum is contacted.

Part of Redmine 507 (Discourse Go client).
2026-08-29 16:54:43 -05:00
mrcharles f13302778b 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).
2026-08-29 16:50:31 -05:00
mrcharles 5b95ac3c7e feat: accept DISCOURSE_KEY as env alias for the API key
NewFromEnv now falls back to DISCOURSE_KEY when DISCOURSE_API_KEY
is unset (the explicit name still wins), matching the shorter
DISCOURSE_URL/DISCOURSE_KEY convention used across the MOPAC fleet
scripts. Behavioral test proves both the alias and the precedence
against the fake server.

Part of Redmine 507 (Discourse Go client).
2026-08-29 16:47:17 -05:00
mrcharles 5bbb307b12 feat: webhook + SSO secret helpers (HMAC verification)
The server side of talking to Discourse, all stdlib crypto:
VerifyWebhook checks X-Discourse-Event-Signature (sha256 HMAC hex,
constant-time, with or without the sha256= prefix) over the raw
body; ParseWebhook/WebhookHandler turn that into a drop-in
http.HandlerFunc that 403s mis-signed deliveries before dispatch.
VerifySSO decodes + verifies a Discourse Connect login redirect
(HMAC over the base64 string), BuildSSOResponse signs the identity
answer (nonce + email/external_id/username/...), and SSOLogin does
the whole dance in one call, returning the redirect URL. Secrets
load from DISCOURSE_WEBHOOK_SECRET / DISCOURSE_SSO_SECRET, are
trimmed, never logged and never echoed in errors.

Part of Redmine 507 (Discourse Go client): the webhook helper is
what the fleet receiver and 495 delivery loop verify pushes with.
2026-08-29 16:43:59 -05:00
mrcharles 6891f3febc feat: private-message send via POST /posts.json targets
Client.SendMessage(title, raw, targets) creates a private-message
topic by posting to /posts.json with target_usernames /
target_group_names / target_emails (comma-joined on the wire, as
Discourse expects, never JSON arrays) instead of a category. The
result reuses the topic ids type, so URL() renders the PM link.
Local validation requires title, body and at least one target;
undeliverable recipients surface as the typed ErrForbidden class.
Fake-server tests assert the wire shape and the recorded recipients.

Part of Redmine 507 (Discourse Go client): PM delivery is how the
495 pipeline pings humans that a briefing has landed.
2026-08-29 16:36:54 -05:00
mrcharles 14cdaa0a2d feat: search — GET /search.json with grouped hits
Client.Search(term, page) url-encodes the term (operators like
@user / #category / order:latest pass through) and decodes the four
hit groups Discourse returns: posts (with blurb and highlighted
topic title), topics, users and categories. Empty term is rejected
locally with ErrInvalidRequest; server failures keep the typed
error classes. Fake-server tests cover encoding, parsing and the
403 path.

Part of Redmine 507 (Discourse Go client): search is how the 495
briefing pipeline finds landing spots and prior threads.
2026-08-29 16:25:29 -05:00
mrcharles f51ca5900c style: gofmt struct-tag alignment across v0 files
v0 landed with a few misaligned struct tags and fields; gofmt -l
flagged categories.go, topics.go, discourse_test.go and the smoke
fake. Alignment only, no behavior change, so the tree passes the
gofmt gate cleanly.
2026-08-29 16:20:44 -05:00
17 changed files with 1190 additions and 36 deletions
+49 -7
View File
@@ -9,10 +9,12 @@ land). AGPLv3.
Status: 2026-08-29 — v0 complete and green: categories (list/create),
topics (create/list/latest/get), posts (create/update/get), current-user
identity probe, raw JSON passthrough for everything else, typed error
classes, thin CLI. Built and tested entirely against a fake Discourse
(no live writes). Live reads verified against community.turnsys.com;
category creation 403s with the current key (needs the admin-scoped key)
— see "Live verification" below.
classes, thin CLI. Same-day additions (Redmine 507): full-text search,
private-message send, and the webhook + SSO (Discourse Connect) secret
helpers. Built and tested entirely against a fake Discourse (no live
writes). Live reads verified against community.turnsys.com; category
creation 403s with the current key (needs the admin-scoped key) — see
"Live verification" below.
## What it implements
@@ -30,6 +32,10 @@ The wire protocol, in plain REST with stdlib:
| posts create | `POST /posts.json``topic_id`+`raw` |
| posts update | `PUT /posts/<id>.json``{post: {raw, edit_reason}}` |
| posts get | `GET /posts/<id>.json` |
| search | `GET /search.json?term=...&page=N` — grouped posts/topics/users/categories hits; term operators (`@user`, `#category`, `order:latest`) pass through |
| message send | `POST /posts.json``title`+`raw`+`target_usernames`/`target_group_names`/`target_emails` (comma-joined) instead of a category; creates a private-message topic |
| webhook verify | `X-Discourse-Event-Signature` — sha256-HMAC hex (with `sha256=` prefix) over the raw body, constant-time compare |
| SSO verify/response | Discourse Connect — sha256-HMAC hex over the base64 `sso` string; payload = urlencoded fields (nonce, return_sso_url / email, external_id, ...) |
| raw anything | `Do(ctx, METHOD, path, body, out)` — JSON in, JSON out; unmodeled endpoints never block on a client release |
Auth is the header pair `Api-Key` + `Api-Username` on every request.
@@ -55,8 +61,37 @@ res.URL(baseURL) // https://forum/t/<slug>/<topic_id>
p, _ := c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: res.TopicID, Raw: "reply"})
_, _ = c.UpdatePost(ctx, p.PostID, "edited", "typo")
hits, _ := c.Search(ctx, discourse.SearchRequest{Term: "fleet briefing", Page: 1})
// hits.Posts[0].TopicID / .Blurb; also .Topics, .Users, .Categories
pm, _ := c.SendMessage(ctx, discourse.SendMessageRequest{
Title: "Briefing landed", Raw: markdown,
TargetUsernames: []string{"ops", "reachableceo"},
})
pm.URL(c.BaseURL()) // private-message topic link
```
### Webhook + SSO helpers (server side)
```go
// webhook receiver: verify before trusting anything
http.Handle("/hooks/discourse", discourse.WebhookHandler(secret, func(evt *discourse.WebhookEvent) {
// evt.Event == "post_created" etc.; evt.Body is the raw JSON
}))
err := discourse.VerifyWebhook(secret, body, sigHeader) // standalone
// Discourse Connect (SSO) login endpoint
redirect, err := discourse.SSOLogin(ctx, secret, r.FormValue("sso"), r.FormValue("sig"),
map[string]string{"email": u.Email, "external_id": u.ID, "username": u.Handle})
http.Redirect(w, r, redirect, http.StatusFound)
// secrets: env-only, trimmed, never logged
secret, _ := discourse.WebhookSecretFromEnv() // DISCOURSE_WEBHOOK_SECRET
secret, _ := discourse.SSOSecretFromEnv() // DISCOURSE_SSO_SECRET
```
Bad signatures are the typed `ErrBadSignature` — drop the delivery.
Errors classify via `errors.Is`: `ErrForbidden` (403 — key lacks scope),
`ErrUnauthorized` (401), `ErrNotFound`, `ErrRateLimited` (429, with
`APIError.RetryAfter`), `ErrServer`, `ErrUnreachable`,
@@ -84,7 +119,8 @@ End-to-end smoke — builds the CLI, boots the fake Discourse in a
container on `127.0.0.1:8610`, drives the real binary from the host
through a 0600 env file (whoami / category list+create / topic create /
topic list by id and slug / post reply / post update / raw passthrough /
typed 404 / redaction sweep / bad-key exit code):
search / private-message send / webhook verify good+bad sig / sso
verify good+bad sig / typed 404 / redaction sweep / bad-key exit code):
```sh
./dev.sh smoke
```
@@ -99,7 +135,8 @@ Credentials NEVER arrive via flags or arguments:
```sh
mkdir -p ~/.config/discourse-go && umask 077
cp env.example ~/.config/discourse-go/env
# fill in DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME
# fill in DISCOURSE_URL / DISCOURSE_API_KEY (or DISCOURSE_KEY) /
# DISCOURSE_API_USERNAME, and the webhook/SSO secrets if used
```
Then `set -a; . ~/.config/discourse-go/env; set +a` before the CLI (or
source it in the service env). For the harness, the key resolves through
@@ -116,11 +153,16 @@ discourse-go topics show ID
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...] [-group NAMES] [-email ADDRS] [-file PATH | -raw TEXT]
discourse-go webhook verify SIG # delivery body on stdin, secret from DISCOURSE_WEBHOOK_SECRET
discourse-go sso verify SSO SIG # secret from DISCOURSE_SSO_SECRET; prints the payload as JSON
discourse-go raw METHOD PATH [-data JSON|@file]
```
Perm levels: 1 = reply/see, 2 = create posts, 3 = full. Output is JSON.
Exit codes: 0 ok, 1 usage/config, 2 API/transport error (the message
names the class).
names the class). `webhook verify` / `sso verify` need no instance
credentials — only their secret.
## Live verification (community.turnsys.com, 2026-08-29)
+7 -7
View File
@@ -28,13 +28,13 @@ type Category struct {
// means "default: everyone full" — for restricted categories set it
// explicitly, e.g. {"staff": 3, "trust_level_0": 1}.
type CreateCategoryRequest struct {
Name string `json:"name"`
Color string `json:"color"` // 6 hex digits, no '#'
TextColor string `json:"text_color"` // 6 hex digits, no '#'
Permissions map[string]int `json:"permissions,omitempty"`
Position int `json:"position,omitempty"`
ParentCategoryID int `json:"parent_category_id,omitempty"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
Color string `json:"color"` // 6 hex digits, no '#'
TextColor string `json:"text_color"` // 6 hex digits, no '#'
Permissions map[string]int `json:"permissions,omitempty"`
Position int `json:"position,omitempty"`
ParentCategoryID int `json:"parent_category_id,omitempty"`
Description string `json:"description,omitempty"`
}
// ListCategories returns the instance's categories (one request; the
+54
View File
@@ -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")
+20 -1
View File
@@ -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":
+70
View File
@@ -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
}
+9 -4
View File
@@ -117,11 +117,16 @@ func New(baseURL, apiKey, apiUsername string) (*Client, error) {
}, nil
}
// NewFromEnv builds a client from DISCOURSE_URL, DISCOURSE_API_KEY and
// DISCOURSE_API_USERNAME (default "system"). The intended source is a
// 0600 env file (see env.example), sourced before the process starts.
// NewFromEnv builds a client from DISCOURSE_URL, the API key
// (DISCOURSE_API_KEY, falling back to the shorter DISCOURSE_KEY alias)
// and DISCOURSE_API_USERNAME (default "system"). The intended source is
// a 0600 env file (see env.example), sourced before the process starts.
func NewFromEnv() (*Client, error) {
return New(os.Getenv("DISCOURSE_URL"), os.Getenv("DISCOURSE_API_KEY"), os.Getenv("DISCOURSE_API_USERNAME"))
key := os.Getenv("DISCOURSE_API_KEY")
if key == "" {
key = os.Getenv("DISCOURSE_KEY")
}
return New(os.Getenv("DISCOURSE_URL"), key, os.Getenv("DISCOURSE_API_USERNAME"))
}
// String renders the client for logs: base url + acting username only.
+137 -15
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -18,15 +19,19 @@ const testKey = "test-api-key-0123456789"
// enforces the Api-Key/Api-Username headers, serves the endpoints the
// client covers, and records every request for assertions.
type fakeDiscourse struct {
mu sync.Mutex
requests []string // "METHOD path"
cats map[int]Category
nextCat int
posts map[int]postRec // post id -> record
nextPost int
topics map[int]topicRec
nextT int
srv *httptest.Server
mu sync.Mutex
requests []string // "METHOD path"
uris []string // "METHOD path?query" — for query assertions
cats map[int]Category
nextCat int
posts map[int]postRec // post id -> record
nextPost int
topics map[int]topicRec
nextT int
searchFail int
pms []pmRec // private messages, in creation order
lastBody string // raw JSON body of the last POST /posts.json
srv *httptest.Server
}
type postRec struct {
@@ -41,6 +46,13 @@ type topicRec struct {
Cat int
}
type pmRec struct {
TopicID int
Title string
Targets []string
Raw string
}
func newFake(t *testing.T) *fakeDiscourse {
t.Helper()
f := &fakeDiscourse{
@@ -82,15 +94,15 @@ func newFake(t *testing.T) *fakeDiscourse {
}
if req.Name == "denied" { // simulate a non-admin key
writeJSON(w, 403, map[string]any{
"errors": []string{"You are not permitted to view the requested resource."},
"error_description": "Access denied",
"errors": []string{"You are not permitted to view the requested resource."},
"error_description": "Access denied",
})
return
}
f.mu.Lock()
cat := Category{
ID: f.nextCat, Name: req.Name,
Slug: strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")),
Slug: strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")),
Color: req.Color, TextColor: req.TextColor, Position: req.Position,
}
f.nextCat++
@@ -109,13 +121,47 @@ func newFake(t *testing.T) *fakeDiscourse {
writeJSON(w, 405, nil)
return
}
var req CreatePostRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
body, err := io.ReadAll(r.Body)
if err != nil {
writeJSON(w, 400, map[string]any{"errors": []string{"bad body"}})
return
}
var req struct {
CreatePostRequest
TargetUsernames string `json:"target_usernames"`
TargetGroupNames string `json:"target_group_names"`
TargetEmails string `json:"target_emails"`
}
if err := json.Unmarshal(body, &req); err != nil {
writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}})
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.lastBody = string(body)
if req.TargetUsernames != "" || req.TargetGroupNames != "" || req.TargetEmails != "" {
// private-message creation
targets := splitCSV(req.TargetUsernames)
for _, name := range append(append(targets, splitCSV(req.TargetGroupNames)...), splitCSV(req.TargetEmails)...) {
if strings.HasPrefix(name, "denied") {
writeJSON(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}})
return
}
}
if strings.TrimSpace(req.Title) == "" {
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
return
}
tid := f.nextT
f.nextT++
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
f.pms = append(f.pms, pmRec{TopicID: tid, Title: req.Title, Targets: targets, Raw: req.Raw})
pid := f.nextPost
f.nextPost++
f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw}}
writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug})
return
}
if req.TopicID == 0 { // topic creation
if strings.TrimSpace(req.Title) == "" || req.Category == 0 {
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
@@ -158,7 +204,7 @@ func newFake(t *testing.T) *fakeDiscourse {
case http.MethodPut:
var body struct {
Post struct {
Raw string `json:"raw"`
Raw string `json:"raw"`
EditReason string `json:"edit_reason"`
} `json:"post"`
}
@@ -179,6 +225,26 @@ func newFake(t *testing.T) *fakeDiscourse {
writeJSON(w, 405, nil)
}
})
mux.HandleFunc("/search.json", func(w http.ResponseWriter, r *http.Request) {
if !f.auth(w, r) {
return
}
if f.searchFail != 0 {
writeJSON(w, f.searchFail, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}})
return
}
writeJSON(w, 200, map[string]any{
"posts": []map[string]any{{
"id": 55, "topic_id": 12, "username": "reachableceo", "post_number": 1,
"blurb": "the fleet briefing for September",
"created_at": "2026-08-29T10:00:00.000Z",
"topic_title_headline": "Fleet briefing 2026-09-01",
}},
"topics": []map[string]any{{"id": 12, "title": "Fleet briefing 2026-09-01", "slug": "fleet-briefing-2026-09-01"}},
"users": []map[string]any{{"id": 5, "username": "reachableceo", "name": "Charles"}},
"categories": []map[string]any{{"id": 2, "name": "MOPAC Briefings", "slug": "mopac-briefings"}},
})
})
mux.HandleFunc("/latest.json", func(w http.ResponseWriter, r *http.Request) {
if !f.auth(w, r) {
return
@@ -203,6 +269,7 @@ func newFake(t *testing.T) *fakeDiscourse {
func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool {
f.mu.Lock()
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
f.uris = append(f.uris, r.Method+" "+r.URL.RequestURI())
f.mu.Unlock()
if r.Header.Get("Api-Key") != testKey || r.Header.Get("Api-Username") != "system" {
writeJSON(w, 403, map[string]any{"errors": []string{"Bad or missing API key"}})
@@ -222,12 +289,40 @@ func (f *fakeDiscourse) saw(substr string) bool {
return false
}
// sawQuery asserts a GET hit path with a query substring ("term=x",
// "page=2").
func (f *fakeDiscourse) sawQuery(path, param string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, u := range f.uris {
if strings.HasPrefix(u, "GET "+path+"?") && strings.Contains(u, param) {
return true
}
}
return false
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
// splitCSV splits a comma-joined target list ("ops,reachableceo").
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func testClient(t *testing.T, f *fakeDiscourse) *Client {
t.Helper()
c, err := New(f.srv.URL, testKey, "")
@@ -246,6 +341,33 @@ func TestNewRejectsBadInput(t *testing.T) {
}
}
func TestNewFromEnvKeyAlias(t *testing.T) {
f := newFake(t)
// alias alone works: DISCOURSE_KEY is accepted for the API key
t.Setenv("DISCOURSE_URL", f.srv.URL)
t.Setenv("DISCOURSE_API_USERNAME", "system")
t.Setenv("DISCOURSE_API_KEY", "")
t.Setenv("DISCOURSE_KEY", testKey)
c, err := NewFromEnv()
if err != nil {
t.Fatalf("NewFromEnv with DISCOURSE_KEY: %v", err)
}
if _, err := c.CurrentUser(context.Background()); err != nil {
t.Fatalf("alias key must authenticate against the fake: %v", err)
}
// explicit DISCOURSE_API_KEY wins over the alias (fake 403s any other key)
t.Setenv("DISCOURSE_API_KEY", "not-the-key")
t.Setenv("DISCOURSE_KEY", testKey)
c, err = NewFromEnv()
if err != nil {
t.Fatalf("NewFromEnv: %v", err)
}
_, err = c.CurrentUser(context.Background())
if !errors.Is(err, ErrForbidden) {
t.Fatalf("primary key must take precedence over the alias, got %v", err)
}
}
func TestCurrentUser(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
+9
View File
@@ -7,8 +7,17 @@ DISCOURSE_URL=https://community.turnsys.com
# Discourse API key (Admin API > Keys, or a user API key). Creating
# categories requires an admin-scoped key; a standard key 403s there.
# DISCOURSE_KEY is accepted as an alias when DISCOURSE_API_KEY is unset.
DISCOURSE_API_KEY=replace-me
# The user the key acts as (must match the key's allowed username, or be
# "system" for global keys).
DISCOURSE_API_USERNAME=reachableceo
# Webhook payload secret (Admin > Settings > API > web hook). Only the
# `webhook verify` command and the server-side helpers need it.
#DISCOURSE_WEBHOOK_SECRET=replace-me
# Discourse Connect (SSO) shared secret (Admin > Settings > Login >
# discourse connect secret). Only the SSO helpers need it.
#DISCOURSE_SSO_SECRET=replace-me
+77
View File
@@ -0,0 +1,77 @@
// Private messages: sent through the same POST /posts.json endpoint as
// topics, but with target_usernames / target_group_names / target_emails
// instead of a category — Discourse then creates a private-message topic
// (archetype private_message) visible only to sender and recipients.
package discourse
import (
"context"
"fmt"
"strings"
)
// SendMessageRequest sends a private message. Exactly one of the three
// target lists is the common case; any non-empty one works. At least one
// target overall is required — Discourse 422s without one.
type SendMessageRequest struct {
Title string
Raw string
TargetUsernames []string
TargetGroupNames []string
TargetEmails []string
}
// sendMessageWire is the POST /posts.json body: Discourse expects the
// target lists comma-joined strings, not JSON arrays.
type sendMessageWire struct {
Title string `json:"title"`
Raw string `json:"raw"`
TargetUsernames string `json:"target_usernames,omitempty"`
TargetGroupNames string `json:"target_group_names,omitempty"`
TargetEmails string `json:"target_emails,omitempty"`
}
// SendMessageResult is the response: a fresh private-message topic,
// same shape as a topic creation (URL() renders its link).
type SendMessageResult = CreateTopicResult
// SendMessage posts a private message and returns the new PM topic's
// ids. Unknown recipients fail server-side as 403 (ErrForbidden) —
// that is the "not permitted to view the requested resource" case.
func (c *Client) SendMessage(ctx context.Context, req SendMessageRequest) (*SendMessageResult, error) {
if strings.TrimSpace(req.Title) == "" {
return nil, fmt.Errorf("%w: message title is required", ErrInvalidRequest)
}
if strings.TrimSpace(req.Raw) == "" {
return nil, fmt.Errorf("%w: message raw body is required", ErrInvalidRequest)
}
if len(req.TargetUsernames) == 0 && len(req.TargetGroupNames) == 0 && len(req.TargetEmails) == 0 {
return nil, fmt.Errorf("%w: at least one target (username, group or email) is required", ErrInvalidRequest)
}
wire := sendMessageWire{
Title: req.Title,
Raw: req.Raw,
TargetUsernames: strings.Join(cleanList(req.TargetUsernames), ","),
TargetGroupNames: strings.Join(cleanList(req.TargetGroupNames), ","),
TargetEmails: strings.Join(cleanList(req.TargetEmails), ","),
}
var out SendMessageResult
if err := c.Post(ctx, "/posts.json", wire, &out); err != nil {
return nil, err
}
if out.TopicID == 0 {
return nil, fmt.Errorf("%w: message sent but response carries no topic_id", ErrMalformedResponse)
}
return &out, nil
}
// cleanList trims whitespace and drops empties from a target list.
func cleanList(in []string) []string {
out := make([]string, 0, len(in))
for _, v := range in {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
return out
}
+96
View File
@@ -0,0 +1,96 @@
package discourse
import (
"context"
"errors"
"strings"
"testing"
)
func TestSendMessageRequiresTargets(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
_, err := c.SendMessage(context.Background(), SendMessageRequest{Title: "t", Raw: "r"})
if !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("want ErrInvalidRequest without targets, got %v", err)
}
if f.saw("/posts.json") {
t.Fatal("no request should have been sent without targets")
}
}
func TestSendMessageRequiresTitleAndRaw(t *testing.T) {
c, _ := New("https://ok.example", testKey, "")
if _, err := c.SendMessage(context.Background(), SendMessageRequest{Raw: "r", TargetUsernames: []string{"ops"}}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing title: want ErrInvalidRequest, got %v", err)
}
if _, err := c.SendMessage(context.Background(), SendMessageRequest{Title: "t", TargetUsernames: []string{"ops"}}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing raw: want ErrInvalidRequest, got %v", err)
}
}
func TestSendMessage(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
res, err := c.SendMessage(context.Background(), SendMessageRequest{
Title: "Fleet ping",
Raw: "check the briefing",
TargetUsernames: []string{"ops", "reachableceo"},
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
if res.TopicID == 0 || res.TopicSlug == "" {
t.Fatalf("want topic ids in result, got %+v", res)
}
if u := res.URL(f.srv.URL); !strings.Contains(u, "/t/fleet-ping/") {
t.Fatalf("PM URL should be /t/<slug>/<id>, got %q", u)
}
body := f.lastBody
for _, want := range []string{`"title":"Fleet ping"`, `"target_usernames":"ops,reachableceo"`} {
if !strings.Contains(body, want) {
t.Fatalf("wire body missing %s: %s", want, body)
}
}
if strings.Contains(body, `"target_usernames":["`) {
t.Fatalf("targets must be comma-joined, not a JSON array: %s", body)
}
if got := f.pms; len(got) != 1 || got[0].TopicID != res.TopicID || got[0].Title != "Fleet ping" {
t.Fatalf("fake did not record the PM: %+v", got)
}
if got := f.pms[0].Targets; len(got) != 2 || got[0] != "ops" || got[1] != "reachableceo" {
t.Fatalf("recipients not recorded: %+v", got)
}
}
func TestSendMessageGroupAndEmailTargets(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
_, err := c.SendMessage(context.Background(), SendMessageRequest{
Title: "Bulk",
Raw: "hi",
TargetGroupNames: []string{"staff", "admins"},
TargetEmails: []string{"a@x.test"},
})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
for _, want := range []string{`"target_group_names":"staff,admins"`, `"target_emails":"a@x.test"`} {
if !strings.Contains(f.lastBody, want) {
t.Fatalf("wire body missing %s: %s", want, f.lastBody)
}
}
}
func TestSendMessageForbiddenIsTyped(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
_, err := c.SendMessage(context.Background(), SendMessageRequest{
Title: "nope",
Raw: "x",
TargetUsernames: []string{"denied"},
})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("want ErrForbidden for undeliverable recipient, got %v", err)
}
}
+74
View File
@@ -0,0 +1,74 @@
// Search: GET /search.json — full-text search across posts, topics,
// users and categories. The term supports Discourse's search operators
// (@user, #category, order:latest, ...); they pass through untouched.
package discourse
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
)
// SearchRequest is the GET /search.json query. Term is required; Page is
// 1-based (0 means "leave unset" — Discourse then returns page 1).
type SearchRequest struct {
Term string
Page int
}
// SearchPost is one post hit: the match lives in Blurb (a plain-text
// excerpt around the match) while TopicTitleHeadline carries the topic
// title with <em> highlights around matched words.
type SearchPost struct {
ID int `json:"id"`
TopicID int `json:"topic_id"`
PostNumber int `json:"post_number"`
Username string `json:"username"`
Name string `json:"name"`
Blurb string `json:"blurb"`
TopicTitleHeadline string `json:"topic_title_headline"`
CreatedAt string `json:"created_at"`
LikeCount int `json:"like_count"`
}
// SearchTopic is one topic hit.
type SearchTopic struct {
ID int `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
}
// SearchUser is one user hit.
type SearchUser struct {
ID int `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
}
// SearchResult groups the four hit lists Discourse returns; whichever
// classes matched are non-empty, the rest are nil.
type SearchResult struct {
Posts []SearchPost `json:"posts"`
Topics []SearchTopic `json:"topics"`
Users []SearchUser `json:"users"`
Categories []Category `json:"categories"`
}
// Search runs a full-text search and returns the grouped hits.
func (c *Client) Search(ctx context.Context, req SearchRequest) (*SearchResult, error) {
if strings.TrimSpace(req.Term) == "" {
return nil, fmt.Errorf("%w: search term is required", ErrInvalidRequest)
}
q := url.Values{}
q.Set("term", req.Term)
if req.Page > 1 {
q.Set("page", strconv.Itoa(req.Page))
}
var res SearchResult
if err := c.Get(ctx, "/search.json?"+q.Encode(), &res); err != nil {
return nil, err
}
return &res, nil
}
+70
View File
@@ -0,0 +1,70 @@
package discourse
import (
"context"
"errors"
"testing"
)
func TestSearchRequiresTerm(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
_, err := c.Search(context.Background(), SearchRequest{})
if !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("want ErrInvalidRequest for empty term, got %v", err)
}
if f.saw("/search.json") {
t.Fatal("no request should have been sent for an empty term")
}
}
func TestSearchQueriesAndParses(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
res, err := c.Search(context.Background(), SearchRequest{Term: "fleet briefing"})
if err != nil {
t.Fatalf("Search: %v", err)
}
if !f.sawQuery("/search.json", "term=fleet+briefing") {
t.Fatal("search term not sent as the term= query parameter")
}
if len(res.Posts) != 1 || res.Posts[0].TopicID != 12 || res.Posts[0].Username != "reachableceo" {
t.Fatalf("posts not parsed: %+v", res.Posts)
}
if res.Posts[0].Blurb == "" {
t.Fatal("blurb not parsed")
}
if len(res.Topics) != 1 || res.Topics[0].Title != "Fleet briefing 2026-09-01" {
t.Fatalf("topics not parsed: %+v", res.Topics)
}
if len(res.Users) != 1 || res.Users[0].Username != "reachableceo" {
t.Fatalf("users not parsed: %+v", res.Users)
}
if len(res.Categories) != 1 || res.Categories[0].Slug != "mopac-briefings" {
t.Fatalf("categories not parsed: %+v", res.Categories)
}
}
func TestSearchEncodesTermAndPage(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
if _, err := c.Search(context.Background(), SearchRequest{Term: "a b&c=d", Page: 2}); err != nil {
t.Fatalf("Search: %v", err)
}
if !f.sawQuery("/search.json", "term=a+b%26c%3Dd") {
t.Fatal("term not url-encoded (a b&c=d must not smuggle extra params)")
}
if !f.sawQuery("/search.json", "page=2") {
t.Fatal("page not sent as the page= query parameter")
}
}
func TestSearchTypedErrors(t *testing.T) {
f := newFake(t)
c := testClient(t, f)
f.searchFail = 403
_, err := c.Search(context.Background(), SearchRequest{Term: "x"})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("want ErrForbidden, got %v", err)
}
}
+53 -1
View File
@@ -26,6 +26,7 @@ type server struct {
nextCat int
nextPost int
nextT int
pms []map[string]any // recorded private messages, creation order
}
func main() {
@@ -69,7 +70,7 @@ func main() {
name, _ := req["name"].(string)
cat := map[string]any{
"id": s.nextCat, "name": name,
"slug": strings.ToLower(strings.ReplaceAll(name, " ", "-")),
"slug": strings.ToLower(strings.ReplaceAll(name, " ", "-")),
"color": orDefault(req["color"], "3AB54A"), "text_color": orDefault(req["text_color"], "FFFFFF"),
"topic_count": 0,
}
@@ -88,6 +89,23 @@ func main() {
s.nextPost++
if topicID == 0 {
title, _ := req["title"].(string)
if tu, _ := req["target_usernames"].(string); tu != "" || hasAnyKey(req, "target_group_names", "target_emails") {
// private-message creation
if strings.Contains(str(req["target_usernames"]), "denied") {
reply(w, 403, map[string]any{"errors": []string{"You are not permitted to view the requested resource."}})
return
}
tid := s.nextT
s.nextT++
slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
s.pms = append(s.pms, map[string]any{
"topic_id": tid, "title": title,
"target_usernames": req["target_usernames"], "raw": req["raw"],
})
s.posts[pid] = map[string]any{"id": pid, "topic_id": tid, "raw": req["raw"], "post_number": 1}
reply(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug})
return
}
catID, _ := req["category"].(float64)
tid := s.nextT
s.nextT++
@@ -127,6 +145,24 @@ func main() {
case path == "/latest.json" && r.Method == "GET":
reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}})
case path == "/search.json" && r.Method == "GET":
term := strings.ToLower(r.URL.Query().Get("term"))
var topics, posts []map[string]any
for _, t := range s.topics {
if term != "" && strings.Contains(strings.ToLower(str(t["title"])), term) {
topics = append(topics, t)
posts = append(posts, map[string]any{
"id": 900 + t["id"].(int), "topic_id": t["id"], "username": "smoker",
"blurb": "match on " + str(t["title"]),
"topic_title_headline": t["title"],
})
}
}
reply(w, 200, map[string]any{"posts": posts, "topics": topics, "users": []any{}, "categories": []any{}})
case path == "/__pms.json" && r.Method == "GET":
reply(w, 200, map[string]any{"private_messages": s.pms})
default:
reply(w, 404, map[string]any{"errors": []string{"not found on the smoke instance"}})
}
@@ -147,3 +183,19 @@ func orDefault(v any, def string) any {
}
return def
}
// str renders any as a display string (missing keys -> "").
func str(v any) string {
s, _ := v.(string)
return s
}
// hasAnyKey reports whether any of the keys holds a non-empty string.
func hasAnyKey(m map[string]any, keys ...string) bool {
for _, k := range keys {
if str(m[k]) != "" {
return true
}
}
return false
}
+32
View File
@@ -96,6 +96,38 @@ CLI posts update "$POST_ID" -raw "edited body" -reason smoke | tee -a "$capture"
echo "--- raw passthrough"
CLI raw GET /latest.json | tee -a "$capture" | grep -q '"topic_list"' || { echo "smoke: raw GET failed" >&2; exit 1; }
echo "--- search"
CLI search "Smoke Topic" | tee -a "$capture" | grep -q '"Smoke Topic"' || { echo "smoke: search failed" >&2; exit 1; }
echo "--- private message send + delivery record"
CLI messages send -title "Smoke PM" -to ops,reachableceo -raw "pm body" | tee -a "$capture" | grep -q '"topic_slug": "smoke-pm"' || { echo "smoke: PM send failed" >&2; exit 1; }
CLI raw GET /__pms.json | tee -a "$capture" | grep -q '"target_usernames": "ops,reachableceo"' || { echo "smoke: PM recipients not recorded" >&2; exit 1; }
echo "--- webhook verify: good sig exits 0, bad sig exits 2"
WHSEC="smoke-webhook-secret"
printf '{"post":{"id":101}}' > .smoke/hook-body
SIG=$(python3 -c 'import hmac,hashlib,sys; print("sha256="+hmac.new(sys.argv[1].encode(),open(sys.argv[2],"rb").read(),hashlib.sha256).hexdigest())' "$WHSEC" .smoke/hook-body)
( set -a; DISCOURSE_WEBHOOK_SECRET="$WHSEC"; set +a; exec ./bin/discourse-go webhook verify "$SIG" ) < .smoke/hook-body >>"$capture" 2>&1 \
|| { echo "smoke: webhook verify (good sig) failed" >&2; exit 1; }
set +e
( set -a; DISCOURSE_WEBHOOK_SECRET="$WHSEC"; set +a; exec ./bin/discourse-go webhook verify "sha256=deadbeef" ) < .smoke/hook-body >"$PWD/.smoke/badsig" 2>&1
code=$?
set -e
if [ "$code" -ne 2 ]; then echo "smoke: bad webhook sig exit=$code want 2" >&2; exit 1; fi
grep -q "bad signature" "$PWD/.smoke/badsig" || { echo "smoke: bad-signature message missing" >&2; exit 1; }
echo "--- sso verify: decoded payload + tamper rejection"
SSOSEC="smoke-sso-secret"
SSO=$(python3 -c 'import base64; print(base64.b64encode(b"nonce=smoke123&return_sso_url=https%3A%2F%2Fapp.test%2Fcb").decode())')
SSIG=$(python3 -c 'import hmac,hashlib,sys; print(hmac.new(sys.argv[1].encode(),sys.argv[2].encode(),hashlib.sha256).hexdigest())' "$SSOSEC" "$SSO")
( set -a; DISCOURSE_SSO_SECRET="$SSOSEC"; set +a; exec ./bin/discourse-go sso verify "$SSO" "$SSIG" ) | tee -a "$capture" | grep -q '"nonce": "smoke123"' \
|| { echo "smoke: sso verify failed" >&2; exit 1; }
set +e
( set -a; DISCOURSE_SSO_SECRET="$SSOSEC"; set +a; exec ./bin/discourse-go sso verify "$SSO" "deadbeef" ) >/dev/null 2>&1
code=$?
set -e
if [ "$code" -ne 2 ]; then echo "smoke: bad sso sig exit=$code want 2" >&2; exit 1; fi
echo "--- redaction: no key material in any captured output"
if grep -q "$SMOKE_KEY" "$capture"; then
echo "smoke: KEY LEAKED in output" >&2
+1 -1
View File
@@ -31,7 +31,7 @@ type Topic struct {
CategoryID int `json:"category_id"`
CreatedAt string `json:"created_at"`
Closed bool `json:"closed"`
Archived bool `json:"archived"`
Archived bool `json:"archived"`
PostStream struct {
Posts []Post `json:"posts"`
} `json:"post_stream"`
+204
View File
@@ -0,0 +1,204 @@
// Webhook + SSO helpers: the secret-handling side of talking to
// Discourse from a server.
//
// Webhooks: Discourse signs each delivery's raw body with the webhook
// secret as HMAC-SHA256 hex in X-Discourse-Event-Signature (with a
// "sha256=" prefix). Never trust an unsigned or mis-signed delivery —
// the endpoint is internet-facing by nature.
//
// SSO (Discourse Connect): Discourse redirects users to your site with
// ?sso=<base64 payload>&sig=<hex HMAC-SHA256 of that base64 string>
// signed with the shared SSO secret; you answer by redirecting back to
// return_sso_url with a payload signed the same way.
//
// Secrets arrive via environment (DISCOURSE_WEBHOOK_SECRET /
// DISCOURSE_SSO_SECRET, see WebhookSecretFromEnv / SSOSecretFromEnv) —
// never flags, never arguments, never logged, never echoed in errors.
package discourse
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)
// ErrBadSignature: HMAC verification failed (webhook or SSO). The
// payload must be treated as attacker-controlled and dropped.
var ErrBadSignature = errors.New("discourse: bad signature")
// WebhookSecretFromEnv reads DISCOURSE_WEBHOOK_SECRET (trimmed).
func WebhookSecretFromEnv() ([]byte, error) {
return secretFromEnv("DISCOURSE_WEBHOOK_SECRET")
}
// SSOSecretFromEnv reads DISCOURSE_SSO_SECRET (trimmed).
func SSOSecretFromEnv() ([]byte, error) {
return secretFromEnv("DISCOURSE_SSO_SECRET")
}
func secretFromEnv(name string) ([]byte, error) {
v := strings.TrimSpace(os.Getenv(name))
if v == "" {
return nil, fmt.Errorf("%w: %s is not set", ErrInvalidRequest, name)
}
return []byte(v), nil
}
// VerifyWebhook checks a Discourse webhook signature: HMAC-SHA256 hex
// digest of the raw request body under secret, constant-time compared.
// Discourse sends the digest with a "sha256=" prefix; both forms pass.
func VerifyWebhook(secret, body []byte, signature string) error {
sig := strings.TrimPrefix(strings.TrimSpace(signature), "sha256=")
want, err := hex.DecodeString(sig)
if err != nil || len(want) != sha256.Size {
return fmt.Errorf("%w: signature is not a sha256 hex digest", ErrBadSignature)
}
m := hmac.New(sha256.New, secret)
m.Write(body)
if !hmac.Equal(want, m.Sum(nil)) {
return ErrBadSignature
}
return nil
}
// WebhookEvent is one verified webhook delivery.
type WebhookEvent struct {
ID string // X-Discourse-Event-Id (delivery id)
Type string // X-Discourse-Event-Type ("post", "topic", "user", ...)
Event string // X-Discourse-Event ("post_created", ...)
Body json.RawMessage // raw JSON payload, unmodified
}
// ParseWebhook verifies and decodes an incoming webhook request. It
// consumes r.Body, so wrap with http.MaxBytesReader first if the
// endpoint is exposed. Signature failure returns ErrBadSignature.
func ParseWebhook(secret []byte, r *http.Request) (*WebhookEvent, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrUnreachable, err)
}
if err := VerifyWebhook(secret, body, r.Header.Get("X-Discourse-Event-Signature")); err != nil {
return nil, err
}
if !json.Valid(body) {
return nil, fmt.Errorf("%w: payload is not JSON", ErrMalformedResponse)
}
return &WebhookEvent{
ID: r.Header.Get("X-Discourse-Event-Id"),
Type: r.Header.Get("X-Discourse-Event-Type"),
Event: r.Header.Get("X-Discourse-Event"),
Body: json.RawMessage(body),
}, nil
}
// WebhookHandler adapts ParseWebhook to net/http: bad signatures get
// 403 and are never dispatched; verified deliveries go to next, which
// must not panic-write after the handler returns.
func WebhookHandler(secret []byte, next func(*WebhookEvent)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
evt, err := ParseWebhook(secret, r)
if err != nil {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
next(evt)
w.WriteHeader(http.StatusOK)
}
}
// VerifySSO verifies a Discourse Connect (SSO) login redirect: sso is
// the base64 payload, sig the hex HMAC-SHA256 of the base64 string
// (not of the decoded payload). Returns the payload's fields — always
// nonce and return_sso_url.
func VerifySSO(secret []byte, sso, sig string) (url.Values, error) {
sigBytes, err := hex.DecodeString(strings.TrimSpace(sig))
if err != nil || len(sigBytes) != sha256.Size {
return nil, fmt.Errorf("%w: sso signature is not a sha256 hex digest", ErrBadSignature)
}
m := hmac.New(sha256.New, secret)
m.Write([]byte(sso))
if !hmac.Equal(sigBytes, m.Sum(nil)) {
return nil, ErrBadSignature
}
raw, err := base64.StdEncoding.DecodeString(sso)
if err != nil {
return nil, fmt.Errorf("%w: sso payload is not base64: %v", ErrBadSignature, err)
}
vals, err := url.ParseQuery(string(raw))
if err != nil {
return nil, fmt.Errorf("%w: sso payload is not a querystring: %v", ErrBadSignature, err)
}
return vals, nil
}
// BuildSSOResponse builds the signed response payload: nonce (copied
// from the incoming request) plus the identity fields Discourse accepts
// (email, external_id, username, name, avatar_url, admin, moderator,
// add_groups, remove_groups, ...). Returns the base64 payload and its
// hex signature, ready for SSORedirectURL.
func BuildSSOResponse(secret []byte, nonce string, params map[string]string) (string, string, error) {
if strings.TrimSpace(nonce) == "" {
return "", "", fmt.Errorf("%w: sso response requires the request's nonce", ErrInvalidRequest)
}
vals := url.Values{}
vals.Set("nonce", nonce)
for k, v := range params {
if v != "" {
vals.Set(k, v)
}
}
raw := vals.Encode()
b64 := base64.StdEncoding.EncodeToString([]byte(raw))
m := hmac.New(sha256.New, secret)
m.Write([]byte(b64))
return b64, hex.EncodeToString(m.Sum(nil)), nil
}
// SSORedirectURL assembles the return redirect: returnURL with sso and
// sig query parameters appended.
func SSORedirectURL(returnURL, sso, sig string) (string, error) {
u, err := url.Parse(returnURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("%w: return_sso_url %q is not absolute", ErrInvalidRequest, returnURL)
}
q := u.Query()
q.Set("sso", sso)
q.Set("sig", sig)
u.RawQuery = q.Encode()
return u.String(), nil
}
// SSOLogin is the full server-side SSO dance for one request: verify
// the incoming sso/sig pair, build the signed response with the user's
// identity, and return the URL to redirect the user's browser to.
// The context is unused today (pure crypto) but keeps the signature
// future-proof for lookups (e.g. fetching the external user's groups).
func SSOLogin(ctx context.Context, secret []byte, sso, sig string, params map[string]string) (string, error) {
vals, err := VerifySSO(secret, sso, sig)
if err != nil {
return "", err
}
nonce := vals.Get("nonce")
if nonce == "" {
return "", fmt.Errorf("%w: sso payload carries no nonce", ErrBadSignature)
}
returnURL := vals.Get("return_sso_url")
if returnURL == "" {
return "", fmt.Errorf("%w: sso payload carries no return_sso_url", ErrBadSignature)
}
b64, respSig, err := BuildSSOResponse(secret, nonce, params)
if err != nil {
return "", err
}
return SSORedirectURL(returnURL, b64, respSig)
}
+228
View File
@@ -0,0 +1,228 @@
package discourse
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
const testWebhookSecret = "whsec-test-0123456789"
const testSSOSecret = "ssosec-test-0123456789"
func hmacHex(secret string, data []byte) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write(data)
return hex.EncodeToString(m.Sum(nil))
}
// --- webhook payload verification ---
func TestVerifyWebhookGoodSignature(t *testing.T) {
body := []byte(`{"post":{"id":55,"topic_id":12}}`)
sig := hmacHex(testWebhookSecret, body)
if err := VerifyWebhook([]byte(testWebhookSecret), body, sig); err != nil {
t.Fatalf("good sig rejected: %v", err)
}
if err := VerifyWebhook([]byte(testWebhookSecret), body, "sha256="+sig); err != nil {
t.Fatalf("sha256=-prefixed sig rejected: %v", err)
}
}
func TestVerifyWebhookBadSignature(t *testing.T) {
body := []byte(`{"post":{"id":55}}`)
err := VerifyWebhook([]byte(testWebhookSecret), body, "deadbeef")
if !errors.Is(err, ErrBadSignature) {
t.Fatalf("want ErrBadSignature, got %v", err)
}
err = VerifyWebhook([]byte("other-secret"), body, hmacHex(testWebhookSecret, body))
if !errors.Is(err, ErrBadSignature) {
t.Fatalf("wrong secret must fail, got %v", err)
}
if err := VerifyWebhook([]byte(testWebhookSecret), nil, ""); !errors.Is(err, ErrBadSignature) {
t.Fatalf("empty signature must fail, got %v", err)
}
}
func TestParseWebhookVerifiesAndExtracts(t *testing.T) {
body := []byte(`{"post":{"id":55,"post_number":2}}`)
req := httptest.NewRequest(http.MethodPost, "/hooks/discourse", bytes.NewReader(body))
req.Header.Set("X-Discourse-Event-Id", "0d8417a0-1c2b-4d7b")
req.Header.Set("X-Discourse-Event-Type", "post")
req.Header.Set("X-Discourse-Event", "post_created")
req.Header.Set("X-Discourse-Event-Signature", "sha256="+hmacHex(testWebhookSecret, body))
evt, err := ParseWebhook([]byte(testWebhookSecret), req)
if err != nil {
t.Fatalf("ParseWebhook: %v", err)
}
if evt.ID != "0d8417a0-1c2b-4d7b" || evt.Type != "post" || evt.Event != "post_created" {
t.Fatalf("headers not extracted: %+v", evt)
}
var payload struct {
Post struct {
ID int `json:"id"`
PostNumber int `json:"post_number"`
} `json:"post"`
}
if err := json.Unmarshal(evt.Body, &payload); err != nil {
t.Fatalf("body not preserved: %v", err)
}
if payload.Post.ID != 55 || payload.Post.PostNumber != 2 {
t.Fatalf("body mangled: %s", evt.Body)
}
}
func TestWebhookHandlerRejectsAndAccepts(t *testing.T) {
body := []byte(`{"topic":{"id":12}}`)
got := 0
h := WebhookHandler([]byte(testWebhookSecret), func(evt *WebhookEvent) { got++ })
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
r.Header.Set("X-Discourse-Event-Signature", hmacHex("wrong", body))
w := httptest.NewRecorder()
h(w, r)
if w.Code != http.StatusForbidden || got != 0 {
t.Fatalf("bad sig: want 403 + no dispatch, got %d dispatch=%d", w.Code, got)
}
r = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
r.Header.Set("X-Discourse-Event-Signature", "sha256="+hmacHex(testWebhookSecret, body))
r.Header.Set("X-Discourse-Event", "topic_created")
w = httptest.NewRecorder()
h(w, r)
if w.Code != http.StatusOK || got != 1 {
t.Fatalf("good sig: want 200 + dispatch, got %d dispatch=%d", w.Code, got)
}
}
// --- SSO (Discourse Connect) ---
func ssoPayload(t *testing.T, fields string) (string, string) {
t.Helper()
b64 := base64.StdEncoding.EncodeToString([]byte(fields))
return b64, hmacHex(testSSOSecret, []byte(b64))
}
func TestVerifySSO(t *testing.T) {
b64, sig := ssoPayload(t, "nonce=cb68251eefb35f7e&return_sso_url=https%3A%2F%2Fapp.example.test%2Fsso%2Fcallback")
vals, err := VerifySSO([]byte(testSSOSecret), b64, sig)
if err != nil {
t.Fatalf("VerifySSO: %v", err)
}
if vals.Get("nonce") != "cb68251eefb35f7e" {
t.Fatalf("nonce not decoded: %q", vals.Get("nonce"))
}
if vals.Get("return_sso_url") != "https://app.example.test/sso/callback" {
t.Fatalf("return url not decoded: %q", vals.Get("return_sso_url"))
}
}
func TestVerifySSORejectsTampering(t *testing.T) {
b64, sig := ssoPayload(t, "nonce=abc")
if _, err := VerifySSO([]byte(testSSOSecret), b64+"x", sig); !errors.Is(err, ErrBadSignature) {
t.Fatalf("tampered payload must fail, got %v", err)
}
if _, err := VerifySSO([]byte("other"), b64, sig); !errors.Is(err, ErrBadSignature) {
t.Fatalf("wrong secret must fail, got %v", err)
}
if _, err := VerifySSO([]byte(testSSOSecret), "!!not-base64!!", sig); err == nil {
t.Fatal("undecodable payload must fail")
}
if _, err := VerifySSO([]byte(testSSOSecret), "", ""); !errors.Is(err, ErrBadSignature) {
t.Fatalf("empty sso/sig must fail, got %v", err)
}
}
func TestBuildSSOResponseRoundTrip(t *testing.T) {
b64, sig, err := BuildSSOResponse([]byte(testSSOSecret), "cb68251eefb35f7e", map[string]string{
"email": "charles@turnsys.com",
"external_id": "7",
"username": "reachableceo",
"admin": "true",
})
if err != nil {
t.Fatalf("BuildSSOResponse: %v", err)
}
vals, err := VerifySSO([]byte(testSSOSecret), b64, sig)
if err != nil {
t.Fatalf("round-trip verify: %v", err)
}
for k, want := range map[string]string{
"nonce": "cb68251eefb35f7e", "email": "charles@turnsys.com",
"external_id": "7", "username": "reachableceo", "admin": "true",
} {
if got := vals.Get(k); got != want {
t.Fatalf("field %s: want %q got %q", k, want, got)
}
}
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
t.Fatalf("response not std base64: %v", err)
}
}
func TestBuildSSOResponseRequiresNonce(t *testing.T) {
if _, _, err := BuildSSOResponse([]byte(testSSOSecret), "", map[string]string{"email": "x@y.test"}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("empty nonce: want ErrInvalidRequest, got %v", err)
}
}
func TestSSORedirectURL(t *testing.T) {
u, err := SSORedirectURL("https://app.example.test/sso/callback", "c29tZXBheWxvYWQ=", "aabbcc")
if err != nil {
t.Fatalf("SSORedirectURL: %v", err)
}
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.Scheme != "https" || parsed.Host != "app.example.test" {
t.Fatalf("host mangled: %s", u)
}
q := parsed.Query()
if q.Get("sso") != "c29tZXBheWxvYWQ=" || q.Get("sig") != "aabbcc" {
t.Fatalf("query mangled: %s", u)
}
}
// --- secret loading from env ---
func TestSecretsFromEnv(t *testing.T) {
t.Setenv("DISCOURSE_WEBHOOK_SECRET", " hook-secret ")
t.Setenv("DISCOURSE_SSO_SECRET", " sso-secret ")
wh, err := WebhookSecretFromEnv()
if err != nil || string(wh) != "hook-secret" {
t.Fatalf("webhook secret: %q %v", wh, err)
}
sso, err := SSOSecretFromEnv()
if err != nil || string(sso) != "sso-secret" {
t.Fatalf("sso secret: %q %v", sso, err)
}
}
func TestSecretsFromEnvMissing(t *testing.T) {
t.Setenv("DISCOURSE_WEBHOOK_SECRET", "")
t.Setenv("DISCOURSE_SSO_SECRET", "")
if _, err := WebhookSecretFromEnv(); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing webhook secret: want ErrInvalidRequest, got %v", err)
}
if _, err := SSOSecretFromEnv(); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing sso secret: want ErrInvalidRequest, got %v", err)
}
}
func TestWebhookSecretNeverLeaksInError(t *testing.T) {
body := []byte(`{}`)
err := VerifyWebhook([]byte(testWebhookSecret), body, "bad")
if strings.Contains(err.Error(), testWebhookSecret) {
t.Fatalf("secret leaked in error: %v", err)
}
}