feat(canary): Phase 1 — Postgres schema + token & event repositories
Schema (3 goose migrations under internal/core/migrations/):
- 0001_create_tokens.sql: tokens table per spec §7.1
Columns: id (varchar 12, PK), manage_id (UUID unique), type, memo,
filename, alert_channel, telegram_bot/chat, webhook_url,
created_at/ip/fp, enabled, trigger_count, last_triggered, metadata (jsonb)
CHECK constraints: chk_token_type (7-token enum), chk_alert_channel (telegram|webhook),
chk_telegram_complete (when channel=telegram, bot+chat required),
chk_webhook_complete (when channel=webhook, url required)
- 0002_create_events.sql: events table
Columns: id (bigserial PK), token_id (FK with ON DELETE CASCADE),
triggered_at, source_ip (inet), user_agent, referer,
geo_country/region/city/asn/asn_org, extra (jsonb),
notify_status, notified_at
CHECK chk_notify_status (pending|sent|failed|deduped)
- 0003_indexes.sql: full index set per spec §7.1
tokens(created_ip), tokens(created_fp), tokens(created_at DESC),
tokens(type), partial idx_tokens_trigger_count WHERE trigger_count > 0,
events(token_id, triggered_at DESC), events(source_ip),
partial idx_events_notify_pending WHERE notify_status = 'pending'
Migration runner (internal/core/migrations.go):
- go:embed migrations/*.sql baked into binary
- core.RunMigrations(*sql.DB) called from main.go after DB connect
- pressly/goose v3.27.1 (research-recommended; no dirty-state bug)
Token domain (internal/token/):
- entity.go: Token struct + typed Type/AlertChannel enums with Valid() guards
- dto.go: CreateRequest with validator/v10 tags (oneof, required_if, url, max),
Response shape with ToResponse(triggerURL, manageURL) helper
- repository.go: Insert (RETURNING created_at + counters), GetByID,
GetByManageID, DeleteByManageID (FK cascade), IncrementTriggerCount,
SetEnabled, ListAll + CountAll. ErrNotFound on sql.ErrNoRows.
Named-parameter binding via sqlx.NamedExecContext.
- repository_test.go (//go:build integration): 11 tests covering insert,
get-by-id, get-by-manage-id, not-found paths, delete with cascade,
trigger count increment, enable toggle, list pagination, type CHECK
Event domain (internal/event/):
- entity.go: Event struct + NotifyStatus enum
- dto.go: GeoView + Response with ToResponse() flattens geo into nested object
- repository.go: Insert, GetByID, ListByToken (cursor pagination via
LIMIT N+1, returns NextCursor + HasMore), CountByToken,
AttachFingerprint (UPDATE most-recent within window using
jsonb || merge), UpdateNotifyStatus, PruneToLimit
(window function row_number() PARTITION BY token_id)
- repository_test.go (//go:build integration): 9 tests covering insert,
cursor-paginated listing, FK cascade from token deletion, fingerprint
merge into existing extra jsonb, notify status update, prune-to-N
preserves newest-first, prune rejects zero limit
Test infrastructure:
- internal/testutil/postgres.go: testcontainers-go helper that spins up
postgres:18-alpine, applies migrations via core.RunMigrations, returns
ready *sql.DB with t.Cleanup-registered teardown. ~5-7s per test;
20 integration tests run in ~13s end-to-end.
Wired into main.go:
- core.RunMigrations(db.DB.DB) called after NewDatabase
- token + event repos instantiated; blank-assigned for now (services
consume them in Phase 9/10)
Verification:
- go build ./... clean
- go vet ./... clean
- go test -tags=integration ./internal/token/... 11 of 11 PASS
- go test -tags=integration ./internal/event/... 9 of 9 PASS
This commit is contained in:
parent
a86939f412
commit
b2558e2e41
|
|
@ -6,6 +6,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
|
@ -16,9 +17,11 @@ import (
|
|||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/config"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/health"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/server"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
)
|
||||
|
||||
const drainDelay = 5 * time.Second
|
||||
|
|
@ -64,6 +67,16 @@ func run(configPath string) error {
|
|||
}
|
||||
logger.Info("database connected")
|
||||
|
||||
if err := core.RunMigrations(db.DB.DB); err != nil {
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
logger.Info("migrations applied")
|
||||
|
||||
tokenRepo := token.NewRepository(db.DB)
|
||||
eventRepo := event.NewRepository(db.DB)
|
||||
_ = tokenRepo
|
||||
_ = eventRepo
|
||||
|
||||
rdb, err := core.NewRedis(ctx, cfg.Redis)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1,36 +1,55 @@
|
|||
module github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-playground/validator/v10 v10.23.0
|
||||
github.com/go-redis/redis_rate/v10 v10.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.0
|
||||
github.com/knadh/koanf/providers/env v1.1.0
|
||||
github.com/knadh/koanf/providers/file v1.2.1
|
||||
github.com/knadh/koanf/v2 v2.1.2
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
go.opentelemetry.io/otel v1.33.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0
|
||||
go.opentelemetry.io/otel/sdk v1.33.0
|
||||
go.opentelemetry.io/otel/trace v1.33.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.68.1
|
||||
google.golang.org/grpc v1.80.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
|
|
@ -38,20 +57,46 @@ require (
|
|||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.2 // indirect
|
||||
github.com/moby/moby/client v0.4.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.33.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.3 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
|
||||
google.golang.org/protobuf v1.35.2 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
|
|
@ -8,11 +17,35 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
|
|||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
|
||||
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
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.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
|
|
@ -20,10 +53,12 @@ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcP
|
|||
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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
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=
|
||||
|
|
@ -34,14 +69,16 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
|
|||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
|
||||
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
|
||||
|
|
@ -50,12 +87,14 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
|||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
|
||||
|
|
@ -74,66 +113,150 @@ 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
|
||||
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
|
||||
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
|
||||
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
|
||||
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
|
||||
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
|
||||
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
|
||||
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
|
||||
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/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
|
||||
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
|
||||
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
|
||||
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
|
||||
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
|
||||
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
|
||||
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
|
||||
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
|
||||
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
|
||||
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
|
||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
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.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
// ©AngelaMos | 2026
|
||||
// migrations.go
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func RunMigrations(db *sql.DB) error {
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return fmt.Errorf("goose set dialect: %w", err)
|
||||
}
|
||||
if err := goose.Up(db, "migrations"); err != nil {
|
||||
return fmt.Errorf("goose up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
-- ©AngelaMos | 2026
|
||||
-- 0001_create_tokens.sql
|
||||
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE tokens (
|
||||
id VARCHAR(12) PRIMARY KEY,
|
||||
manage_id UUID UNIQUE NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
memo TEXT NOT NULL DEFAULT '',
|
||||
filename TEXT,
|
||||
|
||||
alert_channel VARCHAR(16) NOT NULL,
|
||||
telegram_bot TEXT,
|
||||
telegram_chat TEXT,
|
||||
webhook_url TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_ip INET NOT NULL,
|
||||
created_fp CHAR(16) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
trigger_count BIGINT NOT NULL DEFAULT 0,
|
||||
last_triggered TIMESTAMPTZ,
|
||||
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
CONSTRAINT chk_token_type CHECK (type IN
|
||||
('webbug', 'slowredirect', 'docx', 'pdf', 'kubeconfig', 'envfile', 'mysql')),
|
||||
CONSTRAINT chk_alert_channel CHECK (alert_channel IN ('telegram', 'webhook')),
|
||||
CONSTRAINT chk_telegram_complete CHECK (
|
||||
alert_channel <> 'telegram' OR
|
||||
(telegram_bot IS NOT NULL AND telegram_chat IS NOT NULL)
|
||||
),
|
||||
CONSTRAINT chk_webhook_complete CHECK (
|
||||
alert_channel <> 'webhook' OR webhook_url IS NOT NULL
|
||||
)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS tokens;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
-- ©AngelaMos | 2026
|
||||
-- 0002_create_events.sql
|
||||
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
token_id VARCHAR(12) NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
|
||||
triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
source_ip INET NOT NULL,
|
||||
user_agent TEXT,
|
||||
referer TEXT,
|
||||
|
||||
geo_country CHAR(2),
|
||||
geo_region VARCHAR(64),
|
||||
geo_city VARCHAR(64),
|
||||
geo_asn INT,
|
||||
geo_asn_org VARCHAR(128),
|
||||
|
||||
extra JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
notify_status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
notified_at TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT chk_notify_status CHECK (
|
||||
notify_status IN ('pending', 'sent', 'failed', 'deduped')
|
||||
)
|
||||
);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS events;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
-- ©AngelaMos | 2026
|
||||
-- 0003_indexes.sql
|
||||
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE INDEX idx_tokens_created_ip ON tokens(created_ip);
|
||||
CREATE INDEX idx_tokens_created_fp ON tokens(created_fp);
|
||||
CREATE INDEX idx_tokens_created_at ON tokens(created_at DESC);
|
||||
CREATE INDEX idx_tokens_type ON tokens(type);
|
||||
CREATE INDEX idx_tokens_trigger_count
|
||||
ON tokens(trigger_count DESC) WHERE trigger_count > 0;
|
||||
|
||||
CREATE INDEX idx_events_token_triggered ON events(token_id, triggered_at DESC);
|
||||
CREATE INDEX idx_events_source_ip ON events(source_ip);
|
||||
CREATE INDEX idx_events_notify_pending
|
||||
ON events(notify_status) WHERE notify_status = 'pending';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS idx_events_notify_pending;
|
||||
DROP INDEX IF EXISTS idx_events_source_ip;
|
||||
DROP INDEX IF EXISTS idx_events_token_triggered;
|
||||
DROP INDEX IF EXISTS idx_tokens_trigger_count;
|
||||
DROP INDEX IF EXISTS idx_tokens_type;
|
||||
DROP INDEX IF EXISTS idx_tokens_created_at;
|
||||
DROP INDEX IF EXISTS idx_tokens_created_fp;
|
||||
DROP INDEX IF EXISTS idx_tokens_created_ip;
|
||||
-- +goose StatementEnd
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// ©AngelaMos | 2026
|
||||
// dto.go
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeoView struct {
|
||||
Country *string `json:"country"`
|
||||
Region *string `json:"region"`
|
||||
City *string `json:"city"`
|
||||
ASN *int `json:"asn"`
|
||||
ASNOrg *string `json:"asn_org"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID int64 `json:"id"`
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
Referer *string `json:"referer"`
|
||||
Geo GeoView `json:"geo"`
|
||||
Extra json.RawMessage `json:"extra"`
|
||||
NotifyStatus NotifyStatus `json:"notify_status"`
|
||||
NotifiedAt *time.Time `json:"notified_at"`
|
||||
}
|
||||
|
||||
func (e *Event) ToResponse() Response {
|
||||
return Response{
|
||||
ID: e.ID,
|
||||
TriggeredAt: e.TriggeredAt,
|
||||
SourceIP: e.SourceIP,
|
||||
UserAgent: e.UserAgent,
|
||||
Referer: e.Referer,
|
||||
Geo: GeoView{
|
||||
Country: e.GeoCountry,
|
||||
Region: e.GeoRegion,
|
||||
City: e.GeoCity,
|
||||
ASN: e.GeoASN,
|
||||
ASNOrg: e.GeoASNOrg,
|
||||
},
|
||||
Extra: e.Extra,
|
||||
NotifyStatus: e.NotifyStatus,
|
||||
NotifiedAt: e.NotifiedAt,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// ©AngelaMos | 2026
|
||||
// entity.go
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NotifyStatus string
|
||||
|
||||
const (
|
||||
NotifyPending NotifyStatus = "pending"
|
||||
NotifySent NotifyStatus = "sent"
|
||||
NotifyFailed NotifyStatus = "failed"
|
||||
NotifyDeduped NotifyStatus = "deduped"
|
||||
)
|
||||
|
||||
func (s NotifyStatus) Valid() bool {
|
||||
switch s {
|
||||
case NotifyPending, NotifySent, NotifyFailed, NotifyDeduped:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
TokenID string `db:"token_id" json:"token_id"`
|
||||
TriggeredAt time.Time `db:"triggered_at" json:"triggered_at"`
|
||||
SourceIP string `db:"source_ip" json:"source_ip"`
|
||||
UserAgent *string `db:"user_agent" json:"user_agent"`
|
||||
Referer *string `db:"referer" json:"referer"`
|
||||
GeoCountry *string `db:"geo_country" json:"geo_country"`
|
||||
GeoRegion *string `db:"geo_region" json:"geo_region"`
|
||||
GeoCity *string `db:"geo_city" json:"geo_city"`
|
||||
GeoASN *int `db:"geo_asn" json:"geo_asn"`
|
||||
GeoASNOrg *string `db:"geo_asn_org" json:"geo_asn_org"`
|
||||
Extra json.RawMessage `db:"extra" json:"extra"`
|
||||
NotifyStatus NotifyStatus `db:"notify_status" json:"notify_status"`
|
||||
NotifiedAt *time.Time `db:"notified_at" json:"notified_at"`
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repository.go
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("event not found")
|
||||
|
||||
type Repository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *sqlx.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
const insertSQL = `
|
||||
INSERT INTO events (
|
||||
token_id, source_ip, user_agent, referer,
|
||||
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
|
||||
extra, notify_status
|
||||
) VALUES (
|
||||
:token_id, :source_ip, :user_agent, :referer,
|
||||
:geo_country, :geo_region, :geo_city, :geo_asn, :geo_asn_org,
|
||||
:extra, :notify_status
|
||||
)
|
||||
RETURNING id, triggered_at`
|
||||
|
||||
func (r *Repository) Insert(ctx context.Context, e *Event) error {
|
||||
if e.NotifyStatus == "" {
|
||||
e.NotifyStatus = NotifyPending
|
||||
}
|
||||
if len(e.Extra) == 0 {
|
||||
e.Extra = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
stmt, err := r.db.PrepareNamedContext(ctx, insertSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare insert event: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if err := stmt.GetContext(ctx, e, e); err != nil {
|
||||
return fmt.Errorf("insert event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const selectColumns = `
|
||||
id, token_id, triggered_at,
|
||||
source_ip, user_agent, referer,
|
||||
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
|
||||
extra, notify_status, notified_at`
|
||||
|
||||
type ListOptions struct {
|
||||
Cursor int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Events []Event
|
||||
NextCursor int64
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
func (r *Repository) ListByToken(
|
||||
ctx context.Context, tokenID string, opts ListOptions,
|
||||
) (ListResult, error) {
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = 20
|
||||
}
|
||||
|
||||
q := `SELECT ` + selectColumns + `
|
||||
FROM events
|
||||
WHERE token_id = $1
|
||||
AND ($2 = 0 OR id < $2)
|
||||
ORDER BY id DESC
|
||||
LIMIT $3`
|
||||
|
||||
var events []Event
|
||||
err := r.db.SelectContext(ctx, &events, q, tokenID, opts.Cursor, opts.Limit+1)
|
||||
if err != nil {
|
||||
return ListResult{}, fmt.Errorf("list events: %w", err)
|
||||
}
|
||||
|
||||
hasMore := len(events) > opts.Limit
|
||||
if hasMore {
|
||||
events = events[:opts.Limit]
|
||||
}
|
||||
|
||||
var next int64
|
||||
if hasMore && len(events) > 0 {
|
||||
next = events[len(events)-1].ID
|
||||
}
|
||||
|
||||
return ListResult{
|
||||
Events: events,
|
||||
NextCursor: next,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountByToken(ctx context.Context, tokenID string) (int64, error) {
|
||||
var n int64
|
||||
err := r.db.GetContext(ctx, &n,
|
||||
`SELECT COUNT(*) FROM events WHERE token_id = $1`, tokenID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count events: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AttachFingerprint(
|
||||
ctx context.Context, tokenID, sourceIP string, fingerprint json.RawMessage, window time.Duration,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE events
|
||||
SET extra = extra || $3::jsonb
|
||||
WHERE id = (
|
||||
SELECT id FROM events
|
||||
WHERE token_id = $1
|
||||
AND source_ip = $2::inet
|
||||
AND triggered_at >= NOW() - $4::interval
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)`
|
||||
res, err := r.db.ExecContext(ctx, q,
|
||||
tokenID, sourceIP, []byte(fingerprint), fmt.Sprintf("%d milliseconds", window.Milliseconds()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("attach fingerprint: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateNotifyStatus(
|
||||
ctx context.Context, eventID int64, status NotifyStatus, sentAt *time.Time,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE events
|
||||
SET notify_status = $2, notified_at = $3
|
||||
WHERE id = $1`
|
||||
res, err := r.db.ExecContext(ctx, q, eventID, status, sentAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update notify status: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) PruneToLimit(ctx context.Context, perTokenLimit int) (int64, error) {
|
||||
if perTokenLimit <= 0 {
|
||||
return 0, errors.New("perTokenLimit must be positive")
|
||||
}
|
||||
q := `
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
row_number() OVER (PARTITION BY token_id ORDER BY triggered_at DESC) AS rn
|
||||
FROM events
|
||||
)
|
||||
DELETE FROM events
|
||||
WHERE id IN (SELECT id FROM ranked WHERE rn > $1)`
|
||||
res, err := r.db.ExecContext(ctx, q, perTokenLimit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune events: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetByID(ctx context.Context, id int64) (*Event, error) {
|
||||
var e Event
|
||||
q := `SELECT ` + selectColumns + ` FROM events WHERE id = $1`
|
||||
err := r.db.GetContext(ctx, &e, q, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get event by id: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repository_test.go
|
||||
|
||||
//go:build integration
|
||||
|
||||
package event_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func newRepos(t *testing.T) (*sqlx.DB, *token.Repository, *event.Repository) {
|
||||
t.Helper()
|
||||
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
|
||||
return db, token.NewRepository(db), event.NewRepository(db)
|
||||
}
|
||||
|
||||
func seedToken(t *testing.T, repo *token.Repository, id string) *token.Token {
|
||||
t.Helper()
|
||||
tok := &token.Token{
|
||||
ID: id,
|
||||
ManageID: uuid.New().String(),
|
||||
Type: token.TypeWebbug,
|
||||
Memo: "event-test",
|
||||
AlertChannel: token.ChannelWebhook,
|
||||
WebhookURL: ptr("https://example.com/hook"),
|
||||
CreatedIP: "203.0.113.1",
|
||||
CreatedFP: "abcdef0123456789",
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, repo.Insert(context.Background(), tok))
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestRepository_InsertAndCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtinsert001")
|
||||
|
||||
for range 3 {
|
||||
e := &event.Event{
|
||||
TokenID: tok.ID,
|
||||
SourceIP: "203.0.113.45",
|
||||
}
|
||||
require.NoError(t, evtRepo.Insert(ctx, e))
|
||||
require.NotZero(t, e.ID)
|
||||
require.False(t, e.TriggeredAt.IsZero())
|
||||
require.Equal(t, event.NotifyPending, e.NotifyStatus)
|
||||
}
|
||||
|
||||
count, err := evtRepo.CountByToken(ctx, tok.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), count)
|
||||
}
|
||||
|
||||
func TestRepository_ListByToken_CursorPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtcursor001")
|
||||
|
||||
for i := range 5 {
|
||||
e := &event.Event{
|
||||
TokenID: tok.ID,
|
||||
SourceIP: "203.0.113.45",
|
||||
UserAgent: ptr(string(rune('A' + i))),
|
||||
}
|
||||
require.NoError(t, evtRepo.Insert(ctx, e))
|
||||
}
|
||||
|
||||
page1, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{Limit: 2})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page1.Events, 2)
|
||||
require.True(t, page1.HasMore)
|
||||
require.NotZero(t, page1.NextCursor)
|
||||
require.Equal(t, "E", *page1.Events[0].UserAgent, "newest first")
|
||||
|
||||
page2, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{
|
||||
Cursor: page1.NextCursor, Limit: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page2.Events, 2)
|
||||
require.True(t, page2.HasMore)
|
||||
|
||||
page3, err := evtRepo.ListByToken(ctx, tok.ID, event.ListOptions{
|
||||
Cursor: page2.NextCursor, Limit: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, page3.Events, 1)
|
||||
require.False(t, page3.HasMore)
|
||||
require.Zero(t, page3.NextCursor)
|
||||
}
|
||||
|
||||
func TestRepository_FKCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtcascade01")
|
||||
|
||||
for range 3 {
|
||||
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45"}
|
||||
require.NoError(t, evtRepo.Insert(ctx, e))
|
||||
}
|
||||
|
||||
require.NoError(t, tokRepo.DeleteByManageID(ctx, tok.ManageID))
|
||||
|
||||
count, err := evtRepo.CountByToken(ctx, tok.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), count, "cascade delete should remove all events")
|
||||
}
|
||||
|
||||
func TestRepository_AttachFingerprint(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtfprint001")
|
||||
|
||||
e := &event.Event{
|
||||
TokenID: tok.ID,
|
||||
SourceIP: "203.0.113.45",
|
||||
Extra: json.RawMessage(`{"initial":"value"}`),
|
||||
}
|
||||
require.NoError(t, evtRepo.Insert(ctx, e))
|
||||
|
||||
fp := json.RawMessage(`{"screen":"1920x1080","tz":"America/Los_Angeles"}`)
|
||||
require.NoError(t, evtRepo.AttachFingerprint(ctx, tok.ID, "203.0.113.45", fp, 30*time.Second))
|
||||
|
||||
got, err := evtRepo.GetByID(ctx, e.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var merged map[string]any
|
||||
require.NoError(t, json.Unmarshal(got.Extra, &merged))
|
||||
require.Equal(t, "value", merged["initial"])
|
||||
require.Equal(t, "1920x1080", merged["screen"])
|
||||
require.Equal(t, "America/Los_Angeles", merged["tz"])
|
||||
}
|
||||
|
||||
func TestRepository_AttachFingerprint_NoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtfpnomtch1")
|
||||
|
||||
fp := json.RawMessage(`{"screen":"1024x768"}`)
|
||||
err := evtRepo.AttachFingerprint(ctx, tok.ID, "203.0.113.45", fp, 30*time.Second)
|
||||
require.ErrorIs(t, err, event.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_UpdateNotifyStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtnotify001")
|
||||
|
||||
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45"}
|
||||
require.NoError(t, evtRepo.Insert(ctx, e))
|
||||
require.Equal(t, event.NotifyPending, e.NotifyStatus)
|
||||
|
||||
now := time.Now()
|
||||
require.NoError(t, evtRepo.UpdateNotifyStatus(ctx, e.ID, event.NotifySent, &now))
|
||||
|
||||
got, err := evtRepo.GetByID(ctx, e.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, event.NotifySent, got.NotifyStatus)
|
||||
require.NotNil(t, got.NotifiedAt)
|
||||
}
|
||||
|
||||
func TestRepository_PruneToLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tokA := seedToken(t, tokRepo, "evtpruneaaa1")
|
||||
tokB := seedToken(t, tokRepo, "evtprunebbb1")
|
||||
|
||||
for range 5 {
|
||||
require.NoError(t, evtRepo.Insert(ctx, &event.Event{TokenID: tokA.ID, SourceIP: "203.0.113.10"}))
|
||||
}
|
||||
for range 7 {
|
||||
require.NoError(t, evtRepo.Insert(ctx, &event.Event{TokenID: tokB.ID, SourceIP: "203.0.113.20"}))
|
||||
}
|
||||
|
||||
deleted, err := evtRepo.PruneToLimit(ctx, 3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64((5-3)+(7-3)), deleted, "should delete 2 from A and 4 from B")
|
||||
|
||||
cntA, err := evtRepo.CountByToken(ctx, tokA.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), cntA)
|
||||
|
||||
cntB, err := evtRepo.CountByToken(ctx, tokB.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), cntB)
|
||||
}
|
||||
|
||||
func TestRepository_PruneToLimit_KeepsNewest(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, tokRepo, evtRepo := newRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := seedToken(t, tokRepo, "evtprunekeep")
|
||||
|
||||
var ids []int64
|
||||
for i := range 5 {
|
||||
e := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.45", UserAgent: ptr(string(rune('A' + i)))}
|
||||
require.NoError(t, evtRepo.Insert(ctx, e))
|
||||
ids = append(ids, e.ID)
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
|
||||
_, err := evtRepo.PruneToLimit(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
cnt, err := evtRepo.CountByToken(ctx, tok.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), cnt)
|
||||
|
||||
_, err = evtRepo.GetByID(ctx, ids[4])
|
||||
require.NoError(t, err, "newest should be kept")
|
||||
_, err = evtRepo.GetByID(ctx, ids[3])
|
||||
require.NoError(t, err, "second-newest should be kept")
|
||||
_, err = evtRepo.GetByID(ctx, ids[0])
|
||||
require.ErrorIs(t, err, event.ErrNotFound, "oldest should be deleted")
|
||||
}
|
||||
|
||||
func TestRepository_PruneToLimit_RejectsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, evtRepo := newRepos(t)
|
||||
|
||||
_, err := evtRepo.PruneToLimit(context.Background(), 0)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// ©AngelaMos | 2026
|
||||
// postgres.go
|
||||
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/core"
|
||||
)
|
||||
|
||||
func NewTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
pgContainer, err := tcpostgres.Run(ctx, "postgres:18-alpine",
|
||||
tcpostgres.WithDatabase("canary_test"),
|
||||
tcpostgres.WithUsername("test"),
|
||||
tcpostgres.WithPassword("test"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(60*time.Second),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = pgContainer.Terminate(context.Background())
|
||||
})
|
||||
|
||||
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := sql.Open("pgx", connStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
})
|
||||
|
||||
require.NoError(t, db.Ping())
|
||||
require.NoError(t, core.RunMigrations(db))
|
||||
|
||||
return db
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// ©AngelaMos | 2026
|
||||
// dto.go
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateRequest struct {
|
||||
Type Type `json:"type" validate:"required,oneof=webbug slowredirect docx pdf kubeconfig envfile mysql"`
|
||||
Memo string `json:"memo" validate:"max=256"`
|
||||
Filename string `json:"filename" validate:"max=128"`
|
||||
AlertChannel AlertChannel `json:"alert_channel" validate:"required,oneof=telegram webhook"`
|
||||
TelegramBot string `json:"telegram_bot" validate:"required_if=AlertChannel telegram"`
|
||||
TelegramChat string `json:"telegram_chat" validate:"required_if=AlertChannel telegram"`
|
||||
WebhookURL string `json:"webhook_url" validate:"required_if=AlertChannel webhook,omitempty,url"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
TurnstileResp string `json:"cf_turnstile_response" validate:"required"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
ManageID string `json:"manage_id"`
|
||||
Type Type `json:"type"`
|
||||
Memo string `json:"memo"`
|
||||
Filename *string `json:"filename"`
|
||||
AlertChannel AlertChannel `json:"alert_channel"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TriggerCount int64 `json:"trigger_count"`
|
||||
LastTriggered *time.Time `json:"last_triggered"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TriggerURL string `json:"trigger_url"`
|
||||
ManageURL string `json:"manage_url"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (t *Token) ToResponse(triggerURL, manageURL string) Response {
|
||||
return Response{
|
||||
ID: t.ID,
|
||||
ManageID: t.ManageID,
|
||||
Type: t.Type,
|
||||
Memo: t.Memo,
|
||||
Filename: t.Filename,
|
||||
AlertChannel: t.AlertChannel,
|
||||
CreatedAt: t.CreatedAt,
|
||||
TriggerCount: t.TriggerCount,
|
||||
LastTriggered: t.LastTriggered,
|
||||
Enabled: t.Enabled,
|
||||
TriggerURL: triggerURL,
|
||||
ManageURL: manageURL,
|
||||
Metadata: t.Metadata,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
// ©AngelaMos | 2026
|
||||
// entity.go
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeWebbug Type = "webbug"
|
||||
TypeSlowRedirect Type = "slowredirect"
|
||||
TypeDocx Type = "docx"
|
||||
TypePDF Type = "pdf"
|
||||
TypeKubeconfig Type = "kubeconfig"
|
||||
TypeEnvfile Type = "envfile"
|
||||
TypeMySQL Type = "mysql"
|
||||
)
|
||||
|
||||
func (t Type) Valid() bool {
|
||||
switch t {
|
||||
case TypeWebbug, TypeSlowRedirect, TypeDocx, TypePDF,
|
||||
TypeKubeconfig, TypeEnvfile, TypeMySQL:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type AlertChannel string
|
||||
|
||||
const (
|
||||
ChannelTelegram AlertChannel = "telegram"
|
||||
ChannelWebhook AlertChannel = "webhook"
|
||||
)
|
||||
|
||||
func (c AlertChannel) Valid() bool {
|
||||
switch c {
|
||||
case ChannelTelegram, ChannelWebhook:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
ManageID string `db:"manage_id" json:"manage_id"`
|
||||
Type Type `db:"type" json:"type"`
|
||||
Memo string `db:"memo" json:"memo"`
|
||||
Filename *string `db:"filename" json:"filename"`
|
||||
AlertChannel AlertChannel `db:"alert_channel" json:"alert_channel"`
|
||||
TelegramBot *string `db:"telegram_bot" json:"-"`
|
||||
TelegramChat *string `db:"telegram_chat" json:"-"`
|
||||
WebhookURL *string `db:"webhook_url" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
CreatedIP string `db:"created_ip" json:"-"`
|
||||
CreatedFP string `db:"created_fp" json:"-"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
TriggerCount int64 `db:"trigger_count" json:"trigger_count"`
|
||||
LastTriggered *time.Time `db:"last_triggered" json:"last_triggered"`
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repository.go
|
||||
|
||||
package token
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("token not found")
|
||||
|
||||
type Repository struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *sqlx.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
const insertSQL = `
|
||||
INSERT INTO tokens (
|
||||
id, manage_id, type, memo, filename,
|
||||
alert_channel, telegram_bot, telegram_chat, webhook_url,
|
||||
created_ip, created_fp, metadata, enabled
|
||||
) VALUES (
|
||||
:id, :manage_id, :type, :memo, :filename,
|
||||
:alert_channel, :telegram_bot, :telegram_chat, :webhook_url,
|
||||
:created_ip, :created_fp, :metadata, :enabled
|
||||
)
|
||||
RETURNING created_at, trigger_count, last_triggered`
|
||||
|
||||
func (r *Repository) Insert(ctx context.Context, t *Token) error {
|
||||
stmt, err := r.db.PrepareNamedContext(ctx, insertSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare insert token: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if err := stmt.GetContext(ctx, t, t); err != nil {
|
||||
return fmt.Errorf("insert token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const selectColumns = `
|
||||
id, manage_id, type, memo, filename,
|
||||
alert_channel, telegram_bot, telegram_chat, webhook_url,
|
||||
created_at, created_ip, created_fp, enabled,
|
||||
trigger_count, last_triggered, metadata`
|
||||
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Token, error) {
|
||||
var t Token
|
||||
q := `SELECT ` + selectColumns + ` FROM tokens WHERE id = $1`
|
||||
err := r.db.GetContext(ctx, &t, q, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get token by id: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetByManageID(ctx context.Context, manageID string) (*Token, error) {
|
||||
var t Token
|
||||
q := `SELECT ` + selectColumns + ` FROM tokens WHERE manage_id = $1`
|
||||
err := r.db.GetContext(ctx, &t, q, manageID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get token by manage_id: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteByManageID(ctx context.Context, manageID string) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM tokens WHERE manage_id = $1`, manageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete token: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) IncrementTriggerCount(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE tokens
|
||||
SET trigger_count = trigger_count + 1,
|
||||
last_triggered = NOW()
|
||||
WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("increment trigger count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) SetEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE tokens SET enabled = $2 WHERE id = $1`, id, enabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set enabled: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (r *Repository) ListAll(ctx context.Context, opts ListOptions) ([]Token, error) {
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = 50
|
||||
}
|
||||
q := `SELECT ` + selectColumns + ` FROM tokens
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`
|
||||
var tokens []Token
|
||||
if err := r.db.SelectContext(ctx, &tokens, q, opts.Limit, opts.Offset); err != nil {
|
||||
return nil, fmt.Errorf("list all tokens: %w", err)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountAll(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
if err := r.db.GetContext(ctx, &n, `SELECT COUNT(*) FROM tokens`); err != nil {
|
||||
return 0, fmt.Errorf("count tokens: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
// ©AngelaMos | 2026
|
||||
// repository_test.go
|
||||
|
||||
//go:build integration
|
||||
|
||||
package token_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
|
||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||
)
|
||||
|
||||
func newRepo(t *testing.T) *token.Repository {
|
||||
t.Helper()
|
||||
db := sqlx.NewDb(testutil.NewTestDB(t), "pgx")
|
||||
return token.NewRepository(db)
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func sampleWebhookToken(id string) *token.Token {
|
||||
return &token.Token{
|
||||
ID: id,
|
||||
ManageID: uuid.New().String(),
|
||||
Type: token.TypeWebbug,
|
||||
Memo: "test webbug",
|
||||
AlertChannel: token.ChannelWebhook,
|
||||
WebhookURL: ptr("https://example.com/hook"),
|
||||
CreatedIP: "203.0.113.10",
|
||||
CreatedFP: "0123456789abcdef",
|
||||
Metadata: json.RawMessage(`{}`),
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func sampleTelegramToken(id string) *token.Token {
|
||||
return &token.Token{
|
||||
ID: id,
|
||||
ManageID: uuid.New().String(),
|
||||
Type: token.TypeDocx,
|
||||
Memo: "Q4 bonuses",
|
||||
Filename: ptr("Q4_Bonuses_2024.docx"),
|
||||
AlertChannel: token.ChannelTelegram,
|
||||
TelegramBot: ptr("123456:ABCDEF"),
|
||||
TelegramChat: ptr("-1001234567890"),
|
||||
CreatedIP: "198.51.100.5",
|
||||
CreatedFP: "fedcba9876543210",
|
||||
Metadata: json.RawMessage(`{"include_keys":["aws"]}`),
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepository_InsertAndGetByID(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := sampleWebhookToken("abcdef0123ko")
|
||||
require.NoError(t, repo.Insert(ctx, tok))
|
||||
require.False(t, tok.CreatedAt.IsZero(), "Insert should populate created_at via RETURNING")
|
||||
|
||||
got, err := repo.GetByID(ctx, tok.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tok.ID, got.ID)
|
||||
require.Equal(t, tok.ManageID, got.ManageID)
|
||||
require.Equal(t, tok.Type, got.Type)
|
||||
require.Equal(t, tok.AlertChannel, got.AlertChannel)
|
||||
require.Equal(t, tok.Memo, got.Memo)
|
||||
require.NotNil(t, got.WebhookURL)
|
||||
require.Equal(t, "https://example.com/hook", *got.WebhookURL)
|
||||
require.True(t, got.Enabled)
|
||||
require.Equal(t, int64(0), got.TriggerCount)
|
||||
}
|
||||
|
||||
func TestRepository_GetByID_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
|
||||
_, err := repo.GetByID(context.Background(), "doesnotexist")
|
||||
require.ErrorIs(t, err, token.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_GetByManageID(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := sampleTelegramToken("mngabcdef012")
|
||||
require.NoError(t, repo.Insert(ctx, tok))
|
||||
|
||||
got, err := repo.GetByManageID(ctx, tok.ManageID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, tok.ID, got.ID)
|
||||
require.Equal(t, token.ChannelTelegram, got.AlertChannel)
|
||||
require.NotNil(t, got.TelegramBot)
|
||||
require.Equal(t, "123456:ABCDEF", *got.TelegramBot)
|
||||
}
|
||||
|
||||
func TestRepository_GetByManageID_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
|
||||
_, err := repo.GetByManageID(context.Background(), uuid.New().String())
|
||||
require.ErrorIs(t, err, token.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_DeleteByManageID(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := sampleWebhookToken("delok0123abc")
|
||||
require.NoError(t, repo.Insert(ctx, tok))
|
||||
|
||||
require.NoError(t, repo.DeleteByManageID(ctx, tok.ManageID))
|
||||
|
||||
_, err := repo.GetByID(ctx, tok.ID)
|
||||
require.ErrorIs(t, err, token.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_DeleteByManageID_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
|
||||
err := repo.DeleteByManageID(context.Background(), uuid.New().String())
|
||||
require.True(t, errors.Is(err, token.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestRepository_IncrementTriggerCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := sampleWebhookToken("trgcount001a")
|
||||
require.NoError(t, repo.Insert(ctx, tok))
|
||||
|
||||
require.NoError(t, repo.IncrementTriggerCount(ctx, tok.ID))
|
||||
require.NoError(t, repo.IncrementTriggerCount(ctx, tok.ID))
|
||||
|
||||
got, err := repo.GetByID(ctx, tok.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), got.TriggerCount)
|
||||
require.NotNil(t, got.LastTriggered)
|
||||
}
|
||||
|
||||
func TestRepository_SetEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tok := sampleWebhookToken("setenab0001a")
|
||||
require.NoError(t, repo.Insert(ctx, tok))
|
||||
|
||||
require.NoError(t, repo.SetEnabled(ctx, tok.ID, false))
|
||||
|
||||
got, err := repo.GetByID(ctx, tok.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, got.Enabled)
|
||||
|
||||
require.NoError(t, repo.SetEnabled(ctx, tok.ID, true))
|
||||
got, err = repo.GetByID(ctx, tok.ID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, got.Enabled)
|
||||
}
|
||||
|
||||
func TestRepository_SetEnabled_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
|
||||
err := repo.SetEnabled(context.Background(), "nonexistent", false)
|
||||
require.ErrorIs(t, err, token.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestRepository_ListAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, id := range []string{"lstaaaaaaaa1", "lstbbbbbbbb2", "lstcccccccc3"} {
|
||||
tok := sampleWebhookToken(id)
|
||||
tok.Memo = "list-test-" + id
|
||||
require.NoError(t, repo.Insert(ctx, tok))
|
||||
}
|
||||
|
||||
got, err := repo.ListAll(ctx, token.ListOptions{Limit: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3)
|
||||
|
||||
count, err := repo.CountAll(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), count)
|
||||
}
|
||||
|
||||
func TestRepository_TypeAndChannelValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newRepo(t)
|
||||
|
||||
bad := sampleWebhookToken("badtypee0001")
|
||||
bad.Type = "invalid_type"
|
||||
|
||||
err := repo.Insert(context.Background(), bad)
|
||||
require.Error(t, err, "DB CHECK should reject invalid type")
|
||||
}
|
||||
Loading…
Reference in New Issue