Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb96849641 | ||
|
|
445df7a836 | ||
|
|
f13302778b | ||
|
|
5b95ac3c7e | ||
|
|
5bbb307b12 | ||
|
|
6891f3febc | ||
|
|
14cdaa0a2d | ||
|
|
f51ca5900c |
@@ -9,10 +9,12 @@ land). AGPLv3.
|
|||||||
Status: 2026-08-29 — v0 complete and green: categories (list/create),
|
Status: 2026-08-29 — v0 complete and green: categories (list/create),
|
||||||
topics (create/list/latest/get), posts (create/update/get), current-user
|
topics (create/list/latest/get), posts (create/update/get), current-user
|
||||||
identity probe, raw JSON passthrough for everything else, typed error
|
identity probe, raw JSON passthrough for everything else, typed error
|
||||||
classes, thin CLI. Built and tested entirely against a fake Discourse
|
classes, thin CLI. Same-day additions (Redmine 507): full-text search,
|
||||||
(no live writes). Live reads verified against community.turnsys.com;
|
private-message send, and the webhook + SSO (Discourse Connect) secret
|
||||||
category creation 403s with the current key (needs the admin-scoped key)
|
helpers. Built and tested entirely against a fake Discourse (no live
|
||||||
— see "Live verification" below.
|
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
|
## What it implements
|
||||||
|
|
||||||
@@ -30,6 +32,10 @@ The wire protocol, in plain REST with stdlib:
|
|||||||
| posts create | `POST /posts.json` — `topic_id`+`raw` |
|
| posts create | `POST /posts.json` — `topic_id`+`raw` |
|
||||||
| posts update | `PUT /posts/<id>.json` — `{post: {raw, edit_reason}}` |
|
| posts update | `PUT /posts/<id>.json` — `{post: {raw, edit_reason}}` |
|
||||||
| posts get | `GET /posts/<id>.json` |
|
| 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 |
|
| 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.
|
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"})
|
p, _ := c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: res.TopicID, Raw: "reply"})
|
||||||
_, _ = c.UpdatePost(ctx, p.PostID, "edited", "typo")
|
_, _ = 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),
|
Errors classify via `errors.Is`: `ErrForbidden` (403 — key lacks scope),
|
||||||
`ErrUnauthorized` (401), `ErrNotFound`, `ErrRateLimited` (429, with
|
`ErrUnauthorized` (401), `ErrNotFound`, `ErrRateLimited` (429, with
|
||||||
`APIError.RetryAfter`), `ErrServer`, `ErrUnreachable`,
|
`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
|
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 /
|
through a 0600 env file (whoami / category list+create / topic create /
|
||||||
topic list by id and slug / post reply / post update / raw passthrough /
|
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
|
```sh
|
||||||
./dev.sh smoke
|
./dev.sh smoke
|
||||||
```
|
```
|
||||||
@@ -99,7 +135,8 @@ Credentials NEVER arrive via flags or arguments:
|
|||||||
```sh
|
```sh
|
||||||
mkdir -p ~/.config/discourse-go && umask 077
|
mkdir -p ~/.config/discourse-go && umask 077
|
||||||
cp env.example ~/.config/discourse-go/env
|
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
|
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
|
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 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...] [-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]
|
discourse-go raw METHOD PATH [-data JSON|@file]
|
||||||
```
|
```
|
||||||
Perm levels: 1 = reply/see, 2 = create posts, 3 = full. Output is JSON.
|
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
|
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)
|
## Live verification (community.turnsys.com, 2026-08-29)
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -28,13 +28,13 @@ type Category struct {
|
|||||||
// means "default: everyone full" — for restricted categories set it
|
// means "default: everyone full" — for restricted categories set it
|
||||||
// explicitly, e.g. {"staff": 3, "trust_level_0": 1}.
|
// explicitly, e.g. {"staff": 3, "trust_level_0": 1}.
|
||||||
type CreateCategoryRequest struct {
|
type CreateCategoryRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Color string `json:"color"` // 6 hex digits, no '#'
|
Color string `json:"color"` // 6 hex digits, no '#'
|
||||||
TextColor string `json:"text_color"` // 6 hex digits, no '#'
|
TextColor string `json:"text_color"` // 6 hex digits, no '#'
|
||||||
Permissions map[string]int `json:"permissions,omitempty"`
|
Permissions map[string]int `json:"permissions,omitempty"`
|
||||||
Position int `json:"position,omitempty"`
|
Position int `json:"position,omitempty"`
|
||||||
ParentCategoryID int `json:"parent_category_id,omitempty"`
|
ParentCategoryID int `json:"parent_category_id,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCategories returns the instance's categories (one request; the
|
// ListCategories returns the instance's categories (one request; the
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+9
-4
@@ -117,11 +117,16 @@ func New(baseURL, apiKey, apiUsername string) (*Client, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFromEnv builds a client from DISCOURSE_URL, DISCOURSE_API_KEY and
|
// NewFromEnv builds a client from DISCOURSE_URL, the API key
|
||||||
// DISCOURSE_API_USERNAME (default "system"). The intended source is a
|
// (DISCOURSE_API_KEY, falling back to the shorter DISCOURSE_KEY alias)
|
||||||
// 0600 env file (see env.example), sourced before the process starts.
|
// 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) {
|
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.
|
// String renders the client for logs: base url + acting username only.
|
||||||
|
|||||||
+137
-15
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,15 +19,19 @@ const testKey = "test-api-key-0123456789"
|
|||||||
// enforces the Api-Key/Api-Username headers, serves the endpoints the
|
// enforces the Api-Key/Api-Username headers, serves the endpoints the
|
||||||
// client covers, and records every request for assertions.
|
// client covers, and records every request for assertions.
|
||||||
type fakeDiscourse struct {
|
type fakeDiscourse struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
requests []string // "METHOD path"
|
requests []string // "METHOD path"
|
||||||
cats map[int]Category
|
uris []string // "METHOD path?query" — for query assertions
|
||||||
nextCat int
|
cats map[int]Category
|
||||||
posts map[int]postRec // post id -> record
|
nextCat int
|
||||||
nextPost int
|
posts map[int]postRec // post id -> record
|
||||||
topics map[int]topicRec
|
nextPost int
|
||||||
nextT int
|
topics map[int]topicRec
|
||||||
srv *httptest.Server
|
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 {
|
type postRec struct {
|
||||||
@@ -41,6 +46,13 @@ type topicRec struct {
|
|||||||
Cat int
|
Cat int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pmRec struct {
|
||||||
|
TopicID int
|
||||||
|
Title string
|
||||||
|
Targets []string
|
||||||
|
Raw string
|
||||||
|
}
|
||||||
|
|
||||||
func newFake(t *testing.T) *fakeDiscourse {
|
func newFake(t *testing.T) *fakeDiscourse {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
f := &fakeDiscourse{
|
f := &fakeDiscourse{
|
||||||
@@ -82,15 +94,15 @@ func newFake(t *testing.T) *fakeDiscourse {
|
|||||||
}
|
}
|
||||||
if req.Name == "denied" { // simulate a non-admin key
|
if req.Name == "denied" { // simulate a non-admin key
|
||||||
writeJSON(w, 403, map[string]any{
|
writeJSON(w, 403, map[string]any{
|
||||||
"errors": []string{"You are not permitted to view the requested resource."},
|
"errors": []string{"You are not permitted to view the requested resource."},
|
||||||
"error_description": "Access denied",
|
"error_description": "Access denied",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
cat := Category{
|
cat := Category{
|
||||||
ID: f.nextCat, Name: req.Name,
|
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,
|
Color: req.Color, TextColor: req.TextColor, Position: req.Position,
|
||||||
}
|
}
|
||||||
f.nextCat++
|
f.nextCat++
|
||||||
@@ -109,13 +121,47 @@ func newFake(t *testing.T) *fakeDiscourse {
|
|||||||
writeJSON(w, 405, nil)
|
writeJSON(w, 405, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req CreatePostRequest
|
body, err := io.ReadAll(r.Body)
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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"}})
|
writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
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 req.TopicID == 0 { // topic creation
|
||||||
if strings.TrimSpace(req.Title) == "" || req.Category == 0 {
|
if strings.TrimSpace(req.Title) == "" || req.Category == 0 {
|
||||||
writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}})
|
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:
|
case http.MethodPut:
|
||||||
var body struct {
|
var body struct {
|
||||||
Post struct {
|
Post struct {
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
EditReason string `json:"edit_reason"`
|
EditReason string `json:"edit_reason"`
|
||||||
} `json:"post"`
|
} `json:"post"`
|
||||||
}
|
}
|
||||||
@@ -179,6 +225,26 @@ func newFake(t *testing.T) *fakeDiscourse {
|
|||||||
writeJSON(w, 405, nil)
|
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) {
|
mux.HandleFunc("/latest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !f.auth(w, r) {
|
if !f.auth(w, r) {
|
||||||
return
|
return
|
||||||
@@ -203,6 +269,7 @@ func newFake(t *testing.T) *fakeDiscourse {
|
|||||||
func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool {
|
func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
||||||
|
f.uris = append(f.uris, r.Method+" "+r.URL.RequestURI())
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if r.Header.Get("Api-Key") != testKey || r.Header.Get("Api-Username") != "system" {
|
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"}})
|
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
|
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) {
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
_ = json.NewEncoder(w).Encode(body)
|
_ = 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 {
|
func testClient(t *testing.T, f *fakeDiscourse) *Client {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := New(f.srv.URL, testKey, "")
|
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) {
|
func TestCurrentUser(t *testing.T) {
|
||||||
f := newFake(t)
|
f := newFake(t)
|
||||||
c := testClient(t, f)
|
c := testClient(t, f)
|
||||||
|
|||||||
@@ -7,8 +7,17 @@ DISCOURSE_URL=https://community.turnsys.com
|
|||||||
|
|
||||||
# Discourse API key (Admin API > Keys, or a user API key). Creating
|
# Discourse API key (Admin API > Keys, or a user API key). Creating
|
||||||
# categories requires an admin-scoped key; a standard key 403s there.
|
# 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
|
DISCOURSE_API_KEY=replace-me
|
||||||
|
|
||||||
# The user the key acts as (must match the key's allowed username, or be
|
# The user the key acts as (must match the key's allowed username, or be
|
||||||
# "system" for global keys).
|
# "system" for global keys).
|
||||||
DISCOURSE_API_USERNAME=reachableceo
|
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
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ type server struct {
|
|||||||
nextCat int
|
nextCat int
|
||||||
nextPost int
|
nextPost int
|
||||||
nextT int
|
nextT int
|
||||||
|
pms []map[string]any // recorded private messages, creation order
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -69,7 +70,7 @@ func main() {
|
|||||||
name, _ := req["name"].(string)
|
name, _ := req["name"].(string)
|
||||||
cat := map[string]any{
|
cat := map[string]any{
|
||||||
"id": s.nextCat, "name": name,
|
"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"),
|
"color": orDefault(req["color"], "3AB54A"), "text_color": orDefault(req["text_color"], "FFFFFF"),
|
||||||
"topic_count": 0,
|
"topic_count": 0,
|
||||||
}
|
}
|
||||||
@@ -88,6 +89,23 @@ func main() {
|
|||||||
s.nextPost++
|
s.nextPost++
|
||||||
if topicID == 0 {
|
if topicID == 0 {
|
||||||
title, _ := req["title"].(string)
|
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)
|
catID, _ := req["category"].(float64)
|
||||||
tid := s.nextT
|
tid := s.nextT
|
||||||
s.nextT++
|
s.nextT++
|
||||||
@@ -127,6 +145,24 @@ func main() {
|
|||||||
case path == "/latest.json" && r.Method == "GET":
|
case path == "/latest.json" && r.Method == "GET":
|
||||||
reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}})
|
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:
|
default:
|
||||||
reply(w, 404, map[string]any{"errors": []string{"not found on the smoke instance"}})
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,38 @@ CLI posts update "$POST_ID" -raw "edited body" -reason smoke | tee -a "$capture"
|
|||||||
echo "--- raw passthrough"
|
echo "--- raw passthrough"
|
||||||
CLI raw GET /latest.json | tee -a "$capture" | grep -q '"topic_list"' || { echo "smoke: raw GET failed" >&2; exit 1; }
|
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"
|
echo "--- redaction: no key material in any captured output"
|
||||||
if grep -q "$SMOKE_KEY" "$capture"; then
|
if grep -q "$SMOKE_KEY" "$capture"; then
|
||||||
echo "smoke: KEY LEAKED in output" >&2
|
echo "smoke: KEY LEAKED in output" >&2
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type Topic struct {
|
|||||||
CategoryID int `json:"category_id"`
|
CategoryID int `json:"category_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Closed bool `json:"closed"`
|
Closed bool `json:"closed"`
|
||||||
Archived bool `json:"archived"`
|
Archived bool `json:"archived"`
|
||||||
PostStream struct {
|
PostStream struct {
|
||||||
Posts []Post `json:"posts"`
|
Posts []Post `json:"posts"`
|
||||||
} `json:"post_stream"`
|
} `json:"post_stream"`
|
||||||
|
|||||||
+204
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user