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:
2026-09-19 18:20:56 +03:00
parent 9839dbeede
commit 69aa98e18c
22 changed files with 1887 additions and 0 deletions
+131
View File
@@ -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
+130
View File
@@ -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),
)
}
+15
View File
@@ -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
)
+36
View File
@@ -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=
+132
View File
@@ -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
}
+121
View File
@@ -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)
}
})
}
}
+234
View File
@@ -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
}
+98
View File
@@ -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;
+14
View File
@@ -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[:])
}
+71
View File
@@ -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")
+170
View File
@@ -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 ""
}
+30
View File
@@ -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"))
}
+51
View File
@@ -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,
},
}
}
+65
View File
@@ -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)
}
}
}
+62
View File
@@ -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"))
}
+81
View File
@@ -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 }
}
}
}
}
}
}
}
`)
+119
View File
@@ -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()
+241
View File
@@ -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)
}
}
+68
View File
@@ -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
View File
@@ -0,0 +1 @@
console.log("app");
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><body><div id="root">glchat</div></body></html>