diff --git a/.gitignore b/.gitignore index adaa398..5620294 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ # data -data/analytics.db +data # debug __debug_* -# bench +# build out +tinyauth-analytics + +# benchmarks bench \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 7eced9b..436b781 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "mode": "auto", "program": "${fileDirname}", "env": { - "DB_PATH": "./data/analytics.db", + "DATABASE_PATH": "./data/analytics.db", "PORT": "8080", "ADDRESS": "0.0.0.0", "TRUSTED_PROXIES": "0.0.0.0", diff --git a/Dockerfile b/Dockerfile index e8206fe..0cecdf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,12 @@ COPY go.sum ./ RUN go mod download +COPY ./cache.go ./ +COPY ./health_handler.go ./ +COPY ./instances_handler.go ./ COPY ./main.go ./ -COPY ./internal ./internal +COPY ./rate_limiter.go ./ +COPY ./database ./database RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" diff --git a/README.md b/README.md index 8fbba2c..968ce87 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A simple server to transparently collect version information from Tinyauth insta ## How does it work -Every Tinyauth instance runs a goroutine (unless you choose to opt-out) that does a "heartbeat" every 12 hours indicating the instance is still alive. The heartbeat contains the UUID generated by Tinyauth on start up and the version information. The server stores them in the SQLite database alongside with the last seen date. When you request all the instances, the server responds with an array containing the versions, UUIDs and last seen dates. In order to increase transparency, if the `LOG_LEVEL` is configured to `debug` or `trace`, the server will return a warning both in the instances and heartbeat endpoint in order to inform the user that sensitive information like IP addresses may be logged. +Every Tinyauth instance runs a goroutine (unless you choose to opt-out) that does a "heartbeat" every 12 hours indicating the instance is still alive. The heartbeat contains the UUID generated by Tinyauth on start up and the version information. The server stores them in the SQLite database alongside with the last seen date. When you request all the instances, the server responds with an array containing the versions, UUIDs and last seen dates. ## Running @@ -18,15 +18,14 @@ docker compose up -d The server is configured using environment variables, the following options are supported: -| Name | Type | Description | Default | -| ---------------------- | ------------ | ---------------------------------------------------------- | -------------------- | -| `DB_PATH` | string | Path to the SQLite database file. | `/data/analytics.db` | -| `PORT` | number | The port to run the server on. | `8080` | -| `ADDRESS` | string | The address to bind the server to. | `0.0.0.0` | -| `RATE_LIMIT_COUNT` | number | Maximum number of requests per minute per IP. | `3` | -| `CORS_ALLOWED_ORIGINS` | string/array | Comma-separated list of allowed CORS origins. | `*` | -| `TRUSTED_PROXIES` | string/array | Comma-separated list of trusted proxy IPs. | `""` (empty) | -| `LOG_LEVEL` | string | Log level (trace, debug, info, warn, error, fatal, panic). | `info` | +| Name | Type | Description | Default | +| ---------------------- | ------------ | --------------------------------------------- | -------------------- | +| `DATABASE_PATH` | string | Path to the SQLite database file. | `/data/analytics.db` | +| `PORT` | number | The port to run the server on. | `8080` | +| `ADDRESS` | string | The address to bind the server to. | `0.0.0.0` | +| `RATE_LIMIT_COUNT` | number | Maximum number of requests per minute per IP. | `3` | +| `CORS_ALLOWED_ORIGINS` | string/array | Comma-separated list of allowed CORS origins. | `*` | +| `TRUSTED_PROXIES` | string/array | Comma-separated list of trusted proxy IPs. | `` | ## Contributing diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..8e1864e --- /dev/null +++ b/cache.go @@ -0,0 +1,85 @@ +package main + +import ( + "sync" + "time" +) + +type cacheField struct { + value any + expire int64 +} + +type Cache struct { + cache map[string]cacheField + mutex sync.RWMutex +} + +func NewCache() *Cache { + cache := &Cache{ + cache: make(map[string]cacheField), + } + cache.cleanup() + return cache +} + +func (c *Cache) Set(key string, value any, ttl int64) { + c.mutex.Lock() + defer c.mutex.Unlock() + + expire := time.Now().Add(time.Duration(ttl) * time.Second).Unix() + + c.cache[key] = cacheField{ + value: value, + expire: expire, + } +} + +func (c *Cache) Get(key string) (any, bool) { + c.mutex.RLock() + + field, ok := c.cache[key] + + if !ok { + c.mutex.RUnlock() + return nil, false + } + + if time.Now().Unix() > field.expire { + c.mutex.RUnlock() + c.Delete(key) + return nil, false + } + + c.mutex.RUnlock() + return field.value, true +} + +func (c *Cache) Delete(key string) { + c.mutex.Lock() + defer c.mutex.Unlock() + delete(c.cache, key) +} + +func (c *Cache) Flush() { + c.mutex.Lock() + defer c.mutex.Unlock() + c.cache = make(map[string]cacheField, 0) +} + +func (c *Cache) cleanup() { + go func() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for range ticker.C { + c.mutex.Lock() + for key, field := range c.cache { + if time.Now().Unix() > field.expire { + delete(c.cache, key) + } + } + c.mutex.Unlock() + } + }() +} diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/queries/db.go b/database/queries/db.go new file mode 100644 index 0000000..51576a5 --- /dev/null +++ b/database/queries/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package queries + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/database/queries/models.go b/database/queries/models.go new file mode 100644 index 0000000..1f4e635 --- /dev/null +++ b/database/queries/models.go @@ -0,0 +1,11 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package queries + +type Instance struct { + UUID string + Version string + LastSeen int64 +} diff --git a/database/queries/query.sql.go b/database/queries/query.sql.go new file mode 100644 index 0000000..c129168 --- /dev/null +++ b/database/queries/query.sql.go @@ -0,0 +1,123 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: query.sql + +package queries + +import ( + "context" +) + +const createInstance = `-- name: CreateInstance :exec +INSERT INTO instances ( + uuid, version, last_seen +) VALUES ( + ?, ?, ? +) +` + +type CreateInstanceParams struct { + UUID string + Version string + LastSeen int64 +} + +func (q *Queries) CreateInstance(ctx context.Context, arg CreateInstanceParams) error { + _, err := q.db.ExecContext(ctx, createInstance, arg.UUID, arg.Version, arg.LastSeen) + return err +} + +const deleteInstance = `-- name: DeleteInstance :exec +DELETE FROM instances +WHERE uuid = ? +` + +func (q *Queries) DeleteInstance(ctx context.Context, uuid string) error { + _, err := q.db.ExecContext(ctx, deleteInstance, uuid) + return err +} + +const deleteOldInstances = `-- name: DeleteOldInstances :many +DELETE FROM instances +WHERE last_seen < ? +RETURNING uuid, version, last_seen +` + +func (q *Queries) DeleteOldInstances(ctx context.Context, lastSeen int64) ([]Instance, error) { + rows, err := q.db.QueryContext(ctx, deleteOldInstances, lastSeen) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Instance + for rows.Next() { + var i Instance + if err := rows.Scan(&i.UUID, &i.Version, &i.LastSeen); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAllInstances = `-- name: GetAllInstances :many +SELECT uuid, version, last_seen FROM instances +` + +func (q *Queries) GetAllInstances(ctx context.Context) ([]Instance, error) { + rows, err := q.db.QueryContext(ctx, getAllInstances) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Instance + for rows.Next() { + var i Instance + if err := rows.Scan(&i.UUID, &i.Version, &i.LastSeen); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getInstance = `-- name: GetInstance :one +SELECT uuid, version, last_seen FROM instances +WHERE uuid = ? LIMIT 1 +` + +func (q *Queries) GetInstance(ctx context.Context, uuid string) (Instance, error) { + row := q.db.QueryRowContext(ctx, getInstance, uuid) + var i Instance + err := row.Scan(&i.UUID, &i.Version, &i.LastSeen) + return i, err +} + +const updateInstance = `-- name: UpdateInstance :exec +UPDATE instances +set last_seen = ? +WHERE uuid = ? +` + +type UpdateInstanceParams struct { + LastSeen int64 + UUID string +} + +func (q *Queries) UpdateInstance(ctx context.Context, arg UpdateInstanceParams) error { + _, err := q.db.ExecContext(ctx, updateInstance, arg.LastSeen, arg.UUID) + return err +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1894abd..1af6aa2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,27 +12,26 @@ services: - RATE_LIMIT_COUNT=15 - CORS_ALLOWED_ORIGINS=* - TRUSTED_PROXIES=0.0.0.0 - - LOG_LEVEL=debug volumes: - ./data:/data ports: - 8080:8080 restart: unless-stopped - analytics-dashboard: - build: - context: ./dashboard - dockerfile: Dockerfile - args: - - VERSION=development - environment: - - PORT=8090 - - ADDRESS=0.0.0.0 - - API_SERVER=http://tinyauth-analytics:8080 - - PAGE_SIZE=10 - - REFRESH_INTERVAL=1 - ports: - - 8090:8090 - restart: unless-stopped - depends_on: - - tinyauth-analytics + # analytics-dashboard: + # build: + # context: ./dashboard + # dockerfile: Dockerfile + # args: + # - VERSION=development + # environment: + # - PORT=8090 + # - ADDRESS=0.0.0.0 + # - API_SERVER=http://tinyauth-analytics:8080 + # - PAGE_SIZE=10 + # - REFRESH_INTERVAL=1 + # ports: + # - 8090:8090 + # restart: unless-stopped + # depends_on: + # - tinyauth-analytics diff --git a/go.mod b/go.mod index 696775a..9ca16fb 100644 --- a/go.mod +++ b/go.mod @@ -3,47 +3,22 @@ module tinyauth-analytics go 1.24.3 require ( - github.com/gin-contrib/cors v1.7.6 - github.com/gin-gonic/gin v1.11.0 - github.com/glebarez/sqlite v1.11.0 - github.com/golang-migrate/migrate/v4 v4.19.0 - github.com/rs/zerolog v1.34.0 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/cors v1.2.2 + github.com/go-chi/render v1.0.3 github.com/spf13/viper v1.21.0 - gorm.io/gorm v1.31.1 + modernc.org/sqlite v1.40.1 ) require ( - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect - github.com/cloudwego/base64x v0.1.6 // indirect + github.com/ajg/form v1.5.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect - github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -51,22 +26,11 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.uber.org/mock v0.5.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect - modernc.org/libc v1.66.3 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.28.0 // indirect + modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.39.0 // indirect ) diff --git a/go.sum b/go.sum index 244b96d..523890c 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,5 @@ -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -14,94 +8,36 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= -github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= -github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= -github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= -github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= -github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -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-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= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= -github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -114,68 +50,42 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= -github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -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/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +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/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= 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= @@ -184,8 +94,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.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= -modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= 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/health_handler.go b/health_handler.go new file mode 100644 index 0000000..2a96028 --- /dev/null +++ b/health_handler.go @@ -0,0 +1,21 @@ +package main + +import ( + "net/http" + + "github.com/go-chi/render" +) + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (h *HealthHandler) health(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + render.JSON(w, r, map[string]string{ + "status": "200", + "message": "up and running", + }) +} diff --git a/instances_handler.go b/instances_handler.go new file mode 100644 index 0000000..debfa0d --- /dev/null +++ b/instances_handler.go @@ -0,0 +1,116 @@ +package main + +import ( + "database/sql" + "errors" + "log/slog" + "net/http" + "time" + "tinyauth-analytics/database/queries" + + "github.com/go-chi/render" +) + +type InstancesHandler struct { + queries *queries.Queries +} + +func NewInstancesHandler(queries *queries.Queries) *InstancesHandler { + return &InstancesHandler{ + queries: queries, + } +} + +func (h *InstancesHandler) GetInstances(w http.ResponseWriter, r *http.Request) { + instances, err := h.queries.GetAllInstances(r.Context()) + + if err != nil { + slog.Error("failed to get instances", "error", err) + w.WriteHeader(http.StatusInternalServerError) + render.JSON(w, r, map[string]string{ + "status": "500", + "message": "Failed to retrieve instances", + }) + return + } + + w.WriteHeader(http.StatusOK) + render.JSON(w, r, map[string]any{ + "status": "200", + "instances": instances, + "total": len(instances), + }) +} + +func (h *InstancesHandler) Heartbeat(w http.ResponseWriter, r *http.Request) { + var heartbeat struct { + UUID string `json:"uuid"` + Version string `json:"version"` + } + + err := render.DecodeJSON(r.Body, &heartbeat) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + render.JSON(w, r, map[string]string{ + "status": "400", + "message": "Invalid request payload", + }) + return + } + + _, err = h.queries.GetInstance(r.Context(), heartbeat.UUID) + + if err != nil && !errors.Is(err, sql.ErrNoRows) { + slog.Error("failed to get instance", "error", err) + w.WriteHeader(http.StatusInternalServerError) + render.JSON(w, r, map[string]string{ + "status": "500", + "message": "Failed to retrieve instance", + }) + return + } + + if err != nil && errors.Is(err, sql.ErrNoRows) { + err = h.queries.CreateInstance(r.Context(), queries.CreateInstanceParams{ + UUID: heartbeat.UUID, + Version: heartbeat.Version, + LastSeen: time.Now().UnixMilli(), + }) + if err != nil { + slog.Error("failed to create instance", "error", err) + w.WriteHeader(http.StatusInternalServerError) + render.JSON(w, r, map[string]string{ + "status": "500", + "message": "Failed to create instance", + }) + return + } + w.WriteHeader(http.StatusCreated) + render.JSON(w, r, map[string]string{ + "status": "201", + "message": "Instance created", + }) + return + } + + err = h.queries.UpdateInstance(r.Context(), queries.UpdateInstanceParams{ + LastSeen: time.Now().UnixMilli(), + UUID: heartbeat.UUID, + }) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + render.JSON(w, r, map[string]string{ + "status": "500", + "message": "Failed to update instance", + }) + return + } + + w.WriteHeader(http.StatusOK) + render.JSON(w, r, map[string]string{ + "status": "200", + "message": "Instance updated", + }) +} diff --git a/internal/controller/health_controller.go b/internal/controller/health_controller.go deleted file mode 100644 index b218be9..0000000 --- a/internal/controller/health_controller.go +++ /dev/null @@ -1,25 +0,0 @@ -package controller - -import "github.com/gin-gonic/gin" - -type HealthController struct { - router *gin.RouterGroup -} - -func NewHealthController(router *gin.RouterGroup) *HealthController { - return &HealthController{ - router: router, - } -} - -func (hc *HealthController) SetupRoutes() { - hc.router.GET("/healthz", hc.health) - hc.router.HEAD("/healthz", hc.health) -} - -func (hc *HealthController) health(c *gin.Context) { - c.JSON(200, map[string]any{ - "status": 200, - "message": "OK", - }) -} diff --git a/internal/controller/instances_controller.go b/internal/controller/instances_controller.go deleted file mode 100644 index 3953130..0000000 --- a/internal/controller/instances_controller.go +++ /dev/null @@ -1,144 +0,0 @@ -package controller - -import ( - "context" - "errors" - "strings" - "time" - "tinyauth-analytics/internal/model" - - "github.com/gin-gonic/gin" - "github.com/rs/zerolog/log" - "gorm.io/gorm" -) - -type RateLimit interface { - Middleware() gin.HandlerFunc -} - -type Hearbeat struct { - Version string `json:"version"` - UUID string `json:"uuid"` -} - -type InstancesController struct { - database *gorm.DB - router *gin.RouterGroup - rateLimit RateLimit - warn string -} - -func NewInstancesController(router *gin.RouterGroup, database *gorm.DB, rateLimit RateLimit, warn string) *InstancesController { - return &InstancesController{ - database: database, - router: router, - rateLimit: rateLimit, - warn: warn, - } -} - -func (ic *InstancesController) SetupRoutes() { - instancesGroup := ic.router.Group("/instances") - instancesGroup.GET("/all", ic.listAllInstances) - instancesGroup.POST("/heartbeat", ic.rateLimit.Middleware(), ic.heartbeat) -} - -func (ic *InstancesController) listAllInstances(c *gin.Context) { - ctx := context.Background() - - instances, err := gorm.G[model.Instance](ic.database).Find(ctx) - - if err != nil { - log.Error().Err(err).Msg("failed to fetch instances") - c.JSON(500, gin.H{ - "status": 500, - "message": "Database error", - }) - return - } - - c.JSON(200, map[string]any{ - "status": 200, - "total": len(instances), - "instances": instances, - "warning": ic.warn, - }) -} - -func (ic *InstancesController) heartbeat(c *gin.Context) { - var heartbeat Hearbeat - - if err := c.BindJSON(&heartbeat); err != nil { - c.JSON(400, gin.H{ - "status": 400, - "message": "Invalid request body", - }) - return - } - - if strings.TrimSpace(heartbeat.UUID) == "" || strings.TrimSpace(heartbeat.Version) == "" { - c.JSON(400, gin.H{ - "status": 400, - "message": "Missing required fields", - }) - return - } - - ctx := context.Background() - instance, err := gorm.G[model.Instance](ic.database).Where("uuid = ?", heartbeat.UUID).First(ctx) - - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - log.Error().Err(err).Msg("failed to fetch instance") - c.JSON(500, gin.H{ - "status": 500, - "message": "Database error", - }) - return - } - - t := time.Now().UnixMilli() - - if errors.Is(err, gorm.ErrRecordNotFound) { - err := gorm.G[model.Instance](ic.database).Create(ctx, &model.Instance{ - UUID: heartbeat.UUID, - Version: heartbeat.Version, - LastSeen: t, - }) - - if err != nil { - log.Error().Err(err).Msg("failed to create instance") - c.JSON(500, gin.H{ - "status": 500, - "message": "Database error", - }) - return - } - - c.JSON(201, gin.H{ - "status": 201, - "message": "Instance created", - "warning": ic.warn, - }) - return - } - - _, err = gorm.G[model.Instance](ic.database).Where("id = ?", instance.ID).Updates(ctx, model.Instance{ - Version: heartbeat.Version, - LastSeen: t, - }) - - if err != nil { - log.Error().Err(err).Msg("failed to update instance") - c.JSON(500, gin.H{ - "status": 500, - "message": "Database error", - }) - return - } - - c.JSON(200, gin.H{ - "status": 200, - "message": "Instance updated", - "warning": ic.warn, - }) -} diff --git a/internal/middleware/rate_limit_middleware.go b/internal/middleware/rate_limit_middleware.go deleted file mode 100644 index 40e590d..0000000 --- a/internal/middleware/rate_limit_middleware.go +++ /dev/null @@ -1,91 +0,0 @@ -package middleware - -import ( - "sync" - "time" - "tinyauth-analytics/internal/service" - - "fmt" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -type RateLimitMiddleware struct { - database *gorm.DB - cache *service.CacheService - mutex sync.RWMutex - rateLimitCount int -} - -func NewRateLimitMiddleware(database *gorm.DB, cache *service.CacheService, rateLimitCount int) *RateLimitMiddleware { - return &RateLimitMiddleware{ - database: database, - cache: cache, - rateLimitCount: rateLimitCount, - } -} - -func (m *RateLimitMiddleware) Init() error { - return nil -} - -func (m *RateLimitMiddleware) Middleware() gin.HandlerFunc { - return func(c *gin.Context) { - m.mutex.Lock() - defer m.mutex.Unlock() - - clientIP := m.getClientIP(c) - - if clientIP == "" { - c.JSON(500, gin.H{ - "status": 500, - "message": "Failed to determine client IP", - }) - c.Abort() - return - } - - value, exists := m.cache.Get(clientIP) - - c.Header("x-ratelimit-limit", fmt.Sprint(m.rateLimitCount)) - c.Header("x-ratelimit-reset", fmt.Sprint(time.Now().Add(time.Duration(24)*time.Hour).Unix())) - - if !exists { - m.cache.Set(clientIP, 1, 86400) // 1 day TTL - c.Header("x-ratelimit-remaining", fmt.Sprint(m.rateLimitCount-1)) - c.Header("x-ratelimit-used", fmt.Sprint(1)) - c.Next() - return - } - - used := value.(int) + 1 - - if used > m.rateLimitCount { - c.Header("x-ratelimit-remaining", fmt.Sprint(0)) - c.Header("x-ratelimit-used", fmt.Sprint(used)) - c.JSON(429, gin.H{ - "status": 429, - "message": "Rate limit exceeded", - }) - c.Abort() - return - } - - m.cache.Set(clientIP, used, 86400) // 1 day TTL - - c.Header("x-ratelimit-remaining", fmt.Sprint(m.rateLimitCount-used)) - c.Header("x-ratelimit-used", fmt.Sprint(used)) - c.Next() - } -} - -func (m *RateLimitMiddleware) getClientIP(c *gin.Context) string { - cfConnectingIP := c.GetHeader("CF-Connecting-IP") - - if cfConnectingIP != "" { - return cfConnectingIP - } - - return c.ClientIP() -} diff --git a/internal/middleware/zerolog_middleware.go b/internal/middleware/zerolog_middleware.go deleted file mode 100644 index 5f7b621..0000000 --- a/internal/middleware/zerolog_middleware.go +++ /dev/null @@ -1,57 +0,0 @@ -package middleware - -import ( - "time" - - "github.com/gin-gonic/gin" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -type ZerologMiddleware struct { - level zerolog.Level -} - -func NewZerologMiddleware(level zerolog.Level) *ZerologMiddleware { - if level == zerolog.DebugLevel || level == zerolog.TraceLevel { - log.Warn().Msg("Verbose log level is enabled. This may expose sensitive information in the logs.") - } - - return &ZerologMiddleware{ - level: level, - } -} - -func (zm *ZerologMiddleware) Middleware() gin.HandlerFunc { - return func(c *gin.Context) { - tStart := time.Now() - - c.Next() - - code := c.Writer.Status() - address := c.Request.RemoteAddr - clientIP := c.ClientIP() - method := c.Request.Method - path := c.Request.URL.Path - - latency := time.Since(tStart).String() - - subLogger := log.With().Str("method", method). - Str("path", path). - Int("status", code). - Str("latency", latency).Logger() - - if zm.level == zerolog.DebugLevel { - subLogger = subLogger.With().Str("address", address).Str("client_ip", clientIP).Logger() - } - - switch { - case code >= 400 && code < 500: - subLogger.Warn().Msg("Client Error") - case code >= 500: - subLogger.Error().Msg("Server Error") - default: - subLogger.Info().Msg("Request") - } - } -} diff --git a/internal/model/instance_model.go b/internal/model/instance_model.go deleted file mode 100644 index d63cac4..0000000 --- a/internal/model/instance_model.go +++ /dev/null @@ -1,8 +0,0 @@ -package model - -type Instance struct { - ID int64 `gorm:"column:id" json:"-"` - UUID string `gorm:"column:uuid" json:"uuid"` - Version string `gorm:"column:version" json:"version"` - LastSeen int64 `gorm:"column:last_seen" json:"last_seen"` -} diff --git a/internal/service/cache_service.go b/internal/service/cache_service.go deleted file mode 100644 index 85ba61d..0000000 --- a/internal/service/cache_service.go +++ /dev/null @@ -1,72 +0,0 @@ -package service - -import ( - "slices" - "sync" - "time" -) - -type cacheField struct { - key string - value any - expire int64 -} - -type CacheService struct { - cache []cacheField - mutex sync.RWMutex -} - -func NewCacheService() *CacheService { - return &CacheService{ - cache: make([]cacheField, 0), - } -} - -func (cs *CacheService) Set(key string, value any, ttl int64) { - cs.mutex.Lock() - defer cs.mutex.Unlock() - expire := time.Now().Add(time.Duration(ttl) * time.Second).Unix() - for i, field := range cs.cache { - if field.key == key { - cs.cache[i].value = value - cs.cache[i].expire = expire - return - } - } - cs.cache = append(cs.cache, cacheField{ - key: key, - value: value, - expire: expire, - }) -} - -func (cs *CacheService) Get(key string) (any, bool) { - cs.mutex.RLock() - defer cs.mutex.RUnlock() - for _, field := range cs.cache { - if field.key == key { - if time.Now().Unix() > field.expire { - cs.Delete(key) - return nil, false - } - return field.value, true - } - } - return nil, false -} - -func (cs *CacheService) Delete(key string) { - cs.mutex.Lock() - defer cs.mutex.Unlock() - for i, field := range cs.cache { - if field.key == key { - cs.cache = slices.Delete(cs.cache, i, i+1) - return - } - } -} - -func (cs *CacheService) Clear() { - cs.cache = make([]cacheField, 0) -} diff --git a/internal/service/database_service.go b/internal/service/database_service.go deleted file mode 100644 index 871fa74..0000000 --- a/internal/service/database_service.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import ( - "database/sql" - "embed" - - "github.com/glebarez/sqlite" - "github.com/golang-migrate/migrate/v4" - sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" - "github.com/golang-migrate/migrate/v4/source/iofs" - "gorm.io/gorm" -) - -// Migrations -// -//go:embed migrations/*.sql -var migrationsFS embed.FS - -type DatabaseServiceConfig struct { - DatabasePath string -} - -type DatabaseService struct { - config DatabaseServiceConfig - database *gorm.DB -} - -func NewDatabaseService(config DatabaseServiceConfig) *DatabaseService { - return &DatabaseService{ - config: config, - } -} - -func (ds *DatabaseService) Init() error { - gormDB, err := gorm.Open(sqlite.Open(ds.config.DatabasePath), &gorm.Config{}) - - if err != nil { - return err - } - - sqlDB, err := gormDB.DB() - - if err != nil { - return err - } - - sqlDB.SetMaxOpenConns(1) - - err = ds.migrateDatabase(sqlDB) - - if err != nil && err != migrate.ErrNoChange { - return err - } - - ds.database = gormDB - return nil -} - -func (ds *DatabaseService) GetDatabase() *gorm.DB { - return ds.database -} - -func (ds *DatabaseService) migrateDatabase(sqlDB *sql.DB) error { - data, err := iofs.New(migrationsFS, "migrations") - - if err != nil { - return err - } - - target, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{}) - - if err != nil { - return err - } - - migrator, err := migrate.NewWithInstance("iofs", data, "tinyauth-analytics", target) - - if err != nil { - return err - } - - return migrator.Up() -} diff --git a/internal/service/migrations/000001_init_sqlite.down.sql b/internal/service/migrations/000001_init_sqlite.down.sql deleted file mode 100644 index 674eef3..0000000 --- a/internal/service/migrations/000001_init_sqlite.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS "instances"; \ No newline at end of file diff --git a/internal/service/migrations/000001_init_sqlite.up.sql b/internal/service/migrations/000001_init_sqlite.up.sql deleted file mode 100644 index 11261c7..0000000 --- a/internal/service/migrations/000001_init_sqlite.up.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS "instances" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "uuid" TEXT NOT NULL, - "version" TEXT NOT NULL, - "last_seen" INTEGER NOT NULL -); diff --git a/main.go b/main.go index 628cef4..5b4ae02 100644 --- a/main.go +++ b/main.go @@ -2,140 +2,131 @@ package main import ( "context" + "database/sql" + "fmt" + "log/slog" + "net/http" "os" "time" - "tinyauth-analytics/internal/controller" - "tinyauth-analytics/internal/middleware" - "tinyauth-analytics/internal/model" - "tinyauth-analytics/internal/service" - - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" + "tinyauth-analytics/database/queries" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" "github.com/spf13/viper" - "gorm.io/gorm" + _ "modernc.org/sqlite" ) -var version = "development" - -type config struct { - DatabasePath string `mapstructure:"db_path"` - Port string `mapstructure:"port"` +type Config struct { + Port int `mapstructure:"port"` Address string `mapstructure:"address"` RateLimitCount int `mapstructure:"rate_limit_count"` - CORSAllowedOrigins []string `mapstructure:"cors_allowed_origins"` + DatabasePath string `mapstructure:"database_path"` TrustedProxies []string `mapstructure:"trusted_proxies"` - LogLevel string `mapstructure:"log_level"` + CORSAllowedOrigins []string `mapstructure:"cors_allowed_origins"` } func main() { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Timestamp().Caller().Logger() - v := viper.New() - v.AutomaticEnv() - - v.SetDefault("db_path", "/data/analytics.db") - v.SetDefault("port", "8080") + v.SetDefault("port", 8080) v.SetDefault("address", "0.0.0.0") v.SetDefault("rate_limit_count", 3) - v.SetDefault("cors_allowed_origins", "*") - v.SetDefault("trusted_proxies", "") - v.SetDefault("log_level", "info") + v.SetDefault("database_path", "analytics.db") + v.SetDefault("trusted_proxies", []string{""}) + v.SetDefault("cors_allowed_origins", []string{"*"}) - var conf config + v.AutomaticEnv() - if err := v.Unmarshal(&conf); err != nil { - log.Fatal().Err(err).Msg("failed to parse config") - } + var config Config - warn := "no warning" - - switch conf.LogLevel { - case "trace": - log.Logger = log.Level(zerolog.TraceLevel) - warn = "log level set to trace, this may expose sensitive information like IP addresses to the server owner" - case "debug": - log.Logger = log.Level(zerolog.DebugLevel) - warn = "log level set to trace, this may expose sensitive information like IP addresses to the server owner" - case "info": - log.Logger = log.Level(zerolog.InfoLevel) - case "warn": - log.Logger = log.Level(zerolog.WarnLevel) - case "error": - log.Logger = log.Level(zerolog.ErrorLevel) - case "fatal": - log.Logger = log.Level(zerolog.FatalLevel) - case "panic": - log.Logger = log.Level(zerolog.PanicLevel) - default: - log.Fatal().Str("level", conf.LogLevel).Msg("invalid log level") + err := v.Unmarshal(&config) + + if err != nil { + slog.Error("failed to parse configuration: ", "error", err) + os.Exit(1) } - dbSvc := service.NewDatabaseService(service.DatabaseServiceConfig{ - DatabasePath: conf.DatabasePath, - }) + slog.Info("starting tinyauth analytics", "config", config) - if err := dbSvc.Init(); err != nil { - log.Fatal().Err(err).Msg("failed to initialize database") - } + sqlDb, err := sql.Open("sqlite", config.DatabasePath) - db := dbSvc.GetDatabase() + if err != nil { + slog.Error("failed to open database: ", "error", err) + os.Exit(1) + } - cacheSvc := service.NewCacheService() + defer sqlDb.Close() - zerologMiddleware := middleware.NewZerologMiddleware(log.Logger.GetLevel()) + sqlDb.Exec(`PRAGMA journal_mode=WAL;`) - engine := gin.New() - engine.Use(gin.Recovery()) - engine.Use(zerologMiddleware.Middleware()) + sqlDb.Exec(`CREATE TABLE IF NOT EXISTS "instances" ( + "uuid" TEXT NOT NULL PRIMARY KEY, + "version" TEXT NOT NULL, + "last_seen" INTEGER NOT NULL + );`) - engine.Use(cors.New( - cors.Config{ - AllowOrigins: conf.CORSAllowedOrigins, - }, - )) + queries := queries.New(sqlDb) + cache := NewCache() + router := chi.NewRouter() + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) - engine.SetTrustedProxies(conf.TrustedProxies) + rateLimiter := NewRateLimiter(RateLimitConfig{ + RateLimitCount: config.RateLimitCount, + TrustedProxies: config.TrustedProxies, + }, cache) - api := engine.Group("/v1") + instancesHandler := NewInstancesHandler(queries) + healthHandler := NewHealthHandler() - rateLimitMiddleware := middleware.NewRateLimitMiddleware(db, cacheSvc, conf.RateLimitCount) + router.Get("/v1/healthz", healthHandler.health) - instancesCtrl := controller.NewInstancesController(api, db, rateLimitMiddleware, warn) + router.Group(func(r chi.Router) { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: config.CORSAllowedOrigins, + })) + r.Get("/v1/instances/all", instancesHandler.GetInstances) + }) - instancesCtrl.SetupRoutes() + router.Group(func(r chi.Router) { + r.Use(rateLimiter.limit) + r.Post("/v1/instances/heartbeat", instancesHandler.Heartbeat) + }) - healthCtrl := controller.NewHealthController(api) + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", config.Address, config.Port), + Handler: router, + } - healthCtrl.SetupRoutes() + go cleanUpOldInstances(queries) - go clearOldSessions(db) + slog.Info("server listening", "address", srv.Addr) - log.Info().Str("port", conf.Port).Str("address", conf.Address).Msg("starting server, version " + version) + err = srv.ListenAndServe() - if err := engine.Run(conf.Address + ":" + conf.Port); err != nil { - log.Fatal().Err(err).Msg("server error") + if err != nil { + slog.Error("server error: ", "error", err) + os.Exit(1) } } -func clearOldSessions(db *gorm.DB) { - ticker := time.NewTicker(time.Duration(24) * time.Hour) +func cleanUpOldInstances(queries *queries.Queries) { + ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for ; true; <-ticker.C { - log.Info().Msg("clearing old sessions") + slog.Info("cleaning up old instances") - ctx := context.Background() - cutoffTime := time.Now().Add(time.Duration(-48) * time.Hour).UnixMilli() - rowsAffected, err := gorm.G[model.Instance](db).Where("last_seen < ?", cutoffTime).Delete(ctx) + cutoffTime := time.Now().Add(-48 * time.Hour).UnixMilli() + rowsAffected, err := queries.DeleteOldInstances(context.Background(), cutoffTime) if err != nil { - log.Warn().Err(err).Msg("failed to clear old sessions") + slog.Error("failed to clean up old instances: ", "error", err) continue } - log.Info().Msgf("cleared %d old sessions", rowsAffected) + slog.Info("old instances cleaned up", "rows_affected", rowsAffected) } + } diff --git a/query.sql b/query.sql new file mode 100644 index 0000000..7c04e4d --- /dev/null +++ b/query.sql @@ -0,0 +1,27 @@ +-- name: GetInstance :one +SELECT * FROM instances +WHERE uuid = ? LIMIT 1; + +-- name: GetAllInstances :many +SELECT * FROM instances; + +-- name: CreateInstance :exec +INSERT INTO instances ( + uuid, version, last_seen +) VALUES ( + ?, ?, ? +); + +-- name: UpdateInstance :exec +UPDATE instances +set last_seen = ? +WHERE uuid = ?; + +-- name: DeleteInstance :exec +DELETE FROM instances +WHERE uuid = ?; + +-- name: DeleteOldInstances :many +DELETE FROM instances +WHERE last_seen < ? +RETURNING *; \ No newline at end of file diff --git a/rate_limiter.go b/rate_limiter.go new file mode 100644 index 0000000..68a3e00 --- /dev/null +++ b/rate_limiter.go @@ -0,0 +1,103 @@ +package main + +import ( + "fmt" + "log/slog" + "net" + "net/http" + "slices" + "sync" + "time" +) + +type RateLimitConfig struct { + RateLimitCount int + TrustedProxies []string +} + +type RateLimiter struct { + config RateLimitConfig + cache *Cache + mutex sync.RWMutex +} + +func NewRateLimiter(config RateLimitConfig, cache *Cache) *RateLimiter { + return &RateLimiter{ + config: config, + cache: cache, + } +} + +func (rl *RateLimiter) limit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rl.mutex.Lock() + defer rl.mutex.Unlock() + + clientIP := rl.getClientIP(r) + + if clientIP == "" { + http.Error(w, "failed to determine client ip", http.StatusInternalServerError) + return + } + + value, exists := rl.cache.Get(clientIP) + + w.Header().Set("x-ratelimit-limit", fmt.Sprint(rl.config.RateLimitCount)) + w.Header().Set("x-ratelimit-reset", fmt.Sprint(time.Now().Add(12*time.Hour).Unix())) + + if !exists { + rl.cache.Set(clientIP, 1, 43200) // 12 hours TTL + w.Header().Set("x-ratelimit-remaining", fmt.Sprint(rl.config.RateLimitCount-1)) + w.Header().Set("x-ratelimit-used", fmt.Sprint(1)) + next.ServeHTTP(w, r) + return + } + + used, ok := value.(int) + + if !ok { + slog.Error("failed to assert rate limit cache value type") + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + used++ + + if used > rl.config.RateLimitCount { + w.Header().Set("x-ratelimit-remaining", fmt.Sprint(0)) + w.Header().Set("x-ratelimit-used", fmt.Sprint(used)) + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + return + } + + rl.cache.Set(clientIP, used, 43200) // 12 hours TTL + + w.Header().Set("x-ratelimit-remaining", fmt.Sprint(rl.config.RateLimitCount-used)) + w.Header().Set("x-ratelimit-used", fmt.Sprint(used)) + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) getClientIP(r *http.Request) string { + cfConnectingIP := r.Header.Values("cf-connecting-ip") + + if len(cfConnectingIP) > 0 { + return cfConnectingIP[0] + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + + if err != nil { + return "" + } + + if slices.Contains(rl.config.TrustedProxies, ip) { + xForwardedFor := r.Header.Values("x-forwarded-for") + + if len(xForwardedFor) > 0 { + return xForwardedFor[0] + } + } + + return ip +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..ab6e923 --- /dev/null +++ b/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS "instances" ( + "uuid" TEXT NOT NULL PRIMARY KEY, + "version" TEXT NOT NULL, + "last_seen" INTEGER NOT NULL +); \ No newline at end of file diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..8e0283e --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,14 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "query.sql" + schema: "schema.sql" + gen: + go: + package: "queries" + out: "database/queries" + overrides: + - column: "instances.id" + go_struct_tag: json:"-" + rename: + uuid: "UUID" diff --git a/viewer/.gitignore b/viewer/.gitignore deleted file mode 100644 index 9004535..0000000 --- a/viewer/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# node modules -node_modules \ No newline at end of file diff --git a/viewer/README.md b/viewer/README.md deleted file mode 100644 index 597e228..0000000 --- a/viewer/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Viewer - -A simple bun script to fetch analytics from the API server and display them in a human readable format. - -## Usage - -Make sure you have the dependencies installed (typescript pretty much): - -```sh -bun install -``` - -Run the script with: - -```sh -bun main.ts -``` - -> [!TIP] -> You can also pass a custom API server URL as an argument. For example `bun main.ts https://api.tinyauth.app`. diff --git a/viewer/bun.lock b/viewer/bun.lock deleted file mode 100644 index 918b504..0000000 --- a/viewer/bun.lock +++ /dev/null @@ -1,29 +0,0 @@ -{ - "lockfileVersion": 1, - "workspaces": { - "": { - "name": "tinyauth-analytics-read", - "devDependencies": { - "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", - }, - }, - }, - "packages": { - "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], - - "@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="], - - "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], - - "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], - - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], - - "undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="], - } -} diff --git a/viewer/main.ts b/viewer/main.ts deleted file mode 100644 index 9d1f26f..0000000 --- a/viewer/main.ts +++ /dev/null @@ -1,70 +0,0 @@ -interface Instance { - uuid: string; - version: string; - last_seen: string; -} - -interface InstancesResponse { - instances: Instance[]; - total: number; - status: number; -} - -async function getInstances(apiServer: string): Promise { - const response = await fetch(`${apiServer}/v1/instances/all`); - - if (!response.ok) { - console.error("Failed to fetch instances:", response.statusText); - return { instances: [], total: 0, status: response.status }; - } - - const data = (await response.json()) as InstancesResponse; - - if (data.status !== 200) { - console.error("API returned error status:", data.status); - return { instances: [], total: 0, status: data.status }; - } - - return { - instances: data.instances || [], - total: data.total || 0, - status: response.status, - }; -} - -async function main(apiServer: string) { - const instancesResponse = await getInstances(apiServer); - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - var versionCounts: { [key: string]: number } = {}; - - console.log(`Using ${timezone} timezone for last seen timestamps.`); - - for (const instance of instancesResponse.instances) { - versionCounts[instance.version] = - (versionCounts[instance.version] || 0) + 1; - const last_seen = new Date(instance.last_seen).toLocaleString(undefined, { - timeZone: timezone, - }); - console.log( - `UUID: ${instance.uuid}, Version: ${instance.version}, Last Seen: ${last_seen}` - ); - } - - for (const [version, count] of Object.entries(versionCounts)) { - console.log(`Version: ${version}, Count: ${count}`); - } - - console.log(`Total instances: ${instancesResponse.total}`); -} - -var apiServer = "https://api.tinyauth.app"; - -const apiServerArg = Bun.argv[2]; - -if (apiServerArg) { - apiServer = apiServerArg; -} - -console.log(`Using API server: ${apiServer}`); - -main(apiServer); diff --git a/viewer/package.json b/viewer/package.json deleted file mode 100644 index e99f314..0000000 --- a/viewer/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "viewer", - "module": "main.ts", - "type": "module", - "private": true, - "devDependencies": { - "@types/bun": "latest" - }, - "peerDependencies": { - "typescript": "^5" - } -} diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json deleted file mode 100644 index bfa0fea..0000000 --- a/viewer/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -}