mirror of
https://github.com/yanislav-igonin/micrach
synced 2026-07-27 05:14:17 +03:00
feat: register with gateway via board token upsert
All checks were successful
CI / verify (push) Successful in 11m7s
All checks were successful
CI / verify (push) Successful in 11m7s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b2fe7fa038
commit
642c1878cc
@ -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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user