From ed6bc0d895a9c1221bea69536a443d30d68949a9 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Dec 2025 22:20:52 +0200 Subject: [PATCH 1/9] chore: deprecate old codebase --- data/.gitkeep | 0 go.mod => legacy/go.mod | 0 go.sum => legacy/go.sum | 0 {internal => legacy/internal}/controller/health_controller.go | 0 {internal => legacy/internal}/controller/instances_controller.go | 0 {internal => legacy/internal}/middleware/rate_limit_middleware.go | 0 {internal => legacy/internal}/middleware/zerolog_middleware.go | 0 {internal => legacy/internal}/model/instance_model.go | 0 {internal => legacy/internal}/service/cache_service.go | 0 {internal => legacy/internal}/service/database_service.go | 0 .../internal}/service/migrations/000001_init_sqlite.down.sql | 0 .../internal}/service/migrations/000001_init_sqlite.up.sql | 0 main.go => legacy/main.go | 0 {viewer => legacy/viewer}/.gitignore | 0 {viewer => legacy/viewer}/README.md | 0 {viewer => legacy/viewer}/bun.lock | 0 {viewer => legacy/viewer}/main.ts | 0 {viewer => legacy/viewer}/package.json | 0 {viewer => legacy/viewer}/tsconfig.json | 0 19 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 data/.gitkeep rename go.mod => legacy/go.mod (100%) rename go.sum => legacy/go.sum (100%) rename {internal => legacy/internal}/controller/health_controller.go (100%) rename {internal => legacy/internal}/controller/instances_controller.go (100%) rename {internal => legacy/internal}/middleware/rate_limit_middleware.go (100%) rename {internal => legacy/internal}/middleware/zerolog_middleware.go (100%) rename {internal => legacy/internal}/model/instance_model.go (100%) rename {internal => legacy/internal}/service/cache_service.go (100%) rename {internal => legacy/internal}/service/database_service.go (100%) rename {internal => legacy/internal}/service/migrations/000001_init_sqlite.down.sql (100%) rename {internal => legacy/internal}/service/migrations/000001_init_sqlite.up.sql (100%) rename main.go => legacy/main.go (100%) rename {viewer => legacy/viewer}/.gitignore (100%) rename {viewer => legacy/viewer}/README.md (100%) rename {viewer => legacy/viewer}/bun.lock (100%) rename {viewer => legacy/viewer}/main.ts (100%) rename {viewer => legacy/viewer}/package.json (100%) rename {viewer => legacy/viewer}/tsconfig.json (100%) diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/go.mod b/legacy/go.mod similarity index 100% rename from go.mod rename to legacy/go.mod diff --git a/go.sum b/legacy/go.sum similarity index 100% rename from go.sum rename to legacy/go.sum diff --git a/internal/controller/health_controller.go b/legacy/internal/controller/health_controller.go similarity index 100% rename from internal/controller/health_controller.go rename to legacy/internal/controller/health_controller.go diff --git a/internal/controller/instances_controller.go b/legacy/internal/controller/instances_controller.go similarity index 100% rename from internal/controller/instances_controller.go rename to legacy/internal/controller/instances_controller.go diff --git a/internal/middleware/rate_limit_middleware.go b/legacy/internal/middleware/rate_limit_middleware.go similarity index 100% rename from internal/middleware/rate_limit_middleware.go rename to legacy/internal/middleware/rate_limit_middleware.go diff --git a/internal/middleware/zerolog_middleware.go b/legacy/internal/middleware/zerolog_middleware.go similarity index 100% rename from internal/middleware/zerolog_middleware.go rename to legacy/internal/middleware/zerolog_middleware.go diff --git a/internal/model/instance_model.go b/legacy/internal/model/instance_model.go similarity index 100% rename from internal/model/instance_model.go rename to legacy/internal/model/instance_model.go diff --git a/internal/service/cache_service.go b/legacy/internal/service/cache_service.go similarity index 100% rename from internal/service/cache_service.go rename to legacy/internal/service/cache_service.go diff --git a/internal/service/database_service.go b/legacy/internal/service/database_service.go similarity index 100% rename from internal/service/database_service.go rename to legacy/internal/service/database_service.go diff --git a/internal/service/migrations/000001_init_sqlite.down.sql b/legacy/internal/service/migrations/000001_init_sqlite.down.sql similarity index 100% rename from internal/service/migrations/000001_init_sqlite.down.sql rename to legacy/internal/service/migrations/000001_init_sqlite.down.sql diff --git a/internal/service/migrations/000001_init_sqlite.up.sql b/legacy/internal/service/migrations/000001_init_sqlite.up.sql similarity index 100% rename from internal/service/migrations/000001_init_sqlite.up.sql rename to legacy/internal/service/migrations/000001_init_sqlite.up.sql diff --git a/main.go b/legacy/main.go similarity index 100% rename from main.go rename to legacy/main.go diff --git a/viewer/.gitignore b/legacy/viewer/.gitignore similarity index 100% rename from viewer/.gitignore rename to legacy/viewer/.gitignore diff --git a/viewer/README.md b/legacy/viewer/README.md similarity index 100% rename from viewer/README.md rename to legacy/viewer/README.md diff --git a/viewer/bun.lock b/legacy/viewer/bun.lock similarity index 100% rename from viewer/bun.lock rename to legacy/viewer/bun.lock diff --git a/viewer/main.ts b/legacy/viewer/main.ts similarity index 100% rename from viewer/main.ts rename to legacy/viewer/main.ts diff --git a/viewer/package.json b/legacy/viewer/package.json similarity index 100% rename from viewer/package.json rename to legacy/viewer/package.json diff --git a/viewer/tsconfig.json b/legacy/viewer/tsconfig.json similarity index 100% rename from viewer/tsconfig.json rename to legacy/viewer/tsconfig.json From c48fac7cf8cdfec3703deeabb4de68b8c1c0ab9d Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Dec 2025 23:43:09 +0200 Subject: [PATCH 2/9] feat: add new app --- .gitignore | 3 + .vscode/launch.json | 2 +- cache.go | 72 ++++++++++++++++++ database/queries/db.go | 31 ++++++++ database/queries/models.go | 12 +++ database/queries/query.sql.go | 138 ++++++++++++++++++++++++++++++++++ go.mod | 32 ++++++++ go.sum | 56 ++++++++++++++ health_handler.go | 21 ++++++ instances_handler.go | 101 +++++++++++++++++++++++++ main.go | 121 +++++++++++++++++++++++++++++ query.sql | 27 +++++++ rate_limiter.go | 94 +++++++++++++++++++++++ schema.sql | 6 ++ sqlc.yaml | 14 ++++ 15 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 cache.go create mode 100644 database/queries/db.go create mode 100644 database/queries/models.go create mode 100644 database/queries/query.sql.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 health_handler.go create mode 100644 instances_handler.go create mode 100644 main.go create mode 100644 query.sql create mode 100644 rate_limiter.go create mode 100644 schema.sql create mode 100644 sqlc.yaml diff --git a/.gitignore b/.gitignore index 897e6a9..c1780eb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ data/analytics.db # debug __debug_* + +# build out +tinyauth-analytics \ 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/cache.go b/cache.go new file mode 100644 index 0000000..5012457 --- /dev/null +++ b/cache.go @@ -0,0 +1,72 @@ +package main + +import ( + "slices" + "sync" + "time" +) + +type cacheField struct { + key string + value any + expire int64 +} + +type Cache struct { + cache []cacheField + mutex sync.RWMutex +} + +func NewCache() *Cache { + return &Cache{ + cache: make([]cacheField, 0), + } +} + +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() + for i, field := range c.cache { + if field.key == key { + c.cache[i].value = value + c.cache[i].expire = expire + return + } + } + c.cache = append(c.cache, cacheField{ + key: key, + value: value, + expire: expire, + }) +} + +func (c *Cache) Get(key string) (any, bool) { + c.mutex.RLock() + defer c.mutex.RUnlock() + for _, field := range c.cache { + if field.key == key { + if time.Now().Unix() > field.expire { + c.Delete(key) + return nil, false + } + return field.value, true + } + } + return nil, false +} + +func (c *Cache) Delete(key string) { + c.mutex.Lock() + defer c.mutex.Unlock() + for i, field := range c.cache { + if field.key == key { + c.cache = slices.Delete(c.cache, i, i+1) + return + } + } +} + +func (c *Cache) Flush() { + c.cache = make([]cacheField, 0) +} 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..aa92711 --- /dev/null +++ b/database/queries/models.go @@ -0,0 +1,12 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package queries + +type Instance struct { + ID int64 `json:"-"` + 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..413365a --- /dev/null +++ b/database/queries/query.sql.go @@ -0,0 +1,138 @@ +// 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 id, 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.ID, + &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 id, 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.ID, + &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 id, 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.ID, + &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/go.mod b/go.mod new file mode 100644 index 0000000..225e011 --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module tinyauth-analytics + +go 1.24.3 + +require ( + 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/go-chi/chi/v5 v5.2.3 // indirect + github.com/go-chi/render v1.0.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // 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 + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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.40.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..829dbf0 --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +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/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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +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/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/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= 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..d7fd7d0 --- /dev/null +++ b/instances_handler.go @@ -0,0 +1,101 @@ +package main + +import ( + "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 { + 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 { + err = h.queries.CreateInstance(r.Context(), queries.CreateInstanceParams{ + UUID: heartbeat.UUID, + Version: heartbeat.Version, + LastSeen: time.Now().UnixMilli(), + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + render.JSON(w, r, map[string]string{ + "status": "500", + "message": "Failed to create instance", + }) + return + } + w.WriteHeader(http.StatusOK) + render.JSON(w, r, map[string]string{ + "status": "200", + "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/main.go b/main.go new file mode 100644 index 0000000..22aa9a5 --- /dev/null +++ b/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "net/http" + "os" + "time" + "tinyauth-analytics/database/queries" + + "github.com/go-chi/chi/v5" + "github.com/spf13/viper" + _ "modernc.org/sqlite" +) + +type Config struct { + Port int `mapstructure:"port"` + Address string `mapstructure:"address"` + RateLimitCount int `mapstructure:"rate_limit_count"` + DatabasePath string `mapstructure:"database_path"` + TrustedProxies []string `mapstructure:"trusted_proxies"` + CORSAllowedOrigins []string `mapstructure:"cors_allowed_origins"` +} + +func main() { + v := viper.New() + + v.SetDefault("port", 8080) + v.SetDefault("address", "0.0.0.0") + v.SetDefault("rate_limit_count", 3) + v.SetDefault("database_path", "analytics.db") + v.SetDefault("trusted_proxies", []string{""}) + v.SetDefault("cors_allowed_origins", []string{"*"}) + + v.AutomaticEnv() + + var config Config + + err := v.Unmarshal(&config) + + if err != nil { + slog.Error("failed to parse configuration: ", "error", err) + os.Exit(1) + } + + slog.Info("starting tinyauth analytics", "config", config) + + sqlDb, err := sql.Open("sqlite", config.DatabasePath) + + if err != nil { + slog.Error("failed to open database: ", "error", err) + os.Exit(1) + } + + defer sqlDb.Close() + + sqlDb.Exec(`CREATE TABLE IF NOT EXISTS "instances" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "uuid" TEXT NOT NULL, + "version" TEXT NOT NULL, + "last_seen" INTEGER NOT NULL + );`) + + queries := queries.New(sqlDb) + cache := NewCache() + router := chi.NewRouter() + + rateLimiter := NewRateLimiter(RateLimitConfig{ + RateLimitCount: config.RateLimitCount, + TrustedProxies: config.TrustedProxies, + }, cache) + + instancesHandler := NewInstancesHandler(queries) + healthHandler := NewHealthHandler() + + router.Get("/v1/healthz", healthHandler.health) + router.Get("/v1/instances/all", instancesHandler.GetInstances) + + router.Group(func(r chi.Router) { + r.Use(rateLimiter.limit) + r.Post("/v1/instances/heartbeat", instancesHandler.Heartbeat) + }) + + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", config.Address, config.Port), + Handler: router, + } + + go cleanUpOldInstances(queries) + + slog.Info("server listening", "address", srv.Addr) + + err = srv.ListenAndServe() + + if err != nil { + slog.Error("server error: ", "error", err) + os.Exit(1) + } +} + +func cleanUpOldInstances(queries *queries.Queries) { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + for ; true; <-ticker.C { + slog.Info("cleaning up old instances") + + cutoffTime := time.Now().Add(-48 * time.Hour).UnixMilli() + rowsAffected, err := queries.DeleteOldInstances(context.Background(), cutoffTime) + + if err != nil { + slog.Error("failed to clean up old instances: ", "error", err) + continue + } + + 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..93e07b9 --- /dev/null +++ b/rate_limiter.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "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(24*time.Hour).Unix())) + + if !exists { + rl.cache.Set(clientIP, 1, 86400) // 1 day 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 := value.(int) + 1 + + 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, 86400) // 1 day 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..9eafe4d --- /dev/null +++ b/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "instances" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "uuid" TEXT NOT NULL, + "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" From f496d7cb0ce8ab4064355988f810b8fff8f17b99 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Dec 2025 23:43:24 +0200 Subject: [PATCH 3/9] chore: remove legacy app --- legacy/go.mod | 72 ------- legacy/go.sum | 192 ------------------ .../internal/controller/health_controller.go | 25 --- .../controller/instances_controller.go | 144 ------------- .../middleware/rate_limit_middleware.go | 91 --------- .../internal/middleware/zerolog_middleware.go | 57 ------ legacy/internal/model/instance_model.go | 8 - legacy/internal/service/cache_service.go | 72 ------- legacy/internal/service/database_service.go | 83 -------- .../migrations/000001_init_sqlite.down.sql | 1 - .../migrations/000001_init_sqlite.up.sql | 6 - legacy/main.go | 141 ------------- legacy/viewer/.gitignore | 2 - legacy/viewer/README.md | 20 -- legacy/viewer/bun.lock | 29 --- legacy/viewer/main.ts | 70 ------- legacy/viewer/package.json | 12 -- legacy/viewer/tsconfig.json | 29 --- 18 files changed, 1054 deletions(-) delete mode 100644 legacy/go.mod delete mode 100644 legacy/go.sum delete mode 100644 legacy/internal/controller/health_controller.go delete mode 100644 legacy/internal/controller/instances_controller.go delete mode 100644 legacy/internal/middleware/rate_limit_middleware.go delete mode 100644 legacy/internal/middleware/zerolog_middleware.go delete mode 100644 legacy/internal/model/instance_model.go delete mode 100644 legacy/internal/service/cache_service.go delete mode 100644 legacy/internal/service/database_service.go delete mode 100644 legacy/internal/service/migrations/000001_init_sqlite.down.sql delete mode 100644 legacy/internal/service/migrations/000001_init_sqlite.up.sql delete mode 100644 legacy/main.go delete mode 100644 legacy/viewer/.gitignore delete mode 100644 legacy/viewer/README.md delete mode 100644 legacy/viewer/bun.lock delete mode 100644 legacy/viewer/main.ts delete mode 100644 legacy/viewer/package.json delete mode 100644 legacy/viewer/tsconfig.json diff --git a/legacy/go.mod b/legacy/go.mod deleted file mode 100644 index 696775a..0000000 --- a/legacy/go.mod +++ /dev/null @@ -1,72 +0,0 @@ -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/spf13/viper v1.21.0 - gorm.io/gorm v1.31.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/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 - github.com/spf13/afero v1.15.0 // indirect - 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 - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.39.0 // indirect -) diff --git a/legacy/go.sum b/legacy/go.sum deleted file mode 100644 index 244b96d..0000000 --- a/legacy/go.sum +++ /dev/null @@ -1,192 +0,0 @@ -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/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= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -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-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/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= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -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/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= -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/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/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/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= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -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/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= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/legacy/internal/controller/health_controller.go b/legacy/internal/controller/health_controller.go deleted file mode 100644 index b218be9..0000000 --- a/legacy/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/legacy/internal/controller/instances_controller.go b/legacy/internal/controller/instances_controller.go deleted file mode 100644 index 3953130..0000000 --- a/legacy/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/legacy/internal/middleware/rate_limit_middleware.go b/legacy/internal/middleware/rate_limit_middleware.go deleted file mode 100644 index 40e590d..0000000 --- a/legacy/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/legacy/internal/middleware/zerolog_middleware.go b/legacy/internal/middleware/zerolog_middleware.go deleted file mode 100644 index 5f7b621..0000000 --- a/legacy/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/legacy/internal/model/instance_model.go b/legacy/internal/model/instance_model.go deleted file mode 100644 index d63cac4..0000000 --- a/legacy/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/legacy/internal/service/cache_service.go b/legacy/internal/service/cache_service.go deleted file mode 100644 index 85ba61d..0000000 --- a/legacy/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/legacy/internal/service/database_service.go b/legacy/internal/service/database_service.go deleted file mode 100644 index 871fa74..0000000 --- a/legacy/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/legacy/internal/service/migrations/000001_init_sqlite.down.sql b/legacy/internal/service/migrations/000001_init_sqlite.down.sql deleted file mode 100644 index 674eef3..0000000 --- a/legacy/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/legacy/internal/service/migrations/000001_init_sqlite.up.sql b/legacy/internal/service/migrations/000001_init_sqlite.up.sql deleted file mode 100644 index 11261c7..0000000 --- a/legacy/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/legacy/main.go b/legacy/main.go deleted file mode 100644 index 628cef4..0000000 --- a/legacy/main.go +++ /dev/null @@ -1,141 +0,0 @@ -package main - -import ( - "context" - "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" - "github.com/spf13/viper" - "gorm.io/gorm" -) - -var version = "development" - -type config struct { - DatabasePath string `mapstructure:"db_path"` - Port string `mapstructure:"port"` - Address string `mapstructure:"address"` - RateLimitCount int `mapstructure:"rate_limit_count"` - CORSAllowedOrigins []string `mapstructure:"cors_allowed_origins"` - TrustedProxies []string `mapstructure:"trusted_proxies"` - LogLevel string `mapstructure:"log_level"` -} - -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("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") - - var conf config - - if err := v.Unmarshal(&conf); err != nil { - log.Fatal().Err(err).Msg("failed to parse 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") - } - - dbSvc := service.NewDatabaseService(service.DatabaseServiceConfig{ - DatabasePath: conf.DatabasePath, - }) - - if err := dbSvc.Init(); err != nil { - log.Fatal().Err(err).Msg("failed to initialize database") - } - - db := dbSvc.GetDatabase() - - cacheSvc := service.NewCacheService() - - zerologMiddleware := middleware.NewZerologMiddleware(log.Logger.GetLevel()) - - engine := gin.New() - engine.Use(gin.Recovery()) - engine.Use(zerologMiddleware.Middleware()) - - engine.Use(cors.New( - cors.Config{ - AllowOrigins: conf.CORSAllowedOrigins, - }, - )) - - engine.SetTrustedProxies(conf.TrustedProxies) - - api := engine.Group("/v1") - - rateLimitMiddleware := middleware.NewRateLimitMiddleware(db, cacheSvc, conf.RateLimitCount) - - instancesCtrl := controller.NewInstancesController(api, db, rateLimitMiddleware, warn) - - instancesCtrl.SetupRoutes() - - healthCtrl := controller.NewHealthController(api) - - healthCtrl.SetupRoutes() - - go clearOldSessions(db) - - log.Info().Str("port", conf.Port).Str("address", conf.Address).Msg("starting server, version " + version) - - if err := engine.Run(conf.Address + ":" + conf.Port); err != nil { - log.Fatal().Err(err).Msg("server error") - } -} - -func clearOldSessions(db *gorm.DB) { - ticker := time.NewTicker(time.Duration(24) * time.Hour) - defer ticker.Stop() - - for ; true; <-ticker.C { - log.Info().Msg("clearing old sessions") - - 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) - - if err != nil { - log.Warn().Err(err).Msg("failed to clear old sessions") - continue - } - - log.Info().Msgf("cleared %d old sessions", rowsAffected) - } -} diff --git a/legacy/viewer/.gitignore b/legacy/viewer/.gitignore deleted file mode 100644 index 9004535..0000000 --- a/legacy/viewer/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# node modules -node_modules \ No newline at end of file diff --git a/legacy/viewer/README.md b/legacy/viewer/README.md deleted file mode 100644 index 597e228..0000000 --- a/legacy/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/legacy/viewer/bun.lock b/legacy/viewer/bun.lock deleted file mode 100644 index 918b504..0000000 --- a/legacy/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/legacy/viewer/main.ts b/legacy/viewer/main.ts deleted file mode 100644 index 9d1f26f..0000000 --- a/legacy/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/legacy/viewer/package.json b/legacy/viewer/package.json deleted file mode 100644 index e99f314..0000000 --- a/legacy/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/legacy/viewer/tsconfig.json b/legacy/viewer/tsconfig.json deleted file mode 100644 index bfa0fea..0000000 --- a/legacy/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 - } -} From 076fb654bc0f549111dfa111d14e37310f786253 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Dec 2025 23:45:32 +0200 Subject: [PATCH 4/9] feat: add cors support --- go.mod | 1 + go.sum | 2 ++ main.go | 9 ++++++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 225e011..5cd05ea 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/go-chi/cors v1.2.2 // indirect github.com/go-chi/render v1.0.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 829dbf0..eb349f2 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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= diff --git a/main.go b/main.go index 22aa9a5..c43e6d0 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "tinyauth-analytics/database/queries" "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" "github.com/spf13/viper" _ "modernc.org/sqlite" ) @@ -76,7 +77,13 @@ func main() { healthHandler := NewHealthHandler() router.Get("/v1/healthz", healthHandler.health) - router.Get("/v1/instances/all", instancesHandler.GetInstances) + + router.Group(func(r chi.Router) { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: config.CORSAllowedOrigins, + })) + r.Get("/v1/instances/all", instancesHandler.GetInstances) + }) router.Group(func(r chi.Router) { r.Use(rateLimiter.limit) From 2f2e4f3c68a9c9b9491cbe46de49d47d00a3d7f5 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 14 Dec 2025 12:01:18 +0200 Subject: [PATCH 5/9] refactor: performance improvements in sqlite using wal --- .gitignore | 5 ++++- cache.go | 4 +++- database/queries/models.go | 1 - database/queries/query.sql.go | 27 ++++++--------------------- main.go | 2 ++ rate_limiter.go | 4 ++-- schema.sql | 3 +-- 7 files changed, 18 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index c1780eb..2cc35f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ data/analytics.db __debug_* # build out -tinyauth-analytics \ No newline at end of file +tinyauth-analytics + +# benchmarks +bench \ No newline at end of file diff --git a/cache.go b/cache.go index 5012457..6ee32f3 100644 --- a/cache.go +++ b/cache.go @@ -43,16 +43,18 @@ func (c *Cache) Set(key string, value any, ttl int64) { func (c *Cache) Get(key string) (any, bool) { c.mutex.RLock() - defer c.mutex.RUnlock() for _, field := range c.cache { if field.key == key { if time.Now().Unix() > field.expire { + c.mutex.RUnlock() c.Delete(key) return nil, false } + c.mutex.RUnlock() return field.value, true } } + c.mutex.RUnlock() return nil, false } diff --git a/database/queries/models.go b/database/queries/models.go index aa92711..1f4e635 100644 --- a/database/queries/models.go +++ b/database/queries/models.go @@ -5,7 +5,6 @@ package queries type Instance struct { - ID int64 `json:"-"` UUID string Version string LastSeen int64 diff --git a/database/queries/query.sql.go b/database/queries/query.sql.go index 413365a..c129168 100644 --- a/database/queries/query.sql.go +++ b/database/queries/query.sql.go @@ -41,7 +41,7 @@ func (q *Queries) DeleteInstance(ctx context.Context, uuid string) error { const deleteOldInstances = `-- name: DeleteOldInstances :many DELETE FROM instances WHERE last_seen < ? -RETURNING id, uuid, version, last_seen +RETURNING uuid, version, last_seen ` func (q *Queries) DeleteOldInstances(ctx context.Context, lastSeen int64) ([]Instance, error) { @@ -53,12 +53,7 @@ func (q *Queries) DeleteOldInstances(ctx context.Context, lastSeen int64) ([]Ins var items []Instance for rows.Next() { var i Instance - if err := rows.Scan( - &i.ID, - &i.UUID, - &i.Version, - &i.LastSeen, - ); err != nil { + if err := rows.Scan(&i.UUID, &i.Version, &i.LastSeen); err != nil { return nil, err } items = append(items, i) @@ -73,7 +68,7 @@ func (q *Queries) DeleteOldInstances(ctx context.Context, lastSeen int64) ([]Ins } const getAllInstances = `-- name: GetAllInstances :many -SELECT id, uuid, version, last_seen FROM instances +SELECT uuid, version, last_seen FROM instances ` func (q *Queries) GetAllInstances(ctx context.Context) ([]Instance, error) { @@ -85,12 +80,7 @@ func (q *Queries) GetAllInstances(ctx context.Context) ([]Instance, error) { var items []Instance for rows.Next() { var i Instance - if err := rows.Scan( - &i.ID, - &i.UUID, - &i.Version, - &i.LastSeen, - ); err != nil { + if err := rows.Scan(&i.UUID, &i.Version, &i.LastSeen); err != nil { return nil, err } items = append(items, i) @@ -105,19 +95,14 @@ func (q *Queries) GetAllInstances(ctx context.Context) ([]Instance, error) { } const getInstance = `-- name: GetInstance :one -SELECT id, uuid, version, last_seen FROM instances +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.ID, - &i.UUID, - &i.Version, - &i.LastSeen, - ) + err := row.Scan(&i.UUID, &i.Version, &i.LastSeen) return i, err } diff --git a/main.go b/main.go index c43e6d0..a35d44f 100644 --- a/main.go +++ b/main.go @@ -57,6 +57,8 @@ func main() { defer sqlDb.Close() + sqlDb.Exec(`PRAGMA journal_mode=WAL;`) + sqlDb.Exec(`CREATE TABLE IF NOT EXISTS "instances" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "uuid" TEXT NOT NULL, diff --git a/rate_limiter.go b/rate_limiter.go index 93e07b9..c4247b5 100644 --- a/rate_limiter.go +++ b/rate_limiter.go @@ -45,7 +45,7 @@ func (rl *RateLimiter) limit(next http.Handler) http.Handler { w.Header().Set("x-ratelimit-reset", fmt.Sprint(time.Now().Add(24*time.Hour).Unix())) if !exists { - rl.cache.Set(clientIP, 1, 86400) // 1 day TTL + 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) @@ -61,7 +61,7 @@ func (rl *RateLimiter) limit(next http.Handler) http.Handler { return } - rl.cache.Set(clientIP, used, 86400) // 1 day TTL + 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)) diff --git a/schema.sql b/schema.sql index 9eafe4d..ab6e923 100644 --- a/schema.sql +++ b/schema.sql @@ -1,6 +1,5 @@ CREATE TABLE IF NOT EXISTS "instances" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "uuid" TEXT NOT NULL, + "uuid" TEXT NOT NULL PRIMARY KEY, "version" TEXT NOT NULL, "last_seen" INTEGER NOT NULL ); \ No newline at end of file From 26d589dc154899ff10acd9e9cffe15fd5b8f339c Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 14 Dec 2025 12:07:21 +0200 Subject: [PATCH 6/9] refactor: update dockerfile --- .gitignore | 2 +- Dockerfile | 6 ++++- docker-compose.dev.yml | 34 ++++++++++++++-------------- go.mod | 14 +++++++----- go.sum | 50 ++++++++++++++++++++++++++++++++++++++++-- main.go | 6 +++-- 6 files changed, 84 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 2cc35f4..5620294 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # data -data/analytics.db +data # debug __debug_* 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/docker-compose.dev.yml b/docker-compose.dev.yml index 1894abd..f1b7a80 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,20 +19,20 @@ services: - 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 5cd05ea..b96b178 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,19 @@ module tinyauth-analytics go 1.24.3 +require ( + github.com/go-chi/chi v1.5.5 + 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 + modernc.org/sqlite v1.40.1 +) + require ( 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/go-chi/chi/v5 v5.2.3 // indirect - github.com/go-chi/cors v1.2.2 // indirect - github.com/go-chi/render v1.0.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -20,7 +26,6 @@ require ( github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect @@ -29,5 +34,4 @@ require ( 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.40.1 // indirect ) diff --git a/go.sum b/go.sum index eb349f2..6e163d2 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,15 @@ 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= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +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/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= 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= @@ -12,16 +18,28 @@ 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/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/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/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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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= @@ -34,25 +52,53 @@ 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/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= 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/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.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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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-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= +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.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= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +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.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= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index a35d44f..a3f6276 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "time" "tinyauth-analytics/database/queries" + "github.com/go-chi/chi/middleware" "github.com/go-chi/chi/v5" "github.com/go-chi/cors" "github.com/spf13/viper" @@ -60,8 +61,7 @@ func main() { sqlDb.Exec(`PRAGMA journal_mode=WAL;`) sqlDb.Exec(`CREATE TABLE IF NOT EXISTS "instances" ( - "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "uuid" TEXT NOT NULL, + "uuid" TEXT NOT NULL PRIMARY KEY, "version" TEXT NOT NULL, "last_seen" INTEGER NOT NULL );`) @@ -69,6 +69,8 @@ func main() { queries := queries.New(sqlDb) cache := NewCache() router := chi.NewRouter() + router.Use(middleware.Logger) + router.Use(middleware.Recoverer) rateLimiter := NewRateLimiter(RateLimitConfig{ RateLimitCount: config.RateLimitCount, From 82c4fd804e87b5571840cce4acc2758352ab508d Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 14 Dec 2025 15:19:27 +0200 Subject: [PATCH 7/9] refactor: use map in cache service instead of slice --- cache.go | 75 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/cache.go b/cache.go index 6ee32f3..53fa4ec 100644 --- a/cache.go +++ b/cache.go @@ -1,74 +1,83 @@ package main import ( - "slices" "sync" "time" ) type cacheField struct { - key string value any expire int64 } type Cache struct { - cache []cacheField + cache map[string]cacheField mutex sync.RWMutex } func NewCache() *Cache { - return &Cache{ - cache: make([]cacheField, 0), + 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() - for i, field := range c.cache { - if field.key == key { - c.cache[i].value = value - c.cache[i].expire = expire - return - } - } - c.cache = append(c.cache, cacheField{ - key: key, + + c.cache[key] = cacheField{ value: value, expire: expire, - }) + } } func (c *Cache) Get(key string) (any, bool) { c.mutex.RLock() - for _, field := range c.cache { - if field.key == key { - if time.Now().Unix() > field.expire { - c.mutex.RUnlock() - c.Delete(key) - return nil, false - } - c.mutex.RUnlock() - return field.value, true - } + + 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 nil, false + return field.value, true } func (c *Cache) Delete(key string) { c.mutex.Lock() defer c.mutex.Unlock() - for i, field := range c.cache { - if field.key == key { - c.cache = slices.Delete(c.cache, i, i+1) - return - } - } + delete(c.cache, key) } func (c *Cache) Flush() { - c.cache = make([]cacheField, 0) + c.mutex.Lock() + defer c.mutex.Unlock() + c.cache = make(map[string]cacheField, 0) +} + +func (c *Cache) cleanup() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + + go func() { + for range ticker.C { + for key, field := range c.cache { + if time.Now().Unix() > field.expire { + c.Delete(key) + } + } + } + }() } From 6bc969bdd0ec0ef7af171ac0dddb087a169d361e Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 14 Dec 2025 19:31:27 +0200 Subject: [PATCH 8/9] chore: update readme --- README.md | 19 +++++++++---------- docker-compose.dev.yml | 1 - 2 files changed, 9 insertions(+), 11 deletions(-) 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/docker-compose.dev.yml b/docker-compose.dev.yml index f1b7a80..1af6aa2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,7 +12,6 @@ services: - RATE_LIMIT_COUNT=15 - CORS_ALLOWED_ORIGINS=* - TRUSTED_PROXIES=0.0.0.0 - - LOG_LEVEL=debug volumes: - ./data:/data ports: From 984377c40fd6d7057f76c5b332ba5547cc5b6d00 Mon Sep 17 00:00:00 2001 From: Stavros Date: Sun, 14 Dec 2025 19:48:02 +0200 Subject: [PATCH 9/9] fix: review comments --- cache.go | 10 ++++++---- go.mod | 1 - go.sum | 2 -- instances_handler.go | 21 ++++++++++++++++++--- main.go | 2 +- rate_limiter.go | 13 +++++++++++-- 6 files changed, 36 insertions(+), 13 deletions(-) diff --git a/cache.go b/cache.go index 53fa4ec..8e1864e 100644 --- a/cache.go +++ b/cache.go @@ -68,16 +68,18 @@ func (c *Cache) Flush() { } func (c *Cache) cleanup() { - ticker := time.NewTicker(24 * time.Hour) - defer ticker.Stop() - 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 { - c.Delete(key) + delete(c.cache, key) } } + c.mutex.Unlock() } }() } diff --git a/go.mod b/go.mod index b96b178..9ca16fb 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module tinyauth-analytics go 1.24.3 require ( - github.com/go-chi/chi v1.5.5 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 diff --git a/go.sum b/go.sum index 6e163d2..523890c 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ 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/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= -github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= 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= diff --git a/instances_handler.go b/instances_handler.go index d7fd7d0..debfa0d 100644 --- a/instances_handler.go +++ b/instances_handler.go @@ -1,6 +1,9 @@ package main import ( + "database/sql" + "errors" + "log/slog" "net/http" "time" "tinyauth-analytics/database/queries" @@ -22,6 +25,7 @@ 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", @@ -57,13 +61,24 @@ func (h *InstancesHandler) Heartbeat(w http.ResponseWriter, r *http.Request) { _, err = h.queries.GetInstance(r.Context(), heartbeat.UUID) - if err != nil { + 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", @@ -71,9 +86,9 @@ func (h *InstancesHandler) Heartbeat(w http.ResponseWriter, r *http.Request) { }) return } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusCreated) render.JSON(w, r, map[string]string{ - "status": "200", + "status": "201", "message": "Instance created", }) return diff --git a/main.go b/main.go index a3f6276..5b4ae02 100644 --- a/main.go +++ b/main.go @@ -10,8 +10,8 @@ import ( "time" "tinyauth-analytics/database/queries" - "github.com/go-chi/chi/middleware" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/spf13/viper" _ "modernc.org/sqlite" diff --git a/rate_limiter.go b/rate_limiter.go index c4247b5..68a3e00 100644 --- a/rate_limiter.go +++ b/rate_limiter.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "log/slog" "net" "net/http" "slices" @@ -42,7 +43,7 @@ func (rl *RateLimiter) limit(next http.Handler) http.Handler { 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(24*time.Hour).Unix())) + 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 @@ -52,7 +53,15 @@ func (rl *RateLimiter) limit(next http.Handler) http.Handler { return } - used := value.(int) + 1 + 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))