feat: register with gateway via board token upsert
All checks were successful
CI / verify (push) Successful in 11m7s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yanislav Igonin 2026-07-22 14:08:38 +04:00
parent b2fe7fa038
commit 642c1878cc
8 changed files with 102 additions and 71 deletions

View File

@ -7,7 +7,6 @@ POSTGRES_URL=postgres://micrach:micrach@localhost:5432/micrach?pool_max_conns=5
THREAD_BUMP_LIMIT=500
IS_CAPTCHA_ACTIVE=true
GATEWAY_URL=
GATEWAY_API_KEY=
GATEWAY_BOARD_ID=
GATEWAY_BOARD_TOKEN=
GATEWAY_BOARD_URL=
GATEWAY_BOARD_DESCRIPTION=
GATEWAY_BOARD_NAME=

View File

@ -44,7 +44,7 @@ creates `uploads/`, then starts Fiber.
| `GET` | `/:threadID` | Render one thread and its replies. |
| `POST` | `/:threadID` | Validate and add a reply. |
| `GET` | `/captcha/:captchaID` | Render CAPTCHA PNG. |
| `GET` | `/api/ping` | Gateway health endpoint; registered only when `GATEWAY_URL` is set. |
| `GET` | `/api/ping` | Public gateway health endpoint (`{"ok":true}`); registered only when `GATEWAY_URL` is set. |
Static content is served from `/static`; generated files are served from
`/uploads`. Thread and reply creation spans PostgreSQL plus filesystem writes,
@ -85,7 +85,7 @@ the load test as routine verification.
- `IS_DB_SEEDED=true` adds sample data on every startup, not only once.
- `ENV=production` renames CSS files and rewrites templates in place at startup.
Do not use this mode in a development checkout unless that mutation is wanted.
- `GATEWAY_URL=""` disables gateway routes and registration.
- `GATEWAY_URL=""` disables gateway routes and registration. When set, `GATEWAY_BOARD_TOKEN`, `GATEWAY_BOARD_URL`, and `GATEWAY_BOARD_NAME` are required at startup; registration upserts via `POST /api/boards/me` and connect failures are logged, not fatal.
- Uploads are limited to four JPEG/PNG files, 3 MiB each. Titles allow 100
runes; post text allows 1,000 runes.
- Runtime uploads, built binaries, `.env`, and load-test artifacts are ignored

View File

@ -81,10 +81,9 @@ the service in a container.
| `THREAD_BUMP_LIMIT` | `500` | Reply count after which a thread no longer bumps. |
| `IS_CAPTCHA_ACTIVE` | `true` | Require CAPTCHA for new threads and replies. |
| `GATEWAY_URL` | empty | External gateway base URL. Empty disables gateway integration. |
| `GATEWAY_API_KEY` | empty | Shared key used for gateway registration and `/api/ping`. |
| `GATEWAY_BOARD_ID` | empty | Board identifier sent to the gateway. |
| `GATEWAY_BOARD_URL` | empty | Parsed from the environment but not currently used by the gateway client. |
| `GATEWAY_BOARD_DESCRIPTION` | empty | Board description sent to the gateway. |
| `GATEWAY_BOARD_TOKEN` | empty | Bearer token from the gateway admin UI. Required when `GATEWAY_URL` is set. |
| `GATEWAY_BOARD_URL` | empty | Public URL of this board (e.g. `http://localhost:3000`). Required when `GATEWAY_URL` is set. |
| `GATEWAY_BOARD_NAME` | empty | Display name sent to the gateway catalog. Required when `GATEWAY_URL` is set. |
See [.env.example](.env.example) for a complete example.

View File

