From 2df20c2840e29fb9cc034c69077621360e48e34d Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:48:47 -1000 Subject: [PATCH] refactor: adopt portigo infrastructure patterns Add config package with build-tag-switched dev/prod environments, structured logging via zerolog, Taskfile for dev workflow, golangci-lint config, testutil package, and improved DB setup with proper SQLite pragmas and cleanup. Rename sqlc output package from gen to repository. Switch to allowlist .gitignore, Alpine+UPX+scratch Dockerfile, and CI pipeline with test/lint gates before deploy. --- .env.example | 15 ++- .gitea/workflows/deploy.yml | 34 +++++- .gitignore | 40 +++++-- .golangci.yml | 45 ++++++++ Dockerfile | 31 ++---- Taskfile.yml | 63 +++++++++++ config/config.go | 73 +++++++++++++ config/config_dev.go | 9 ++ config/config_prod.go | 9 ++ config/config_test_helper.go | 19 ++++ db/db.go | 57 ++++++++-- db/persister.go | 28 ++--- db/{gen => repository}/chat.sql.go | 2 +- db/{gen => repository}/db.go | 2 +- db/{gen => repository}/games.sql.go | 2 +- db/{gen => repository}/models.go | 2 +- db/{gen => repository}/snake_games.sql.go | 2 +- db/{gen => repository}/users.sql.go | 2 +- db/sqlc.yaml | 8 +- go.mod | 27 ++--- go.sum | 62 +++++------ logging/log.go | 41 +++++++ logging/middleware.go | 126 ++++++++++++++++++++++ main.go | 52 +++++---- testutil/db.go | 47 ++++++++ testutil/sessions.go | 31 ++++++ ui/status.go | 8 +- 27 files changed, 694 insertions(+), 143 deletions(-) create mode 100644 .golangci.yml create mode 100644 Taskfile.yml create mode 100644 config/config.go create mode 100644 config/config_dev.go create mode 100644 config/config_prod.go create mode 100644 config/config_test_helper.go rename db/{gen => repository}/chat.sql.go (98%) rename db/{gen => repository}/db.go (96%) rename db/{gen => repository}/games.sql.go (99%) rename db/{gen => repository}/models.go (98%) rename db/{gen => repository}/snake_games.sql.go (99%) rename db/{gen => repository}/users.sql.go (98%) create mode 100644 logging/log.go create mode 100644 logging/middleware.go create mode 100644 testutil/db.go create mode 100644 testutil/sessions.go diff --git a/.env.example b/.env.example index 0643835..7ae4ff9 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,16 @@ -# Application URL for invite links (defaults to https://games.adriatica.io) +# Log level (TRACE, DEBUG, INFO, WARN, ERROR). Defaults to INFO. +# LOG_LEVEL=DEBUG + +# SQLite database path. Defaults to data/c4.db. +# DB_PATH=data/c4.db + +# Application URL for invite links. Defaults to https://games.adriatica.io. # APP_URL=http://localhost:7331 -# Server port (defaults to 7331) +# Server port. Defaults to 7331. # PORT=7331 + +# Goose CLI migration config (only needed for running goose manually) +GOOSE_DRIVER=sqlite3 +GOOSE_DBSTRING=data/c4.db?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) +GOOSE_MIGRATION_DIR=db/migrations diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1a314c7..f8a7271 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,14 +1,44 @@ -name: Deploy c4 +name: CI / Deploy on: push: branches: [main] + pull_request: env: DEPLOY_DIR: /home/ryan/c4 jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Run tests + run: go test ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + + - name: Run linter + run: golangci-lint run + deploy: + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: [test, lint] runs-on: games steps: - uses: actions/checkout@v4 @@ -21,8 +51,6 @@ jobs: - name: Ensure data directory exists with correct ownership run: | mkdir -p $DEPLOY_DIR/data - # UID 5 / GID 60 = games:games in the container (debian:bookworm-slim) - sudo chown 5:60 $DEPLOY_DIR/data - name: Rebuild and restart run: cd $DEPLOY_DIR && docker compose up -d --build --remove-orphans diff --git a/.gitignore b/.gitignore index 58dd18e..9dca485 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,34 @@ -c4 -c4.db -data/ -.env +# Allowlisting gitignore: ignore everything, then un-ignore what we track. +# source: https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# Deploy artifacts -c4-deploy-*.tar.gz -c4-deploy-*_b64*.txt +# Ignore everything +* + +# But not these files... +!.gitignore + +!*.go +!*.sql +!go.sum +!go.mod +!Taskfile.yml +!sqlc.yaml +!.golangci.yml +!.gitea/workflows/*.yml + +!.env.example +!LICENSE + +!assets/**/* + +# Generated CSS stays out of version control +assets/css/output.css + +# Deploy scripts and configs +!deploy/*.sh +!deploy/*.service +!docker-compose.yml +!Dockerfile + +# ...even if they are in subdirectories +!*/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..961c8c8 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,45 @@ +version: "2" + +linters: + default: standard + enable: + - errcheck + - govet + - staticcheck + - gosec + - bodyclose + - sqlclosecheck + - misspell + - errname + - copyloopvar + + settings: + staticcheck: + checks: + - all + - "-ST1001" # dot imports + - "-ST1003" # naming conventions + gosec: + excludes: + - G104 # unhandled errors — redundant with errcheck + - G107 # HTTP requests with variable URLs — expected in a web app + - G115 # integer overflow conversion + - G301 # directory permissions 0750 — 0755 is standard for data dirs + - G404 # weak random — acceptable for game IDs and player IDs + +formatters: + enable: + - gofmt + - goimports + + settings: + goimports: + local-prefixes: + - github.com/ryanhamamura/c4 + +issues: + exclude-rules: + - path: _test\.go + linters: + - gosec + - errcheck diff --git a/Dockerfile b/Dockerfile index 08261c3..a22afc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM golang:1.25.4-bookworm AS build +FROM docker.io/golang:1.25.4-alpine AS build + +RUN apk add --no-cache upx WORKDIR /src COPY go.mod go.sum ./ @@ -6,24 +8,11 @@ RUN go mod download COPY . . RUN go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /c4 . +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 go build -ldflags="-s" -o /bin/c4 . +RUN upx -9 -k /bin/c4 -FROM debian:bookworm-slim - -RUN apt-get update && \ - apt-get install -y --no-install-recommends ca-certificates wget && \ - rm -rf /var/lib/apt/lists/* - -COPY --from=build /c4 /usr/local/bin/c4 - -WORKDIR /app -RUN mkdir data && chown games:games data - -USER games - -EXPOSE 8080 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD wget -qO /dev/null http://localhost:8080/ - -CMD ["c4"] +FROM scratch +ENV PORT=8080 +COPY --from=build /bin/c4 / +ENTRYPOINT ["/c4"] diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..f5b9d29 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,63 @@ +version: "3" + +tasks: + build:styles: + desc: Build TailwindCSS styles + cmds: + - go tool gotailwind -i assets/css/input.css -o assets/css/output.css --minify + sources: + - "assets/css/input.css" + - "**/*.go" + generates: + - "assets/css/output.css" + + build: + desc: Production build to bin/c4 + cmds: + - go build -o bin/c4 . + deps: + - build:styles + + live:styles: + desc: Watch and rebuild TailwindCSS styles + cmds: + - go tool gotailwind -i assets/css/input.css -o assets/css/output.css -w + + live:server: + desc: Run server with hot-reload via air + cmds: + - | + go tool air \ + -build.cmd "go build -tags=dev -o tmp/bin/c4 ." \ + -build.bin "tmp/bin/c4" \ + -build.exclude_dir "data,bin,tmp,deploy" \ + -build.include_ext "go" \ + -misc.clean_on_exit "true" + + live: + desc: Dev mode with hot-reload + deps: + - live:styles + - live:server + + test: + desc: Run the test suite + cmds: + - go test ./... + + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run + + run: + desc: Build and run the server + cmds: + - ./bin/c4 + deps: + - build + + default: + desc: Run the default task (live) + cmds: + - task: live diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..6c09cd9 --- /dev/null +++ b/config/config.go @@ -0,0 +1,73 @@ +// Package config provides build-tag-switched application configuration. +// The global Config singleton is initialized at import time via init() +// and can be overridden in tests with LoadForTest(). +package config + +import ( + "os" + "sync" + + "github.com/joho/godotenv" + "github.com/rs/zerolog" +) + +type Environment string + +const ( + Dev Environment = "dev" + Prod Environment = "prod" +) + +type Config struct { + Environment Environment + Host string + Port string + LogLevel zerolog.Level + AppURL string + DBPath string +} + +var ( + Global *Config + once sync.Once +) + +func init() { + once.Do(func() { + Global = Load() + }) +} + +func getEnv(key, fallback string) string { + if val, ok := os.LookupEnv(key); ok { + return val + } + return fallback +} + +func loadBase() *Config { + godotenv.Load() //nolint:errcheck // .env file is optional + + return &Config{ + Host: getEnv("HOST", "0.0.0.0"), + Port: getEnv("PORT", "7331"), + LogLevel: func() zerolog.Level { + switch os.Getenv("LOG_LEVEL") { + case "TRACE": + return zerolog.TraceLevel + case "DEBUG": + return zerolog.DebugLevel + case "INFO": + return zerolog.InfoLevel + case "WARN": + return zerolog.WarnLevel + case "ERROR": + return zerolog.ErrorLevel + default: + return zerolog.InfoLevel + } + }(), + AppURL: getEnv("APP_URL", "https://games.adriatica.io"), + DBPath: getEnv("DB_PATH", "data/c4.db"), + } +} diff --git a/config/config_dev.go b/config/config_dev.go new file mode 100644 index 0000000..53af2dd --- /dev/null +++ b/config/config_dev.go @@ -0,0 +1,9 @@ +//go:build dev + +package config + +func Load() *Config { + cfg := loadBase() + cfg.Environment = Dev + return cfg +} diff --git a/config/config_prod.go b/config/config_prod.go new file mode 100644 index 0000000..9598fb1 --- /dev/null +++ b/config/config_prod.go @@ -0,0 +1,9 @@ +//go:build !dev + +package config + +func Load() *Config { + cfg := loadBase() + cfg.Environment = Prod + return cfg +} diff --git a/config/config_test_helper.go b/config/config_test_helper.go new file mode 100644 index 0000000..aec2a48 --- /dev/null +++ b/config/config_test_helper.go @@ -0,0 +1,19 @@ +package config + +import ( + "github.com/rs/zerolog" +) + +// LoadForTest sets config.Global to safe defaults without reading +// environment variables or .env files. Call this in TestMain or at the +// top of tests that import packages which depend on config.Global. +func LoadForTest() { + Global = &Config{ + Environment: Dev, + Host: "127.0.0.1", + Port: "0", + LogLevel: zerolog.WarnLevel, + AppURL: "http://localhost:0", + DBPath: ":memory:", + } +} diff --git a/db/db.go b/db/db.go index 04bb9bb..3d7031a 100644 --- a/db/db.go +++ b/db/db.go @@ -1,33 +1,70 @@ +// Package db handles SQLite database setup, pragma configuration, and +// goose migrations. package db import ( "database/sql" "embed" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" "github.com/pressly/goose/v3" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql -var migrations embed.FS +var MigrationFS embed.FS var DB *sql.DB -func Init(dbPath string) error { +func Init(dbPath string) (func(), error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + return nil, fmt.Errorf("creating data dir: %w", err) + } + + // busy_timeout must be first because the connection needs to block on + // busy before WAL mode is set in case it hasn't been set already. + pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)" var err error - DB, err = sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + DB, err = goose.OpenDBWithDriver("sqlite", dbPath+pragmas) if err != nil { - return err + return nil, fmt.Errorf("opening database: %w", err) } - DB.SetMaxOpenConns(1) - goose.SetBaseFS(migrations) + if err := DB.Ping(); err != nil { + return nil, errors.Join(fmt.Errorf("pinging database: %w", err), DB.Close()) + } + slog.Info("db connected", "db", dbPath) + + sub, err := fs.Sub(MigrationFS, "migrations") + if err != nil { + return nil, errors.Join(fmt.Errorf("migrations sub fs: %w", err), DB.Close()) + } + goose.SetBaseFS(sub) + if err := goose.SetDialect("sqlite3"); err != nil { - return err + return nil, errors.Join(fmt.Errorf("setting goose dialect: %w", err), DB.Close()) } - if err := goose.Up(DB, "migrations"); err != nil { - return err + if err := goose.Up(DB, "."); err != nil { + return nil, errors.Join(fmt.Errorf("running migrations: %w", err), DB.Close()) } - return nil + if _, err := DB.Exec("PRAGMA optimize"); err != nil { + return nil, errors.Join(fmt.Errorf("pragma optimize: %w", err), DB.Close()) + } + + cleanup := func() { + if _, err := DB.Exec("PRAGMA optimize(0x10002)"); err != nil { + slog.Error("pragma optimize at shutdown", "error", err) + } + if err := DB.Close(); err != nil { + slog.Error("closing database", "error", err) + } + } + + return cleanup, nil } diff --git a/db/persister.go b/db/persister.go index 5e68b17..ddab719 100644 --- a/db/persister.go +++ b/db/persister.go @@ -5,17 +5,17 @@ import ( "database/sql" "slices" - "github.com/ryanhamamura/c4/db/gen" + "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/c4/ui" ) type GamePersister struct { - queries *gen.Queries + queries *repository.Queries } -func NewGamePersister(q *gen.Queries) *GamePersister { +func NewGamePersister(q *repository.Queries) *GamePersister { return &GamePersister{queries: q} } @@ -24,7 +24,7 @@ func (p *GamePersister) SaveGame(g *game.Game) error { _, err := p.queries.GetGame(ctx, g.ID) if err == sql.ErrNoRows { - _, err = p.queries.CreateGame(ctx, gen.CreateGameParams{ + _, err = p.queries.CreateGame(ctx, repository.CreateGameParams{ ID: g.ID, Board: g.BoardToJSON(), CurrentTurn: int64(g.CurrentTurn), @@ -51,7 +51,7 @@ func (p *GamePersister) SaveGame(g *game.Game) error { rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true} } - return p.queries.UpdateGame(ctx, gen.UpdateGameParams{ + return p.queries.UpdateGame(ctx, repository.UpdateGameParams{ Board: g.BoardToJSON(), CurrentTurn: int64(g.CurrentTurn), Status: int64(g.Status), @@ -100,7 +100,7 @@ func (p *GamePersister) SaveGamePlayer(gameID string, player *game.Player, slot guestPlayerID = sql.NullString{String: string(player.ID), Valid: true} } - return p.queries.CreateGamePlayer(ctx, gen.CreateGamePlayerParams{ + return p.queries.CreateGamePlayer(ctx, repository.CreateGamePlayerParams{ GameID: gameID, UserID: userID, GuestPlayerID: guestPlayerID, @@ -144,10 +144,10 @@ func (p *GamePersister) DeleteGame(id string) error { // SnakePersister implements snake.Persister type SnakePersister struct { - queries *gen.Queries + queries *repository.Queries } -func NewSnakePersister(q *gen.Queries) *SnakePersister { +func NewSnakePersister(q *repository.Queries) *SnakePersister { return &SnakePersister{queries: q} } @@ -167,7 +167,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error { _, err := p.queries.GetSnakeGame(ctx, sg.ID) if err == sql.ErrNoRows { - _, err = p.queries.CreateSnakeGame(ctx, gen.CreateSnakeGameParams{ + _, err = p.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{ ID: sg.ID, Board: boardJSON, Status: int64(sg.Status), @@ -192,7 +192,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error { rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true} } - return p.queries.UpdateSnakeGame(ctx, gen.UpdateSnakeGameParams{ + return p.queries.UpdateSnakeGame(ctx, repository.UpdateSnakeGameParams{ Board: boardJSON, Status: int64(sg.Status), WinnerUserID: winnerUserID, @@ -247,7 +247,7 @@ func (p *SnakePersister) SaveSnakePlayer(gameID string, player *snake.Player) er guestPlayerID = sql.NullString{String: string(player.ID), Valid: true} } - return p.queries.CreateSnakePlayer(ctx, gen.CreateSnakePlayerParams{ + return p.queries.CreateSnakePlayer(ctx, repository.CreateSnakePlayerParams{ GameID: gameID, UserID: userID, GuestPlayerID: guestPlayerID, @@ -290,15 +290,15 @@ func (p *SnakePersister) DeleteSnakeGame(id string) error { } type ChatPersister struct { - queries *gen.Queries + queries *repository.Queries } -func NewChatPersister(q *gen.Queries) *ChatPersister { +func NewChatPersister(q *repository.Queries) *ChatPersister { return &ChatPersister{queries: q} } func (p *ChatPersister) SaveChatMessage(gameID string, msg ui.C4ChatMessage) error { - return p.queries.CreateChatMessage(context.Background(), gen.CreateChatMessageParams{ + return p.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{ GameID: gameID, Nickname: msg.Nickname, Color: int64(msg.Color), diff --git a/db/gen/chat.sql.go b/db/repository/chat.sql.go similarity index 98% rename from db/gen/chat.sql.go rename to db/repository/chat.sql.go index 2d956cd..1694e02 100644 --- a/db/gen/chat.sql.go +++ b/db/repository/chat.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: chat.sql -package gen +package repository import ( "context" diff --git a/db/gen/db.go b/db/repository/db.go similarity index 96% rename from db/gen/db.go rename to db/repository/db.go index d577e39..998bfd3 100644 --- a/db/gen/db.go +++ b/db/repository/db.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package gen +package repository import ( "context" diff --git a/db/gen/games.sql.go b/db/repository/games.sql.go similarity index 99% rename from db/gen/games.sql.go rename to db/repository/games.sql.go index 6bb412d..7883526 100644 --- a/db/gen/games.sql.go +++ b/db/repository/games.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: games.sql -package gen +package repository import ( "context" diff --git a/db/gen/models.go b/db/repository/models.go similarity index 98% rename from db/gen/models.go rename to db/repository/models.go index f539b79..de3f897 100644 --- a/db/gen/models.go +++ b/db/repository/models.go @@ -2,7 +2,7 @@ // versions: // sqlc v1.30.0 -package gen +package repository import ( "database/sql" diff --git a/db/gen/snake_games.sql.go b/db/repository/snake_games.sql.go similarity index 99% rename from db/gen/snake_games.sql.go rename to db/repository/snake_games.sql.go index ec1d547..1c80c2b 100644 --- a/db/gen/snake_games.sql.go +++ b/db/repository/snake_games.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: snake_games.sql -package gen +package repository import ( "context" diff --git a/db/gen/users.sql.go b/db/repository/users.sql.go similarity index 98% rename from db/gen/users.sql.go rename to db/repository/users.sql.go index 5adfa6e..4654293 100644 --- a/db/gen/users.sql.go +++ b/db/repository/users.sql.go @@ -3,7 +3,7 @@ // sqlc v1.30.0 // source: users.sql -package gen +package repository import ( "context" diff --git a/db/sqlc.yaml b/db/sqlc.yaml index 0c1840b..47ad0a3 100644 --- a/db/sqlc.yaml +++ b/db/sqlc.yaml @@ -5,5 +5,9 @@ sql: schema: "migrations" gen: go: - package: "gen" - out: "gen" + package: "repository" + out: "repository" + emit_db_tags: true + emit_json_tags: true + emit_result_struct_pointers: true + emit_pointers_for_null_types: true diff --git a/go.mod b/go.mod index 2c8644a..5b66895 100644 --- a/go.mod +++ b/go.mod @@ -3,27 +3,28 @@ module github.com/ryanhamamura/c4 go 1.25.4 require ( + github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de + github.com/alexedwards/scs/v2 v2.9.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - github.com/pressly/goose/v3 v3.26.0 + github.com/pressly/goose/v3 v3.27.0 + github.com/rs/zerolog v1.34.0 github.com/ryanhamamura/via v0.23.0 - golang.org/x/crypto v0.47.0 - modernc.org/sqlite v1.44.0 + golang.org/x/crypto v0.48.0 + modernc.org/sqlite v1.46.1 ) require ( github.com/CAFxX/httpcompression v0.0.9 // indirect - github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect - github.com/alexedwards/scs/v2 v2.9.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/delaneyj/toolbelt v0.9.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/go-tpm v0.9.7 // indirect - github.com/hookenz/gotailwind/v4 v4.1.18 // indirect - github.com/klauspost/compress v1.18.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/hookenz/gotailwind/v4 v4.2.1 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect @@ -33,18 +34,18 @@ require ( github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rs/zerolog v1.34.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/starfederation/datastar-go v1.0.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect maragu.dev/gomponents v1.2.0 // indirect - modernc.org/libc v1.67.4 // indirect + modernc.org/libc v1.68.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 4adb164..e30bc87 100644 --- a/go.sum +++ b/go.sum @@ -31,16 +31,17 @@ 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hookenz/gotailwind/v4 v4.1.18 h1:1h3XwTVx1dEBm6A0bcosAplNCde+DCmVJG0arLy5fBE= -github.com/hookenz/gotailwind/v4 v4.1.18/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE= +github.com/hookenz/gotailwind/v4 v4.2.1 h1:FpZLtAAbHH7wMvyGYT+01vTLFITGMGZGMtEbp7dd2dM= +github.com/hookenz/gotailwind/v4 v4.2.1/go.mod h1:IfiJtdp8ExV9HV2XUiVjRBvB3QewVXVKWoAGEcpjfNE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -65,21 +66,20 @@ github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OS 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/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= -github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= 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/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/ryanhamamura/via v0.21.2 h1:osR6peY/mZSl9SNPeEv6IvzGU0akkQfQzQJgA74+7mk= -github.com/ryanhamamura/via v0.21.2/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo= github.com/ryanhamamura/via v0.23.0 h1:0e7nytisazcWq7uxs6T27GM3FwzosCMenkxJd+78Lko= github.com/ryanhamamura/via v0.23.0/go.mod h1:rpJewNVG6tgginZN7Be3qqRuol70+v1sFCKD4UjHsQo= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= @@ -103,24 +103,24 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -129,18 +129,18 @@ maragu.dev/gomponents v1.2.0 h1:H7/N5htz1GCnhu0HB1GasluWeU2rJZOYztVEyN61iTc= maragu.dev/gomponents v1.2.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= +modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg= -modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= 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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -149,8 +149,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.44.0 h1:YjCKJnzZde2mLVy0cMKTSL4PxCmbIguOq9lGp8ZvGOc= -modernc.org/sqlite v1.44.0/go.mod h1:2Dq41ir5/qri7QJJJKNZcP4UF7TsX/KNeykYgPDtGhE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/logging/log.go b/logging/log.go new file mode 100644 index 0000000..4cdaed3 --- /dev/null +++ b/logging/log.go @@ -0,0 +1,41 @@ +// Package logging configures zerolog and provides HTTP request logging middleware. +package logging + +import ( + "io" + stdlog "log" + "os" + + "github.com/ryanhamamura/c4/config" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/rs/zerolog/pkgerrors" +) + +func SetupLogger(env config.Environment, level zerolog.Level) *zerolog.Logger { + zerolog.ErrorStackFieldName = "stack_trace" + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack + + zerolog.SetGlobalLevel(level) + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + var output io.Writer + switch env { + case config.Dev: + output = zerolog.ConsoleWriter{ + Out: os.Stderr, + TimeFormat: "2006/01/02 15:04:05", + } + case config.Prod: + output = os.Stderr + } + + logger := zerolog.New(output).With().Timestamp().Stack().Logger() + zerolog.DefaultContextLogger = &logger + log.Logger = logger + + stdlog.SetFlags(0) + stdlog.SetOutput(logger) + return &logger +} diff --git a/logging/middleware.go b/logging/middleware.go new file mode 100644 index 0000000..dd6404e --- /dev/null +++ b/logging/middleware.go @@ -0,0 +1,126 @@ +package logging + +import ( + "fmt" + "net/http" + "time" + + "github.com/ryanhamamura/c4/config" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +const ( + ansiReset = "\033[0m" + ansiBrightRed = "\033[31;1m" + ansiBrightGreen = "\033[32;1m" + ansiBrightYellow = "\033[33;1m" + ansiBrightMagenta = "\033[35;1m" + ansiBrightCyan = "\033[36;1m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiRed = "\033[31m" +) + +func colorStatus(status int, useColor bool) string { + s := fmt.Sprintf("%d", status) + if !useColor { + return s + } + switch { + case status < 200: + return ansiBrightGreen + s + ansiReset + case status < 300: + return ansiBrightGreen + s + ansiReset + case status < 400: + return ansiBrightCyan + s + ansiReset + case status < 500: + return ansiBrightYellow + s + ansiReset + default: + return ansiBrightRed + s + ansiReset + } +} + +func colorMethod(method string, useColor bool) string { + if !useColor { + return method + } + return ansiBrightMagenta + method + ansiReset +} + +func colorLatency(d time.Duration, useColor bool) string { + s := d.String() + if !useColor { + return s + } + switch { + case d < 500*time.Millisecond: + return ansiGreen + s + ansiReset + case d < 5*time.Second: + return ansiYellow + s + ansiReset + default: + return ansiRed + s + ansiReset + } +} + +type responseWriter struct { + http.ResponseWriter + status int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} + +func RequestLogger(logger *zerolog.Logger, env config.Environment) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &responseWriter{ResponseWriter: w} + + next.ServeHTTP(rw, r) + + status := rw.status + if status == 0 { + status = http.StatusOK + } + l := log.Ctx(r.Context()) + if l.GetLevel() == zerolog.Disabled { + l = logger + } + + var evt *zerolog.Event + switch { + case status < 400: + evt = l.Info() + case status < 500: + evt = l.Warn() + case status < 600: + evt = l.Error() + default: + evt = l.Info() + } + + latency := time.Since(start) + switch env { + case config.Dev: + useColor := true + evt.Msg(fmt.Sprintf("%s %s %s [%s]", + colorStatus(status, useColor), + colorMethod(r.Method, useColor), + r.URL.Path, + colorLatency(latency, useColor), + )) + default: + evt. + Int("status", status). + Str("method", r.Method). + Str("path", r.URL.Path). + Dur("latency", latency). + Msg("request") + } + }) + } +} diff --git a/main.go b/main.go index fa6bce1..e324692 100644 --- a/main.go +++ b/main.go @@ -8,20 +8,20 @@ import ( "encoding/hex" "encoding/json" "io/fs" - "log" - "os" "sync" "time" - "github.com/google/uuid" - "github.com/joho/godotenv" - "github.com/ryanhamamura/c4/auth" + "github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/c4/db" - "github.com/ryanhamamura/c4/db/gen" + "github.com/ryanhamamura/c4/db/repository" "github.com/ryanhamamura/c4/game" + "github.com/ryanhamamura/c4/logging" "github.com/ryanhamamura/c4/snake" "github.com/ryanhamamura/c4/ui" + + "github.com/google/uuid" + "github.com/rs/zerolog/log" "github.com/ryanhamamura/via" "github.com/ryanhamamura/via/h" ) @@ -29,7 +29,7 @@ import ( var ( store = game.NewGameStore() snakeStore = snake.NewSnakeStore() - queries *gen.Queries + queries *repository.Queries chatPersister *db.ChatPersister ) @@ -43,39 +43,35 @@ func DaisyUIPlugin(v *via.V) { v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/assets/css/output.css?v="+version))) } -func port() string { - if p := os.Getenv("PORT"); p != "" { - return p - } - return "7331" -} - func main() { - _ = godotenv.Load() + cfg := config.Global + logger := logging.SetupLogger(cfg.Environment, cfg.LogLevel) - if err := os.MkdirAll("data", 0o755); err != nil { - log.Fatal(err) + cleanupDB, err := db.Init(cfg.DBPath) + if err != nil { + log.Fatal().Err(err).Msg("initializing database") } - if err := db.Init("data/c4.db"); err != nil { - log.Fatal(err) - } - queries = gen.New(db.DB) + defer cleanupDB() + + queries = repository.New(db.DB) store.SetPersister(db.NewGamePersister(queries)) snakeStore.SetPersister(db.NewSnakePersister(queries)) chatPersister = db.NewChatPersister(queries) sessionManager, err := via.NewSQLiteSessionManager(db.DB) if err != nil { - log.Fatal(err) + log.Fatal().Err(err).Msg("creating session manager") } + _ = logger + v := via.New() v.Config(via.Options{ - LogLevel: via.LogLevelDebug, - DocumentTitle: "Game Lobby", - ServerAddress: ":" + port(), - SessionManager: sessionManager, - Plugins: []via.Plugin{DaisyUIPlugin}, + LogLevel: via.LogLevelDebug, + DocumentTitle: "Game Lobby", + ServerAddress: ":" + cfg.Port, + SessionManager: sessionManager, + Plugins: []via.Plugin{DaisyUIPlugin}, }) subFS, _ := fs.Sub(assets, "assets") @@ -315,7 +311,7 @@ func main() { ctx := context.Background() id := uuid.New().String() - user, err := queries.CreateUser(ctx, gen.CreateUserParams{ + user, err := queries.CreateUser(ctx, repository.CreateUserParams{ ID: id, Username: username.String(), PasswordHash: hash, diff --git a/testutil/db.go b/testutil/db.go new file mode 100644 index 0000000..d2cb2f9 --- /dev/null +++ b/testutil/db.go @@ -0,0 +1,47 @@ +// Package testutil provides composable test helpers for spinning up +// real infrastructure (in-memory SQLite, session managers) in +// integration tests. +package testutil + +import ( + "database/sql" + "io/fs" + "testing" + + "github.com/ryanhamamura/c4/db" + "github.com/ryanhamamura/c4/db/repository" + + "github.com/pressly/goose/v3" + _ "modernc.org/sqlite" +) + +// NewTestDB opens an in-memory SQLite database with the same pragmas as +// production, runs all goose migrations, and returns the raw connection +// alongside the sqlc Queries handle. The database is closed automatically +// when the test finishes. +func NewTestDB(t *testing.T) (*sql.DB, *repository.Queries) { + t.Helper() + + pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)" + database, err := goose.OpenDBWithDriver("sqlite", ":memory:"+pragmas) + if err != nil { + t.Fatalf("open test database: %v", err) + } + t.Cleanup(func() { database.Close() }) //nolint:errcheck // test cleanup + + if err := database.Ping(); err != nil { + t.Fatalf("ping test database: %v", err) + } + + sub, err := fs.Sub(db.MigrationFS, "migrations") + if err != nil { + t.Fatalf("migrations sub fs: %v", err) + } + goose.SetBaseFS(sub) + + if err := goose.Up(database, "."); err != nil { + t.Fatalf("run migrations: %v", err) + } + + return database, repository.New(database) +} diff --git a/testutil/sessions.go b/testutil/sessions.go new file mode 100644 index 0000000..5753f7d --- /dev/null +++ b/testutil/sessions.go @@ -0,0 +1,31 @@ +package testutil + +import ( + "database/sql" + "net/http" + "testing" + "time" + + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" +) + +// NewTestSessionManager creates an SCS session manager backed by the +// provided SQLite database. The background cleanup goroutine is stopped +// automatically when the test finishes. +func NewTestSessionManager(t *testing.T, db *sql.DB) *scs.SessionManager { + t.Helper() + + store := sqlite3store.New(db) + t.Cleanup(func() { store.StopCleanup() }) + + sm := scs.New() + sm.Store = store + sm.Lifetime = 30 * 24 * time.Hour + sm.Cookie.Path = "/" + sm.Cookie.HttpOnly = true + sm.Cookie.Secure = false + sm.Cookie.SameSite = http.SameSiteLaxMode + + return sm +} diff --git a/ui/status.go b/ui/status.go index 37a0e8c..3fb3793 100644 --- a/ui/status.go +++ b/ui/status.go @@ -1,8 +1,7 @@ package ui import ( - "os" - + "github.com/ryanhamamura/c4/config" "github.com/ryanhamamura/c4/game" "github.com/ryanhamamura/via/h" ) @@ -118,10 +117,7 @@ func PlayerInfo(g *game.Game, myColor int) h.H { } func getBaseURL() string { - if url := os.Getenv("APP_URL"); url != "" { - return url - } - return "https://games.adriatica.io" + return config.Global.AppURL } func InviteLink(gameID string) h.H {