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 THREAD_BUMP_LIMIT=500
IS_CAPTCHA_ACTIVE=true IS_CAPTCHA_ACTIVE=true
GATEWAY_URL= GATEWAY_URL=
GATEWAY_API_KEY= GATEWAY_BOARD_TOKEN=
GATEWAY_BOARD_ID=
GATEWAY_BOARD_URL= 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. | | `GET` | `/:threadID` | Render one thread and its replies. |
| `POST` | `/:threadID` | Validate and add a reply. | | `POST` | `/:threadID` | Validate and add a reply. |
| `GET` | `/captcha/:captchaID` | Render CAPTCHA PNG. | | `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 Static content is served from `/static`; generated files are served from
`/uploads`. Thread and reply creation spans PostgreSQL plus filesystem writes, `/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. - `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. - `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. 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 - Uploads are limited to four JPEG/PNG files, 3 MiB each. Titles allow 100
runes; post text allows 1,000 runes. runes; post text allows 1,000 runes.
- Runtime uploads, built binaries, `.env`, and load-test artifacts are ignored - 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. | | `THREAD_BUMP_LIMIT` | `500` | Reply count after which a thread no longer bumps. |
| `IS_CAPTCHA_ACTIVE` | `true` | Require CAPTCHA for new threads and replies. | | `IS_CAPTCHA_ACTIVE` | `true` | Require CAPTCHA for new threads and replies. |
| `GATEWAY_URL` | empty | External gateway base URL. Empty disables gateway integration. | | `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_TOKEN` | empty | Bearer token from the gateway admin UI. Required when `GATEWAY_URL` is set. |
| `GATEWAY_BOARD_ID` | empty | Board identifier sent to the gateway. | | `GATEWAY_BOARD_URL` | empty | Public URL of this board (e.g. `http://localhost:3000`). Required when `GATEWAY_URL` is set. |
| `GATEWAY_BOARD_URL` | empty | Parsed from the environment but not currently used by the gateway client. | | `GATEWAY_BOARD_NAME` | empty | Display name sent to the gateway catalog. Required when `GATEWAY_URL` is set. |
| `GATEWAY_BOARD_DESCRIPTION` | empty | Board description sent to the gateway. |
See [.env.example](.env.example) for a complete example. See [.env.example](.env.example) for a complete example.

View File

@ -9,10 +9,9 @@ import (
type GatewayConfig struct { type GatewayConfig struct {
Url string Url string
ApiKey string Token string
BoardId string
BoardUrl string BoardUrl string
BoardDescription string BoardName string
} }
type AppConfig struct { type AppConfig struct {
@ -56,18 +55,26 @@ func getValueOrDefaultString(value string, defaultValue string) string {
} }
func getGatewayConfig() GatewayConfig { 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{ return GatewayConfig{
Url: url, Url: os.Getenv("GATEWAY_URL"),
ApiKey: apiKey, Token: os.Getenv("GATEWAY_BOARD_TOKEN"),
BoardId: boardId, BoardUrl: os.Getenv("GATEWAY_BOARD_URL"),
BoardUrl: boardUrl, BoardName: os.Getenv("GATEWAY_BOARD_NAME"),
BoardDescription: description, }
}
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) threadBumpLimit := getValueOrDefaultInt(os.Getenv("THREAD_BUMP_LIMIT"), 500)
isCaptchaActive := getValueOrDefaultBoolean(os.Getenv("IS_CAPTCHA_ACTIVE"), true) isCaptchaActive := getValueOrDefaultBoolean(os.Getenv("IS_CAPTCHA_ACTIVE"), true)
gateway := getGatewayConfig() gateway := getGatewayConfig()
validateGatewayConfig(gateway)
return AppConfig{ return AppConfig{
Env: env, Env: env,

View File

@ -48,8 +48,8 @@ func TestDefaultConfigs(t *testing.T) {
for _, key := range []string{ for _, key := range []string{
"ENV", "PORT", "IS_DB_SEEDED", "IS_RATE_LIMITER_ENABLED", "ENV", "PORT", "IS_DB_SEEDED", "IS_RATE_LIMITER_ENABLED",
"THREADS_MAX_COUNT", "THREAD_BUMP_LIMIT", "IS_CAPTCHA_ACTIVE", "THREADS_MAX_COUNT", "THREAD_BUMP_LIMIT", "IS_CAPTCHA_ACTIVE",
"GATEWAY_URL", "GATEWAY_API_KEY", "GATEWAY_BOARD_ID", "GATEWAY_URL", "GATEWAY_BOARD_TOKEN", "GATEWAY_BOARD_URL",
"GATEWAY_BOARD_URL", "GATEWAY_BOARD_DESCRIPTION", "POSTGRES_URL", "GATEWAY_BOARD_NAME", "POSTGRES_URL",
} { } {
t.Setenv(key, "") t.Setenv(key, "")
} }
@ -65,3 +65,45 @@ func TestDefaultConfigs(t *testing.T) {
t.Fatalf("unexpected database default: %+v", db) 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" "log"
"net/http" "net/http"
Config "micrach/config" "micrach/config"
) )
// Make http request to gateway to tell the board id and description
func Connect() { func Connect() {
requestBody, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"id": Config.App.Gateway.BoardId, "name": config.App.Gateway.BoardName,
"name": Config.App.Gateway.BoardDescription, "url": config.App.Gateway.BoardUrl,
"url": Config.App.Gateway.Url,
}) })
url := Config.App.Gateway.Url + "/api/boards" req, err := http.NewRequest(http.MethodPost, config.App.Gateway.Url+"/api/boards/me", bytes.NewReader(body))
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
if err != nil { 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") req.Header.Set("Content-Type", "application/json")
client := &http.Client{} resp, err := http.DefaultClient.Do(req)
resp, err := client.Do(req)
if err != nil { if err != nil {
log.Panicln(err) log.Println("gateway - connect error:", err)
return
} }
defer resp.Body.Close() defer resp.Body.Close()
//We Read the response body on the line below. if resp.StatusCode >= 300 {
body, err := io.ReadAll(resp.Body) b, _ := io.ReadAll(resp.Body)
if err != nil { log.Println("gateway - connect failed:", resp.StatusCode, string(b))
log.Panicln(err) return
} }
log.Println(string(body))
log.Println("gateway - online") log.Println("gateway - online")
} }

View File

@ -1,21 +1,9 @@
package gateway package gateway
import ( import (
"micrach/config"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func Ping(c *fiber.Ctx) error { func Ping(c *fiber.Ctx) error {
headerValues := c.GetReqHeaders()["Authorization"] return c.JSON(fiber.Map{"ok": true})
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",
})
} }

View File

@ -1,33 +1,31 @@
package gateway package gateway
import ( import (
"encoding/json"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"micrach/config"
) )
func TestPingUsesLastAuthorizationHeader(t *testing.T) { func TestPingIsPublic(t *testing.T) {
previousConfig := config.App
t.Cleanup(func() {
config.App = previousConfig
})
config.App.Gateway.ApiKey = "configured-key"
app := fiber.New() app := fiber.New()
app.Get("/api/ping", Ping) app.Get("/api/ping", Ping)
req := httptest.NewRequest("GET", "/api/ping", nil) req := httptest.NewRequest("GET", "/api/ping", nil)
req.Header.Add("Authorization", "wrong-key")
req.Header.Add("Authorization", "configured-key")
resp, err := app.Test(req) resp, err := app.Test(req)
if err != nil { if err != nil {
t.Fatalf("test ping request: %v", err) t.Fatalf("test ping request: %v", err)
} }
if resp.StatusCode != fiber.StatusOK { 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)
} }
} }