From 642c1878cc532b87bf94a4237f9ec45b75a31c7d Mon Sep 17 00:00:00 2001 From: Yanislav Igonin Date: Wed, 22 Jul 2026 14:08:38 +0400 Subject: [PATCH] feat: register with gateway via board token upsert Co-authored-by: Cursor --- .env.example | 5 ++-- AGENTS.md | 4 ++-- README.md | 7 +++--- config/config.go | 40 +++++++++++++++++++------------- config/config_test.go | 46 +++++++++++++++++++++++++++++++++++-- gateway/connect.go | 33 ++++++++++++-------------- gateway/controllers.go | 14 +---------- gateway/controllers_test.go | 24 +++++++++---------- 8 files changed, 102 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index 61055a3..77d2702 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/AGENTS.md b/AGENTS.md index ad73f9b..1ffb670 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 6c4c766..7a3b1ef 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/config.go b/config/config.go index 45fcb40..ce7dc8f 100644 --- a/config/config.go +++ b/config/config.go @@ -8,11 +8,10 @@ import ( ) type GatewayConfig struct { - Url string - ApiKey string - BoardId string - BoardUrl string - BoardDescription string + Url string + Token string + BoardUrl 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, diff --git a/config/config_test.go b/config/config_test.go index ad13382..83b173a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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() +} diff --git a/gateway/connect.go b/gateway/connect.go index 87b5fc6..8255e05 100644 --- a/gateway/connect.go +++ b/gateway/connect.go @@ -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") } diff --git a/gateway/controllers.go b/gateway/controllers.go index bf1ed9a..990d798 100644 --- a/gateway/controllers.go +++ b/gateway/controllers.go @@ -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}) } diff --git a/gateway/controllers_test.go b/gateway/controllers_test.go index 3cf82d5..a00067d 100644 --- a/gateway/controllers_test.go +++ b/gateway/controllers_test.go @@ -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) } }