diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4a6839a --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go new file mode 100644 index 0000000..1f8208e --- /dev/null +++ b/cmd/glchat/main.go @@ -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), + ) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3c54f5c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..097497b --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ff95748 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..58bf2af --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..5733914 --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go new file mode 100644 index 0000000..aa8377c --- /dev/null +++ b/internal/database/database_test.go @@ -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") + } +} diff --git a/internal/database/migrations/00001_baseline.sql b/internal/database/migrations/00001_baseline.sql new file mode 100644 index 0000000..fb4643a --- /dev/null +++ b/internal/database/migrations/00001_baseline.sql @@ -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; diff --git a/internal/httpx/context.go b/internal/httpx/context.go new file mode 100644 index 0000000..f9b4b3b --- /dev/null +++ b/internal/httpx/context.go @@ -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[:]) +} diff --git a/internal/httpx/errors.go b/internal/httpx/errors.go new file mode 100644 index 0000000..c83aea2 --- /dev/null +++ b/internal/httpx/errors.go @@ -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") diff --git a/internal/httpx/middleware.go b/internal/httpx/middleware.go new file mode 100644 index 0000000..d6c0437 --- /dev/null +++ b/internal/httpx/middleware.go @@ -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 "" +} diff --git a/internal/httpx/request.go b/internal/httpx/request.go new file mode 100644 index 0000000..f8616f9 --- /dev/null +++ b/internal/httpx/request.go @@ -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")) +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go new file mode 100644 index 0000000..2a380a0 --- /dev/null +++ b/internal/meta/meta.go @@ -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, + }, + } +} diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go new file mode 100644 index 0000000..bb3a27f --- /dev/null +++ b/internal/meta/meta_test.go @@ -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) + } + } +} diff --git a/internal/server/fallback.go b/internal/server/fallback.go new file mode 100644 index 0000000..47acc0b --- /dev/null +++ b/internal/server/fallback.go @@ -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")) +} diff --git a/internal/server/openapi.go b/internal/server/openapi.go new file mode 100644 index 0000000..7e7dabf --- /dev/null +++ b/internal/server/openapi.go @@ -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 } + } + } + } + } + } + } +} +`) diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..720c5a0 --- /dev/null +++ b/internal/server/server.go @@ -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() diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..8fcf4c4 --- /dev/null +++ b/internal/server/server_test.go @@ -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) + } +} diff --git a/internal/server/static.go b/internal/server/static.go new file mode 100644 index 0000000..517d308 --- /dev/null +++ b/internal/server/static.go @@ -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")) +} diff --git a/internal/server/testdata/web/assets/app.js b/internal/server/testdata/web/assets/app.js new file mode 100644 index 0000000..702645f --- /dev/null +++ b/internal/server/testdata/web/assets/app.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/internal/server/testdata/web/index.html b/internal/server/testdata/web/index.html new file mode 100644 index 0000000..c868601 --- /dev/null +++ b/internal/server/testdata/web/index.html @@ -0,0 +1 @@ +