@ -9,10 +9,9 @@ import (
type GatewayConfig struct {
Url string
ApiKey string
BoardId string
Token string
BoardUrl string
BoardDescription string
BoardName string
}
type AppConfig struct {
@ -56,18 +55,26 @@ func getValueOrDefaultString(value string, defaultValue string) string {
}
func getGatewayConfig() GatewayConfig {
url := os.Getenv("GATEWAY_URL")
apiKey := os.Getenv("GATEWAY_API_KEY")
boardId := os.Getenv("GATEWAY_BOARD_ID")
description := os.Getenv("GATEWAY_BOARD_DESCRIPTION")
boardUrl := os.Getenv("GATEWAY_BOARD_URL")
return GatewayConfig{
Url: url,
ApiKey: apiKey,
BoardId: boardId,
BoardUrl: boardUrl,
BoardDescription: description,
Url: os.Getenv("GATEWAY_URL"),
Token: os.Getenv("GATEWAY_BOARD_TOKEN"),
BoardUrl: os.Getenv("GATEWAY_BOARD_URL"),
BoardName: os.Getenv("GATEWAY_BOARD_NAME"),
}
}
func validateGatewayConfig(gateway GatewayConfig) {
if gateway.Url == "" {
return
}
if gateway.Token == "" {
log.Panicln("GATEWAY_BOARD_TOKEN is required when GATEWAY_URL is set")
}
if gateway.BoardUrl == "" {
log.Panicln("GATEWAY_BOARD_URL is required when GATEWAY_URL is set")
}
if gateway.BoardName == "" {
log.Panicln("GATEWAY_BOARD_NAME is required when GATEWAY_URL is set")
}
}
@ -80,6 +87,7 @@ func getAppConfig() AppConfig {
threadBumpLimit := getValueOrDefaultInt(os.Getenv("THREAD_BUMP_LIMIT"), 500)
isCaptchaActive := getValueOrDefaultBoolean(os.Getenv("IS_CAPTCHA_ACTIVE"), true)
gateway := getGatewayConfig()
validateGatewayConfig(gateway)
return AppConfig{
Env: env,

View File

@ -48,8 +48,8 @@ func TestDefaultConfigs(t *testing.T) {
for _, key := range []string{
"ENV", "PORT", "IS_DB_SEEDED", "IS_RATE_LIMITER_ENABLED",
"THREADS_MAX_COUNT", "THREAD_BUMP_LIMIT", "IS_CAPTCHA_ACTIVE",
"GATEWAY_URL", "GATEWAY_API_KEY", "GATEWAY_BOARD_ID",
"GATEWAY_BOARD_URL", "GATEWAY_BOARD_DESCRIPTION", "POSTGRES_URL",
"GATEWAY_URL", "GATEWAY_BOARD_TOKEN", "GATEWAY_BOARD_URL",
"GATEWAY_BOARD_NAME", "POSTGRES_URL",
} {
t.Setenv(key, "")
}
@ -65,3 +65,45 @@ func TestDefaultConfigs(t *testing.T) {
t.Fatalf("unexpected database default: %+v", db)
}
}
func TestGatewayConfigRequiresTokenWhenUrlSet(t *testing.T) {
t.Setenv("GATEWAY_URL", "http://gateway:3001")
t.Setenv("GATEWAY_BOARD_TOKEN", "")
t.Setenv("GATEWAY_BOARD_URL", "http://board:3000")
t.Setenv("GATEWAY_BOARD_NAME", "testboard")
defer func() {
if recover() == nil {
t.Fatal("expected panic without GATEWAY_BOARD_TOKEN")
}
}()
getAppConfig()
}
func TestGatewayConfigRequiresBoardUrlWhenUrlSet(t *testing.T) {
t.Setenv("GATEWAY_URL", "http://gateway:3001")
t.Setenv("GATEWAY_BOARD_TOKEN", "secret-token")
t.Setenv("GATEWAY_BOARD_URL", "")
t.Setenv("GATEWAY_BOARD_NAME", "testboard")
defer func() {
if recover() == nil {
t.Fatal("expected panic without GATEWAY_BOARD_URL")
}
}()
getAppConfig()
}
func TestGatewayConfigRequiresBoardNameWhenUrlSet(t *testing.T) {
t.Setenv("GATEWAY_URL", "http://gateway:3001")
t.Setenv("GATEWAY_BOARD_TOKEN", "secret-token")
t.Setenv("GATEWAY_BOARD_URL", "http://board:3000")
t.Setenv("GATEWAY_BOARD_NAME", "")
defer func() {
if recover() == nil {
t.Fatal("expected panic without GATEWAY_BOARD_NAME")
}
}()
getAppConfig()
}

View File

@ -7,34 +7,31 @@ import (
"log"
"net/http"
Config "micrach/config"
"micrach/config"
)
// Make http request to gateway to tell the board id and description
func Connect() {
requestBody, _ := json.Marshal(map[string]string{
"id": Config.App.Gateway.BoardId,
"name": Config.App.Gateway.BoardDescription,
"url": Config.App.Gateway.Url,
body, _ := json.Marshal(map[string]string{
"name": config.App.Gateway.BoardName,
"url": config.App.Gateway.BoardUrl,
})
url := Config.App.Gateway.Url + "/api/boards"
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
req, err := http.NewRequest(http.MethodPost, config.App.Gateway.Url+"/api/boards/me", bytes.NewReader(body))
if err != nil {
log.Panicln(err)
log.Println("gateway - connect error:", err)
return
}
req.Header.Set("Authorization", Config.App.Gateway.ApiKey)
req.Header.Set("Authorization", "Bearer "+config.App.Gateway.Token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Panicln(err)
log.Println("gateway - connect error:", err)
return
}
defer resp.Body.Close()
//We Read the response body on the line below.
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Panicln(err)
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
log.Println("gateway - connect failed:", resp.StatusCode, string(b))
return
}
log.Println(string(body))
log.Println("gateway - online")
}

View File

@ -1,21 +1,9 @@
package gateway
import (
"micrach/config"
"github.com/gofiber/fiber/v2"
)
func Ping(c *fiber.Ctx) error {
headerValues := c.GetReqHeaders()["Authorization"]
headerKey := ""
if len(headerValues) > 0 {
headerKey = headerValues[len(headerValues)-1]
}
if config.App.Gateway.ApiKey != headerKey {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
}
return c.JSON(fiber.Map{
"message": "pong",
})
return c.JSON(fiber.Map{"ok": true})
}

View File

@ -1,33 +1,31 @@
package gateway
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"micrach/config"
)
func TestPingUsesLastAuthorizationHeader(t *testing.T) {
previousConfig := config.App
t.Cleanup(func() {
config.App = previousConfig
})
config.App.Gateway.ApiKey = "configured-key"
func TestPingIsPublic(t *testing.T) {
app := fiber.New()
app.Get("/api/ping", Ping)
req := httptest.NewRequest("GET", "/api/ping", nil)
req.Header.Add("Authorization", "wrong-key")
req.Header.Add("Authorization", "configured-key")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("test ping request: %v", err)
}
if resp.StatusCode != fiber.StatusOK {
t.Fatalf("expected status %d when final Authorization header matches, got %d", fiber.StatusOK, resp.StatusCode)
t.Fatalf("expected status %d, got %d", fiber.StatusOK, resp.StatusCode)
}
var body map[string]bool
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if !body["ok"] {
t.Fatalf("expected ok=true, got %+v", body)
}
}