feat(server): скелет Go-сервера с healthz, readyz и api/v1/meta
- internal/config: конфигурация из env с валидацией и дефолтами (§5.1, §9.1) - internal/httpx: middleware (request id, slog-логи, recover, security-заголовки), единый формат ошибок §8.5 и лимит тела запроса - internal/database: SQLite WAL + busy_timeout + foreign_keys, один writer и пул чтения, goose-миграции embedded, PRAGMA optimize по расписанию - internal/meta: полезная нагрузка /api/v1/meta (версия, фичи, лимиты) - internal/server: маршруты, SPA-раздача собранного клиента, OpenAPI 3.1 - cmd/glchat: точка входа с graceful shutdown и JSON-логами - тесты: конфиг, миграции и идемпотентность, HTTP-эндпоинты, статика, 404/405
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
build-tags:
|
||||
- sqlite_cgo
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- asasalint
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- depguard
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- forbidigo
|
||||
- gocheckcompilerdirectives
|
||||
- gocritic
|
||||
- gosec
|
||||
- govet
|
||||
- ineffassign
|
||||
- loggercheck
|
||||
- misspell
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sloglint
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- whitespace
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
permissions:
|
||||
files:
|
||||
- "**/internal/permissions/**"
|
||||
deny:
|
||||
- pkg: "glchat/internal/(auth|guilds|roles|channels|messages|files|invites|voice|search|gateway|emojis|sounds|webhooks|moderation|audit|friends|dm)/"
|
||||
desc: "permissions must stay a leaf package without domain dependencies"
|
||||
database:
|
||||
files:
|
||||
- "**/internal/database/**"
|
||||
deny:
|
||||
- pkg: "glchat/internal/(server|gateway|auth|guilds|roles|channels|messages)/"
|
||||
desc: "the database package must not import domain packages"
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: 'panic\('
|
||||
msg: "no panics in handlers or services; return errors instead"
|
||||
- pattern: 'fmt\.Print.*'
|
||||
msg: "use log/slog instead of fmt printing"
|
||||
analyze-types: false
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- performance
|
||||
disabled-checks:
|
||||
- whyNoLint
|
||||
- hugeParam
|
||||
- rangeValCopy
|
||||
gosec:
|
||||
excludes:
|
||||
- G404
|
||||
misspell:
|
||||
locale: US
|
||||
revive:
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: error-naming
|
||||
- name: exported
|
||||
disabled: true
|
||||
- name: indent-error-flow
|
||||
- name: var-naming
|
||||
- name: unreachable-code
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
kv-only: true
|
||||
attr-only: true
|
||||
context: "scope"
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -ST1000
|
||||
- -ST1003
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- std-error-handling
|
||||
rules:
|
||||
- path: "_test\\.go"
|
||||
linters:
|
||||
- gosec
|
||||
- errcheck
|
||||
- forbidigo
|
||||
- path: "(^|/)internal/database/sqlc/"
|
||||
linters:
|
||||
- revive
|
||||
- gocritic
|
||||
- unused
|
||||
- staticcheck
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
settings:
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- glchat
|
||||
exclusions:
|
||||
generated: lax
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/server"
|
||||
)
|
||||
|
||||
// Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...".
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
buildDate = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("glchat exited with error", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
if v := os.Getenv("APP_VERSION"); v != "" {
|
||||
version = v
|
||||
}
|
||||
if c := os.Getenv("APP_COMMIT"); c != "" {
|
||||
commit = c
|
||||
}
|
||||
if b := os.Getenv("APP_BUILD_DATE"); b != "" {
|
||||
buildDate = b
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
cfg.Version = version
|
||||
cfg.Commit = commit
|
||||
cfg.BuildDate = buildDate
|
||||
|
||||
logger := newLogger(cfg)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: cfg.DatabasePath,
|
||||
ReadPool: cfg.ReadPoolSize,
|
||||
CacheSize: cfg.SQLiteCache,
|
||||
MmapSize: cfg.SQLiteMmap,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error("close database", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
schemaVersion, err := db.SchemaVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
journalMode, err := db.JournalMode(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("database ready",
|
||||
slog.String("path", db.Path()),
|
||||
slog.Int64("schema_version", schemaVersion),
|
||||
slog.String("journal_mode", journalMode),
|
||||
)
|
||||
|
||||
stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance)
|
||||
defer stopMaintenance()
|
||||
|
||||
srv := server.New(cfg, db, logger)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("http server listening",
|
||||
slog.String("addr", cfg.ListenAddr),
|
||||
slog.String("version", cfg.Version),
|
||||
slog.String("domain", cfg.Domain),
|
||||
)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown requested")
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTTL)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("graceful shutdown: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newLogger(cfg config.Config) *slog.Logger {
|
||||
opts := &slog.HandlerOptions{Level: slog.Level(cfg.LogLevelNumeric())}
|
||||
var handler slog.Handler
|
||||
if cfg.LogFormat == "text" {
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
}
|
||||
return slog.New(handler).With(
|
||||
slog.String("service", "glchat"),
|
||||
slog.String("version", cfg.Version),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
module glchat
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/pressly/goose/v3 v3.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
|
||||
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
|
||||
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
||||
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
@@ -0,0 +1,132 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultListenAddr = "127.0.0.1:8080"
|
||||
DefaultDataDir = "/opt/glchat/data"
|
||||
DefaultLogLevel = "info"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Domain string
|
||||
FilesDomain string
|
||||
InstanceName string
|
||||
ListenAddr string
|
||||
DataDir string
|
||||
WebRoot string
|
||||
DatabasePath string
|
||||
SQLiteCache string
|
||||
SQLiteMmap string
|
||||
LogLevel string
|
||||
LogFormat string
|
||||
ReadPoolSize int
|
||||
MaxUploadSize int64
|
||||
Version string
|
||||
Commit string
|
||||
BuildDate string
|
||||
ShutdownTTL time.Duration
|
||||
Maintenance time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Domain: env("DOMAIN", "localhost"),
|
||||
FilesDomain: env("FILES_DOMAIN", ""),
|
||||
InstanceName: env("INSTANCE_NAME", "glchat"),
|
||||
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
|
||||
DataDir: env("DATA_DIR", DefaultDataDir),
|
||||
WebRoot: env("WEB_ROOT", "/app/web"),
|
||||
DatabasePath: env("DATABASE_PATH", ""),
|
||||
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
|
||||
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
|
||||
LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)),
|
||||
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
|
||||
ReadPoolSize: envInt("SQLITE_READ_POOL", 4),
|
||||
MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400),
|
||||
Version: env("APP_VERSION", "dev"),
|
||||
Commit: env("APP_COMMIT", "none"),
|
||||
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
||||
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
|
||||
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
|
||||
}
|
||||
if cfg.FilesDomain == "" {
|
||||
cfg.FilesDomain = "files." + cfg.Domain
|
||||
}
|
||||
if cfg.DatabasePath == "" {
|
||||
cfg.DatabasePath = cfg.DataDir + "/db/glchat.db"
|
||||
}
|
||||
if cfg.ReadPoolSize < 1 {
|
||||
return Config{}, fmt.Errorf("SQLITE_READ_POOL must be >= 1, got %d", cfg.ReadPoolSize)
|
||||
}
|
||||
if _, err := parseLogLevel(cfg.LogLevel); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.LogFormat != "json" && cfg.LogFormat != "text" {
|
||||
return Config{}, fmt.Errorf("LOG_FORMAT must be json or text, got %q", cfg.LogFormat)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) BaseURL() string { return "https://" + c.Domain }
|
||||
func (c Config) FilesURL() string { return "https://" + c.FilesDomain }
|
||||
|
||||
func (c Config) LogLevelNumeric() int {
|
||||
level, err := parseLogLevel(c.LogLevel)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) (int, error) {
|
||||
switch level {
|
||||
case "debug":
|
||||
return -4, nil
|
||||
case "info":
|
||||
return 0, nil
|
||||
case "warn", "warning":
|
||||
return 4, nil
|
||||
case "error":
|
||||
return 8, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown LOG_LEVEL %q", level)
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envInt64(key string, fallback int64) int64 {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("DOMAIN", "")
|
||||
t.Setenv("LISTEN_ADDR", "")
|
||||
t.Setenv("DATA_DIR", "")
|
||||
t.Setenv("DATABASE_PATH", "")
|
||||
t.Setenv("INSTANCE_NAME", "")
|
||||
t.Setenv("FILES_DOMAIN", "")
|
||||
t.Setenv("LOG_LEVEL", "")
|
||||
t.Setenv("LOG_FORMAT", "")
|
||||
t.Setenv("SQLITE_READ_POOL", "")
|
||||
t.Setenv("MAX_UPLOAD_SIZE", "")
|
||||
t.Setenv("SHUTDOWN_TIMEOUT_SECONDS", "")
|
||||
t.Setenv("DB_MAINTENANCE_INTERVAL_SECONDS", "")
|
||||
t.Setenv("APP_VERSION", "")
|
||||
t.Setenv("APP_COMMIT", "")
|
||||
t.Setenv("APP_BUILD_DATE", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() returned error: %v", err)
|
||||
}
|
||||
if cfg.ListenAddr != DefaultListenAddr {
|
||||
t.Errorf("ListenAddr = %q, want %q", cfg.ListenAddr, DefaultListenAddr)
|
||||
}
|
||||
if cfg.DataDir != DefaultDataDir {
|
||||
t.Errorf("DataDir = %q, want %q", cfg.DataDir, DefaultDataDir)
|
||||
}
|
||||
if cfg.DatabasePath != DefaultDataDir+"/db/glchat.db" {
|
||||
t.Errorf("DatabasePath = %q, want default under data dir", cfg.DatabasePath)
|
||||
}
|
||||
if cfg.FilesDomain != "files.localhost" {
|
||||
t.Errorf("FilesDomain = %q, want files.localhost", cfg.FilesDomain)
|
||||
}
|
||||
if cfg.ReadPoolSize != 4 {
|
||||
t.Errorf("ReadPoolSize = %d, want 4", cfg.ReadPoolSize)
|
||||
}
|
||||
if cfg.MaxUploadSize != 26214400 {
|
||||
t.Errorf("MaxUploadSize = %d, want 26214400", cfg.MaxUploadSize)
|
||||
}
|
||||
if cfg.Version != "dev" {
|
||||
t.Errorf("Version = %q, want dev", cfg.Version)
|
||||
}
|
||||
if cfg.LogLevelNumeric() != 0 {
|
||||
t.Errorf("LogLevelNumeric() = %d, want 0 for info", cfg.LogLevelNumeric())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvOverrides(t *testing.T) {
|
||||
t.Setenv("DOMAIN", "gl.mhspx.su")
|
||||
t.Setenv("FILES_DOMAIN", "")
|
||||
t.Setenv("LISTEN_ADDR", "0.0.0.0:9090")
|
||||
t.Setenv("DATA_DIR", "/srv/glchat")
|
||||
t.Setenv("DATABASE_PATH", "/srv/glchat/db/custom.db")
|
||||
t.Setenv("LOG_LEVEL", "debug")
|
||||
t.Setenv("LOG_FORMAT", "text")
|
||||
t.Setenv("SQLITE_READ_POOL", "8")
|
||||
t.Setenv("MAX_UPLOAD_SIZE", "104857600")
|
||||
t.Setenv("APP_VERSION", "v0.1.0")
|
||||
t.Setenv("APP_COMMIT", "abc1234")
|
||||
t.Setenv("SHUTDOWN_TIMEOUT_SECONDS", "5")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() returned error: %v", err)
|
||||
}
|
||||
if cfg.Domain != "gl.mhspx.su" {
|
||||
t.Errorf("Domain = %q", cfg.Domain)
|
||||
}
|
||||
if cfg.FilesDomain != "files.gl.mhspx.su" {
|
||||
t.Errorf("FilesDomain = %q, want derived subdomain", cfg.FilesDomain)
|
||||
}
|
||||
if cfg.DatabasePath != "/srv/glchat/db/custom.db" {
|
||||
t.Errorf("DatabasePath = %q", cfg.DatabasePath)
|
||||
}
|
||||
if cfg.ReadPoolSize != 8 {
|
||||
t.Errorf("ReadPoolSize = %d", cfg.ReadPoolSize)
|
||||
}
|
||||
if cfg.MaxUploadSize != 104857600 {
|
||||
t.Errorf("MaxUploadSize = %d", cfg.MaxUploadSize)
|
||||
}
|
||||
if cfg.LogLevelNumeric() != -4 {
|
||||
t.Errorf("LogLevelNumeric() = %d, want -4 for debug", cfg.LogLevelNumeric())
|
||||
}
|
||||
if cfg.LogFormat != "text" {
|
||||
t.Errorf("LogFormat = %q", cfg.LogFormat)
|
||||
}
|
||||
if cfg.BaseURL() != "https://gl.mhspx.su" {
|
||||
t.Errorf("BaseURL() = %q", cfg.BaseURL())
|
||||
}
|
||||
if cfg.FilesURL() != "https://files.gl.mhspx.su" {
|
||||
t.Errorf("FilesURL() = %q", cfg.FilesURL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
}{
|
||||
{name: "log level", env: map[string]string{"LOG_LEVEL": "verbose"}},
|
||||
{name: "log format", env: map[string]string{"LOG_FORMAT": "xml"}},
|
||||
{name: "read pool", env: map[string]string{"SQLITE_READ_POOL": "0"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("LOG_LEVEL", "info")
|
||||
t.Setenv("LOG_FORMAT", "json")
|
||||
t.Setenv("SQLITE_READ_POOL", "4")
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("Load() accepted invalid %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type DB struct {
|
||||
Writer *sql.DB
|
||||
Reader *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Path string
|
||||
ReadPool int
|
||||
CacheSize string
|
||||
MmapSize string
|
||||
Migrate bool
|
||||
}
|
||||
|
||||
const (
|
||||
writerMaxOpenConns = 1
|
||||
connMaxLifetime = time.Hour
|
||||
)
|
||||
|
||||
func Open(ctx context.Context, opts Options) (*DB, error) {
|
||||
if opts.Path == "" {
|
||||
return nil, errors.New("database path is required")
|
||||
}
|
||||
if opts.ReadPool < 1 {
|
||||
opts.ReadPool = max(2, runtime.NumCPU())
|
||||
}
|
||||
if err := EnsureDir(opts.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
writer, err := sql.Open("sqlite3", dsn(opts, true))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open writer connection: %w", err)
|
||||
}
|
||||
writer.SetMaxOpenConns(writerMaxOpenConns)
|
||||
writer.SetMaxIdleConns(writerMaxOpenConns)
|
||||
writer.SetConnMaxLifetime(connMaxLifetime)
|
||||
|
||||
if err := applyPragmas(ctx, writer, opts); err != nil {
|
||||
writer.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := writer.PingContext(ctx); err != nil {
|
||||
writer.Close()
|
||||
return nil, fmt.Errorf("ping writer connection: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{Writer: writer, path: opts.Path}
|
||||
if opts.Migrate {
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
reader, err := sql.Open("sqlite3", dsn(opts, false))
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("open reader connection: %w", err)
|
||||
}
|
||||
reader.SetMaxOpenConns(opts.ReadPool)
|
||||
reader.SetMaxIdleConns(opts.ReadPool)
|
||||
reader.SetConnMaxLifetime(connMaxLifetime)
|
||||
if err := applyPragmas(ctx, reader, opts); err != nil {
|
||||
reader.Close()
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := reader.PingContext(ctx); err != nil {
|
||||
reader.Close()
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping reader connection: %w", err)
|
||||
}
|
||||
db.Reader = reader
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// dsn builds the mattn/go-sqlite3 DSN. The journal mode needs an exclusive
|
||||
// lock to switch, so it is set once on the writer pool; readers reuse the
|
||||
// mode already persisted in the database file.
|
||||
func dsn(opts Options, writer bool) string {
|
||||
params := []string{"_foreign_keys=ON", "_busy_timeout=5000"}
|
||||
if writer {
|
||||
params = append(params, "_journal_mode=WAL", "_txlock=immediate")
|
||||
} else {
|
||||
params = append(params, "mode=ro", "_query_only=ON")
|
||||
}
|
||||
return "file:" + opts.Path + "?" + strings.Join(params, "&")
|
||||
}
|
||||
|
||||
func applyPragmas(ctx context.Context, db *sql.DB, opts Options) error {
|
||||
pragmas := []string{
|
||||
"PRAGMA foreign_keys = ON",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
"PRAGMA synchronous = NORMAL",
|
||||
"PRAGMA temp_store = MEMORY",
|
||||
}
|
||||
if opts.CacheSize != "" {
|
||||
pragmas = append(pragmas, "PRAGMA cache_size = "+opts.CacheSize)
|
||||
}
|
||||
if opts.MmapSize != "" {
|
||||
pragmas = append(pragmas, "PRAGMA mmap_size = "+opts.MmapSize)
|
||||
}
|
||||
for _, stmt := range pragmas {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("apply %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) Path() string { return db.path }
|
||||
|
||||
func (db *DB) Migrate(ctx context.Context) error {
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return fmt.Errorf("set goose dialect: %w", err)
|
||||
}
|
||||
if err := goose.UpContext(ctx, db.Writer, "migrations"); err != nil {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SchemaVersion(ctx context.Context) (int64, error) {
|
||||
var version int64
|
||||
err := db.Reader.QueryRowContext(ctx,
|
||||
`SELECT version_id FROM goose_db_version WHERE is_applied = 1 ORDER BY id DESC LIMIT 1`,
|
||||
).Scan(&version)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (db *DB) JournalMode(ctx context.Context) (string, error) {
|
||||
var mode string
|
||||
if err := db.Reader.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
|
||||
return "", fmt.Errorf("read journal mode: %w", err)
|
||||
}
|
||||
return mode, nil
|
||||
}
|
||||
|
||||
func (db *DB) Ready(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
var one int
|
||||
if err := db.Reader.QueryRowContext(ctx, "SELECT 1").Scan(&one); err != nil {
|
||||
return fmt.Errorf("database not ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) Optimize(ctx context.Context) error {
|
||||
if _, err := db.Writer.ExecContext(ctx, "PRAGMA optimize"); err != nil {
|
||||
return fmt.Errorf("pragma optimize: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) RunMaintenance(ctx context.Context, logger *slog.Logger, interval time.Duration) func() {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := db.Optimize(ctx); err != nil {
|
||||
logger.WarnContext(ctx, "database maintenance failed", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
var errs []error
|
||||
if db.Reader != nil {
|
||||
if err := db.Reader.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if db.Writer != nil {
|
||||
if err := db.Writer.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("close database: %w", errors.Join(errs...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureDir(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAppliesPragmasAndMigrates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "db", "glchat.db")
|
||||
|
||||
db, err := Open(ctx, Options{Path: path, ReadPool: 2, Migrate: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Open() returned error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
journal, err := db.JournalMode(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("JournalMode() returned error: %v", err)
|
||||
}
|
||||
if journal != "wal" {
|
||||
t.Errorf("journal mode = %q, want wal", journal)
|
||||
}
|
||||
|
||||
version, err := db.SchemaVersion(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion() returned error: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Errorf("schema version = %d, want 1", version)
|
||||
}
|
||||
|
||||
var foreignKeys int
|
||||
if err := db.Writer.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
|
||||
t.Fatalf("read foreign_keys pragma: %v", err)
|
||||
}
|
||||
if foreignKeys != 1 {
|
||||
t.Errorf("foreign_keys = %d, want 1", foreignKeys)
|
||||
}
|
||||
|
||||
var baseline string
|
||||
if err := db.Reader.QueryRowContext(ctx,
|
||||
`SELECT value FROM instance_settings WHERE key = 'registration_enabled'`,
|
||||
).Scan(&baseline); err != nil {
|
||||
t.Fatalf("read instance setting: %v", err)
|
||||
}
|
||||
if baseline != "true" {
|
||||
t.Errorf("registration_enabled = %q, want true", baseline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "glchat.db")
|
||||
|
||||
db, err := Open(ctx, Options{Path: path, ReadPool: 2, Migrate: true})
|
||||
if err != nil {
|
||||
t.Fatalf("first Open() returned error: %v", err)
|
||||
}
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
t.Fatalf("second Migrate() returned error: %v", err)
|
||||
}
|
||||
|
||||
var applied int
|
||||
if err := db.Reader.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM goose_db_version WHERE version_id = 1 AND is_applied = 1`,
|
||||
).Scan(&applied); err != nil {
|
||||
t.Fatalf("count applied migrations: %v", err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Errorf("migration 1 applied %d times, want 1", applied)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("Close() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsEmptyPath(t *testing.T) {
|
||||
if _, err := Open(context.Background(), Options{}); err == nil {
|
||||
t.Fatal("Open() accepted an empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyFailsOnClosedDatabase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := Open(ctx, Options{Path: filepath.Join(t.TempDir(), "glchat.db"), Migrate: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Open() returned error: %v", err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("Close() returned error: %v", err)
|
||||
}
|
||||
if err := db.Ready(ctx); err == nil {
|
||||
t.Fatal("Ready() returned nil for a closed database")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- Baseline migration: instance-level key/value settings (AGENT.md 6.1, 6.5).
|
||||
CREATE TABLE instance_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
INSERT INTO instance_settings (key, value) VALUES
|
||||
('schema_baseline', '1'),
|
||||
('registration_enabled', 'true'),
|
||||
('anti_bot_enabled', 'false'),
|
||||
('allow_guild_creation', 'true');
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE instance_settings;
|
||||
@@ -0,0 +1,14 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func newRequestID() string {
|
||||
var buf [16]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "0000000000000000"
|
||||
}
|
||||
return hex.EncodeToString(buf[:])
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
CodeInternalError ErrorCode = "internal.error"
|
||||
CodeNotFound ErrorCode = "not_found"
|
||||
CodeBadRequest ErrorCode = "request.bad"
|
||||
CodeRateLimited ErrorCode = "ratelimit.hit"
|
||||
CodeNotReady ErrorCode = "instance.not_ready"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error Error `json:"error"`
|
||||
}
|
||||
|
||||
func (e Error) HTTPStatus() int {
|
||||
switch e.Code {
|
||||
case CodeNotFound:
|
||||
return http.StatusNotFound
|
||||
case CodeBadRequest:
|
||||
return http.StatusBadRequest
|
||||
case CodeRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case CodeNotReady:
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func NewError(code ErrorCode, message string) Error {
|
||||
return Error{Code: code, Message: message}
|
||||
}
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(status)
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
slog.Error("write json response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, apiErr Error) {
|
||||
WriteJSON(w, apiErr.HTTPStatus(), errorEnvelope{Error: apiErr})
|
||||
}
|
||||
|
||||
func WriteErrorStatus(w http.ResponseWriter, status int, code ErrorCode, message string) {
|
||||
WriteJSON(w, status, errorEnvelope{Error: NewError(code, message)})
|
||||
}
|
||||
|
||||
func StatusForCode(code ErrorCode) int { return NewError(code, "").HTTPStatus() }
|
||||
|
||||
var errBodyTooLarge = errors.New("request body too large")
|
||||
@@ -0,0 +1,170 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
func Chain(h http.Handler, mws ...Middleware) http.Handler {
|
||||
for i := len(mws) - 1; i >= 0; i-- {
|
||||
h = mws[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(status int) {
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-Id")
|
||||
if id == "" {
|
||||
id = newRequestID()
|
||||
}
|
||||
w.Header().Set("X-Request-Id", id)
|
||||
next.ServeHTTP(w, r.WithContext(withRequestID(r.Context(), id)))
|
||||
})
|
||||
}
|
||||
|
||||
func Logger(logger *slog.Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
next.ServeHTTP(rec, r)
|
||||
if rec.status == 0 {
|
||||
rec.status = http.StatusOK
|
||||
}
|
||||
logger.LogAttrs(r.Context(), slog.LevelInfo, "http request",
|
||||
slog.String("request_id", RequestIDFrom(r.Context())),
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", rec.status),
|
||||
slog.Int("bytes", rec.bytes),
|
||||
slog.String("remote_ip", ClientIP(r, nil)),
|
||||
slog.Duration("duration", time.Since(start)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Recoverer(logger *slog.Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
logger.ErrorContext(r.Context(), "panic recovered",
|
||||
slog.String("request_id", RequestIDFrom(r.Context())),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Any("panic", rec),
|
||||
)
|
||||
WriteError(w, NewError(CodeInternalError, "internal error"))
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func SecurityHeaders(filesDomain string) Middleware {
|
||||
frameAncestors := "'none'"
|
||||
csp := strings.Join([]string{
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors " + frameAncestors,
|
||||
"form-action 'self'",
|
||||
}, "; ")
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", csp)
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Cross-Origin-Resource-Policy", "same-site")
|
||||
if isTLS(r) {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isTLS(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func ClientIP(r *http.Request, trustedProxies []*net.IPNet) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
proxyTrusted := false
|
||||
for _, n := range trustedProxies {
|
||||
if ip := net.ParseIP(host); ip != nil && n.Contains(ip) {
|
||||
proxyTrusted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !proxyTrusted {
|
||||
return host
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
candidate := strings.TrimSpace(parts[len(parts)-1])
|
||||
if ip := net.ParseIP(candidate); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
if realIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); realIP != "" {
|
||||
if ip := net.ParseIP(realIP); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const requestIDKey ctxKey = iota
|
||||
|
||||
func withRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, requestIDKey, id)
|
||||
}
|
||||
|
||||
func RequestIDFrom(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const defaultJSONBodyLimit = 1 << 20
|
||||
|
||||
func JSONBodyLimit(limit int64) Middleware {
|
||||
if limit <= 0 {
|
||||
limit = defaultJSONBodyLimit
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete:
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func MethodNotAllowed(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteErrorStatus(w, http.StatusMethodNotAllowed, CodeBadRequest, "method not allowed")
|
||||
}
|
||||
|
||||
func NotFound(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteError(w, NewError(CodeNotFound, "resource not found"))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package meta
|
||||
|
||||
import "glchat/internal/config"
|
||||
|
||||
const APIVersion = "v1"
|
||||
|
||||
type Features struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
AntiBotEnabled bool `json:"anti_bot_enabled"`
|
||||
VoiceEnabled bool `json:"voice_enabled"`
|
||||
WebPushEnabled bool `json:"web_push_enabled"`
|
||||
OAuthEnabled bool `json:"oauth_enabled"`
|
||||
PasskeysEnabled bool `json:"passkeys_enabled"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
BuildDate string `json:"build_date"`
|
||||
APIVersion string `json:"api_version"`
|
||||
BaseURL string `json:"base_url"`
|
||||
FilesURL string `json:"files_url"`
|
||||
GatewayURL string `json:"gateway_url"`
|
||||
RTCPath string `json:"rtc_path"`
|
||||
MaxUploadSize int64 `json:"max_upload_size"`
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
|
||||
func New(cfg config.Config) Response {
|
||||
return Response{
|
||||
Name: cfg.InstanceName,
|
||||
Version: cfg.Version,
|
||||
Commit: cfg.Commit,
|
||||
BuildDate: cfg.BuildDate,
|
||||
APIVersion: APIVersion,
|
||||
BaseURL: cfg.BaseURL(),
|
||||
FilesURL: cfg.FilesURL(),
|
||||
GatewayURL: "wss://" + cfg.Domain + "/gateway",
|
||||
RTCPath: "/rtc",
|
||||
MaxUploadSize: cfg.MaxUploadSize,
|
||||
Features: Features{
|
||||
RegistrationEnabled: true,
|
||||
AntiBotEnabled: false,
|
||||
VoiceEnabled: false,
|
||||
WebPushEnabled: false,
|
||||
OAuthEnabled: false,
|
||||
PasskeysEnabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package meta
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/config"
|
||||
)
|
||||
|
||||
func TestNewBuildsMetaPayload(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
Domain: "gl.mhspx.su",
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
Version: "v0.1.0",
|
||||
Commit: "abc1234",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
}
|
||||
|
||||
resp := New(cfg)
|
||||
if resp.APIVersion != APIVersion {
|
||||
t.Errorf("APIVersion = %q, want %q", resp.APIVersion, APIVersion)
|
||||
}
|
||||
if resp.BaseURL != "https://gl.mhspx.su" {
|
||||
t.Errorf("BaseURL = %q", resp.BaseURL)
|
||||
}
|
||||
if resp.FilesURL != "https://files.gl.mhspx.su" {
|
||||
t.Errorf("FilesURL = %q", resp.FilesURL)
|
||||
}
|
||||
if resp.GatewayURL != "wss://gl.mhspx.su/gateway" {
|
||||
t.Errorf("GatewayURL = %q", resp.GatewayURL)
|
||||
}
|
||||
if resp.RTCPath != "/rtc" {
|
||||
t.Errorf("RTCPath = %q", resp.RTCPath)
|
||||
}
|
||||
if resp.Version != "v0.1.0" || resp.Commit != "abc1234" {
|
||||
t.Errorf("version metadata lost: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONUsesSnakeCaseKeys(t *testing.T) {
|
||||
raw, err := json.Marshal(New(config.Config{Domain: "example.com", FilesDomain: "files.example.com"}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal meta: %v", err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal meta: %v", err)
|
||||
}
|
||||
for _, key := range []string{"api_version", "base_url", "files_url", "gateway_url", "max_upload_size", "features"} {
|
||||
if _, ok := decoded[key]; !ok {
|
||||
t.Errorf("meta payload is missing key %q: %s", key, raw)
|
||||
}
|
||||
}
|
||||
features, ok := decoded["features"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("features is not an object: %s", raw)
|
||||
}
|
||||
for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "oauth_enabled", "passkeys_enabled"} {
|
||||
if _, ok := features[key]; !ok {
|
||||
t.Errorf("features is missing key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"glchat/internal/httpx"
|
||||
)
|
||||
|
||||
// methodOfPattern splits a Go 1.22 routing pattern such as "GET /api/v1/meta"
|
||||
// into its method and path parts. Patterns without a method apply to all.
|
||||
func methodOfPattern(pattern string) (method, path string) {
|
||||
if i := strings.IndexByte(pattern, ' '); i > 0 {
|
||||
return pattern[:i], pattern[i+1:]
|
||||
}
|
||||
return "", pattern
|
||||
}
|
||||
|
||||
func (s *Server) routeExists(method, path string) bool {
|
||||
for _, pattern := range s.patterns {
|
||||
patternMethod, patternPath := methodOfPattern(pattern)
|
||||
if patternMethod == "" || patternMethod == method {
|
||||
continue
|
||||
}
|
||||
if patternPath == path {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(patternPath, "/") && strings.HasPrefix(path, patternPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reservedPrefixes are handled by the API, the gateway or the file CDN; an
|
||||
// unknown path under them is a real 404 and must not receive the SPA shell.
|
||||
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"}
|
||||
|
||||
func isReservedPath(path string) bool {
|
||||
for _, prefix := range reservedPrefixes {
|
||||
if path == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.routeExists(r.Method, r.URL.Path) {
|
||||
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
|
||||
return
|
||||
}
|
||||
if isReservedPath(r.URL.Path) {
|
||||
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
s.static.serve(w, r)
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
// openAPIDocument is the hand-maintained OpenAPI 3.1 contract for the endpoints
|
||||
// implemented so far. Phase 1 replaces it with a schema generated from typed
|
||||
// handler definitions (AGENT.md 8.1).
|
||||
var openAPIDocument = []byte(`{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "glchat API",
|
||||
"version": "0.1.0",
|
||||
"description": "Self-hosted communication platform. Phase 0 exposes health and metadata endpoints only.",
|
||||
"license": { "name": "AGPL-3.0-or-later", "identifier": "AGPL-3.0-or-later" }
|
||||
},
|
||||
"servers": [{ "url": "/api/v1" }],
|
||||
"paths": {
|
||||
"/meta": {
|
||||
"get": {
|
||||
"operationId": "getMeta",
|
||||
"summary": "Instance metadata, API version and feature flags",
|
||||
"tags": ["Meta"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Instance metadata",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/Meta" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Meta": {
|
||||
"type": "object",
|
||||
"required": ["name", "version", "api_version", "base_url", "features"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"version": { "type": "string" },
|
||||
"commit": { "type": "string" },
|
||||
"build_date": { "type": "string" },
|
||||
"api_version": { "type": "string", "enum": ["v1"] },
|
||||
"base_url": { "type": "string" },
|
||||
"files_url": { "type": "string" },
|
||||
"gateway_url": { "type": "string" },
|
||||
"rtc_path": { "type": "string" },
|
||||
"max_upload_size": { "type": "integer", "format": "int64" },
|
||||
"features": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registration_enabled": { "type": "boolean" },
|
||||
"anti_bot_enabled": { "type": "boolean" },
|
||||
"voice_enabled": { "type": "boolean" },
|
||||
"web_push_enabled": { "type": "boolean" },
|
||||
"oauth_enabled": { "type": "boolean" },
|
||||
"passkeys_enabled": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "object",
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"details": { "type": "object", "additionalProperties": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/httpx"
|
||||
"glchat/internal/meta"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
db *database.DB
|
||||
logger *slog.Logger
|
||||
http *http.Server
|
||||
static *staticHandler
|
||||
patterns []string
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
logger: logger,
|
||||
patterns: append([]string(nil), routePatterns...),
|
||||
static: newStaticHandler(cfg.WebRoot),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
handler := httpx.Chain(mux,
|
||||
httpx.SecurityHeaders(cfg.FilesDomain),
|
||||
httpx.RequestID,
|
||||
httpx.Logger(logger),
|
||||
httpx.Recoverer(logger),
|
||||
httpx.JSONBodyLimit(1<<20),
|
||||
)
|
||||
s.http = &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 16,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var routePatterns = []string{
|
||||
"GET /healthz",
|
||||
"GET /readyz",
|
||||
"GET /api/v1/meta",
|
||||
"GET /api/v1/openapi.json",
|
||||
"/",
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", s.handleReadyz)
|
||||
mux.HandleFunc("GET /api/v1/meta", s.handleMeta)
|
||||
mux.HandleFunc("GET /api/v1/openapi.json", s.handleOpenAPI)
|
||||
mux.HandleFunc("/", s.handleFallback)
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler { return s.http.Handler }
|
||||
|
||||
func (s *Server) ListenAndServe() error { return s.http.ListenAndServe() }
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error { return s.http.Shutdown(ctx) }
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"version": s.cfg.Version,
|
||||
"uptime": time.Since(startedAt).Round(time.Second).String(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
checks := map[string]string{"database": "ok"}
|
||||
ready := true
|
||||
if err := s.db.Ready(ctx); err != nil {
|
||||
checks["database"] = "failed"
|
||||
ready = false
|
||||
s.logger.WarnContext(ctx, "readiness check failed", slog.Any("error", err))
|
||||
}
|
||||
status := http.StatusOK
|
||||
state := "ready"
|
||||
if !ready {
|
||||
status = http.StatusServiceUnavailable
|
||||
state = "unavailable"
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]any{
|
||||
"status": state,
|
||||
"version": s.cfg.Version,
|
||||
"checks": checks,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg))
|
||||
}
|
||||
|
||||
func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(openAPIDocument); err != nil {
|
||||
s.logger.Error("write openapi document", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
var startedAt = time.Now()
|
||||
@@ -0,0 +1,241 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *database.DB) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("close test database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
cfg := config.Config{
|
||||
Domain: "gl.mhspx.su",
|
||||
WebRoot: filepath.Join("testdata", "web"),
|
||||
FilesDomain: "files.gl.mhspx.su",
|
||||
InstanceName: "glchat",
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
Version: "v0.1.0-test",
|
||||
Commit: "deadbee",
|
||||
BuildDate: "2026-09-19T00:00:00Z",
|
||||
MaxUploadSize: 26214400,
|
||||
LogLevel: "error",
|
||||
LogFormat: "json",
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, db, logger), db
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("status field = %v, want ok", body["status"])
|
||||
}
|
||||
if body["version"] != "v0.1.0-test" {
|
||||
t.Errorf("version field = %v", body["version"])
|
||||
}
|
||||
if rec.Header().Get("X-Request-Id") == "" {
|
||||
t.Error("X-Request-Id header is missing")
|
||||
}
|
||||
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Error("security headers are missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzReportsDatabase(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Checks map[string]string `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.Status != "ready" {
|
||||
t.Errorf("status = %q, want ready", body.Status)
|
||||
}
|
||||
if body.Checks["database"] != "ok" {
|
||||
t.Errorf("database check = %q, want ok", body.Checks["database"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzFailsWhenDatabaseClosed(t *testing.T) {
|
||||
srv, db := newTestServer(t)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("close database: %v", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaEndpoint(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
APIVersion string `json:"api_version"`
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if body.APIVersion != "v1" {
|
||||
t.Errorf("api_version = %q, want v1", body.APIVersion)
|
||||
}
|
||||
if body.BaseURL != "https://gl.mhspx.su" {
|
||||
t.Errorf("base_url = %q", body.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentIsServed(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("openapi document is not valid json: %v", err)
|
||||
}
|
||||
if doc["openapi"] != "3.1.0" {
|
||||
t.Errorf("openapi version = %v, want 3.1.0", doc["openapi"])
|
||||
}
|
||||
if _, ok := doc["paths"].(map[string]any)["/meta"]; !ok {
|
||||
t.Error("openapi document does not describe /meta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownAPIRouteUsesErrorEnvelope(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/does-not-exist", nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode error envelope: %v", err)
|
||||
}
|
||||
if body.Error.Code == "" || body.Error.Message == "" {
|
||||
t.Errorf("error envelope is incomplete: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServesWebClientWithSPAFallback(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantSubstr string
|
||||
wantHeaders map[string]string
|
||||
}{
|
||||
{path: "/", wantStatus: http.StatusOK, wantSubstr: "glchat"},
|
||||
{path: "/app/guild/1/channel/2", wantStatus: http.StatusOK, wantSubstr: "glchat"},
|
||||
{path: "/assets/app.js", wantStatus: http.StatusOK, wantSubstr: "console.log"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("GET %s status = %d, want %d", tc.path, rec.Code, tc.wantStatus)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), tc.wantSubstr) {
|
||||
t.Errorf("GET %s body = %q, want it to contain %q", tc.path, rec.Body.String(), tc.wantSubstr)
|
||||
}
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
|
||||
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
|
||||
t.Errorf("assets Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeWebClientReportsMissingBundle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
srv := New(cfg, db, logger)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503 when the bundle is absent", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodNotAllowedOnMeta(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/meta", nil))
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// staticHandler serves the built web client with SPA fallback: unknown paths
|
||||
// return index.html so client-side routes survive a page reload.
|
||||
type staticHandler struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func newStaticHandler(root string) *staticHandler {
|
||||
return &staticHandler{root: root}
|
||||
}
|
||||
|
||||
func (h *staticHandler) serve(w http.ResponseWriter, r *http.Request) {
|
||||
cleaned := path.Clean("/" + r.URL.Path)
|
||||
rel := strings.TrimPrefix(cleaned, "/")
|
||||
if rel == "" {
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := fs.Stat(os.DirFS(h.root), rel)
|
||||
if err != nil || info.IsDir() {
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if contentType := mime.TypeByExtension(filepath.Ext(rel)); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
if strings.HasPrefix(rel, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(h.root, filepath.FromSlash(rel)))
|
||||
}
|
||||
|
||||
func (h *staticHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
index := filepath.Join(h.root, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
writeClientNotBuilt(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, index)
|
||||
}
|
||||
|
||||
func writeClientNotBuilt(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("glchat: web client bundle is not present in this image\n"))
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
console.log("app");
|
||||
+1
@@ -0,0 +1 @@
|
||||
<!doctype html><html><body><div id="root">glchat</div></body></html>
|
||||
Reference in New Issue
Block a user