feat(canary): full wire-up + webbug refactor + mysql goroutine
Phase 9 tasks 9.7 + 9.8 + closes Phase 8 task 8.5 deferral.
config.Config additions:
- Canary {BaseURL, ManageURL} — public URLs for trigger
+ manage redirects
- Turnstile {SecretKey, SiteKey} — Cloudflare Turnstile
(empty SecretKey = dev bypass)
- MySQL {Enabled, Addr, PublicHost, — fake TCP listener config
PublicPort}
cmd/canary/main.go full wire-up per spec §6.3 + §11.1:
- Global middleware: RequestID → Logger → Recovery → SecurityHeaders
- /healthz: no further middleware (router-mounted directly)
- /c/{id}, /c/{id}/fingerprint, /k/{id}, /k/{id}/*: mounted at
router root, OUTSIDE /api subrouter, so they skip CORS + rate
limit (spec §11.1 line 1552 — we want to record EVERY hit)
- /api subrouter: CORS → RateLimit (KeyByFingerprint, FailOpen=true)
- /api/tokens/types: no further middleware (read-only public list)
- /api/tokens POST: + TurnstileVerify middleware
- mysql goroutine: spawned via spawnMySQLListener iff
cfg.MySQL.Enabled; uses mysqlTokenLookup + mysqlEventRecorder
adapters bridging mysql.{TokenLookup,EventRecorder} to
token.Service + event.Repository
Adapter types in cmd/canary/main.go:
- registryAdapter: bridges registry.Registry (map type) to
token.Registry interface (Get method)
- directEventRecorder: bridges token.EventRecorder to
event.Repository.Insert + token.Repository.IncrementTriggerCount
(synchronous; Phase 10 swaps in event.Service for dedup + async
notify)
- mysqlTokenLookup, mysqlEventRecorder: same shape for the TCP
mysql handler
webbug refactor (task 9.8):
- Delete local realIP/lastNonEmptyXFF/optionalHeader copies (~30 LOC)
- Use middleware.RealIP and middleware.OptionalHeader instead
- Tests still pass (the IP-precedence suite in webbug_test.go
exercises middleware.RealIP behavior end-to-end via the Trigger
contract)
Pre-rollup gate clean at HEAD:
- go build ./... + go vet ./... clean
- go test -race -timeout=60s ./... all pass
- go test -tags=integration -race -timeout=300s ./internal/token/...
./internal/event/... all pass
- golangci-lint run ./... → 0 issues
- grep -rn "//nolint" --include="*.go" returns nothing
DEFERRED (not in Phase 9 scope):
- Phase 9 task 9.9 integration test (full POST /api/tokens against
testcontainers) — defer to Phase 10's broader integration suite
along with event.Service + notify.Service end-to-end coverage
- Phase 10 brings: event.Service with dedup + async notify, swaps
in for the directEventRecorder/mysqlEventRecorder adapters
- Phase 13 brings: geoip.Service wired into event recording
This commit is contained in:
parent
0a456f97ae
commit
8d1016b87d
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -23,6 +24,9 @@ import (
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
|
"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/server"
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/registry"
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/turnstile"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -73,8 +77,8 @@ func run(configPath string) error {
|
||||||
}
|
}
|
||||||
logger.Info("migrations applied")
|
logger.Info("migrations applied")
|
||||||
|
|
||||||
_ = token.NewRepository(db.DB)
|
tokenRepo := token.NewRepository(db.DB)
|
||||||
_ = event.NewRepository(db.DB)
|
eventRepo := event.NewRepository(db.DB)
|
||||||
|
|
||||||
rdb, err := core.NewRedis(ctx, cfg.Redis)
|
rdb, err := core.NewRedis(ctx, cfg.Redis)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -82,8 +86,37 @@ func run(configPath string) error {
|
||||||
}
|
}
|
||||||
logger.Info("redis connected")
|
logger.Info("redis connected")
|
||||||
|
|
||||||
|
genRegistry := registry.Build(registry.Config{BaseURL: cfg.Canary.BaseURL})
|
||||||
|
tokenSvc := token.NewService(
|
||||||
|
tokenRepo,
|
||||||
|
registryAdapter{r: genRegistry},
|
||||||
|
token.ServiceConfig{
|
||||||
|
BaseURL: cfg.Canary.BaseURL,
|
||||||
|
ManageURL: cfg.Canary.ManageURL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
verifier := turnstile.NewVerifier(
|
||||||
|
turnstile.Config{SecretKey: cfg.Turnstile.SecretKey},
|
||||||
|
rdb.Client,
|
||||||
|
)
|
||||||
|
|
||||||
healthH := health.NewHandler(db, rdb)
|
healthH := health.NewHandler(db, rdb)
|
||||||
srv := mountRouter(cfg, logger, rdb, healthH)
|
tokenH := token.NewHandler(
|
||||||
|
tokenSvc,
|
||||||
|
&directEventRecorder{
|
||||||
|
repo: eventRepo,
|
||||||
|
tokens: tokenRepo,
|
||||||
|
logger: logger,
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
srv := mountRouter(cfg, logger, rdb, healthH, tokenH, verifier)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
spawnMySQLListener(ctx, cfg, logger, &wg, tokenSvc, eventRepo, tokenRepo)
|
||||||
|
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
go func() { errChan <- srv.Start() }()
|
go func() { errChan <- srv.Start() }()
|
||||||
|
|
@ -95,23 +128,38 @@ func run(configPath string) error {
|
||||||
logger.Info("shutdown signal received")
|
logger.Info("shutdown signal received")
|
||||||
}
|
}
|
||||||
|
|
||||||
return gracefulShutdown(cfg, logger, srv, telemetry, rdb, db)
|
shutdownErr := gracefulShutdown(cfg, logger, srv, telemetry, rdb, db)
|
||||||
|
wg.Wait()
|
||||||
|
return shutdownErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func initTelemetry(
|
func spawnMySQLListener(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) *core.Telemetry {
|
wg *sync.WaitGroup,
|
||||||
if !cfg.Otel.Enabled {
|
tokenSvc *token.Service,
|
||||||
return nil
|
eventRepo *event.Repository,
|
||||||
|
tokenRepo *token.Repository,
|
||||||
|
) {
|
||||||
|
if !cfg.MySQL.Enabled {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
t, err := core.NewTelemetry(ctx, cfg.Otel, cfg.App)
|
wg.Add(1)
|
||||||
if err != nil {
|
go func() {
|
||||||
logger.Warn("telemetry init failed", "error", err)
|
defer wg.Done()
|
||||||
return nil
|
handler := mysql.NewHandler(
|
||||||
}
|
&mysqlTokenLookup{svc: tokenSvc},
|
||||||
return t
|
&mysqlEventRecorder{
|
||||||
|
repo: eventRepo,
|
||||||
|
tokens: tokenRepo,
|
||||||
|
logger: logger,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if mErr := mysql.Run(ctx, cfg.MySQL.Addr, handler); mErr != nil {
|
||||||
|
logger.Error("mysql server error", "error", mErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mountRouter(
|
func mountRouter(
|
||||||
|
|
@ -119,6 +167,8 @@ func mountRouter(
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
rdb *core.Redis,
|
rdb *core.Redis,
|
||||||
healthH *health.Handler,
|
healthH *health.Handler,
|
||||||
|
tokenH *token.Handler,
|
||||||
|
verifier *turnstile.Verifier,
|
||||||
) *server.Server {
|
) *server.Server {
|
||||||
srv := server.New(server.Config{
|
srv := server.New(server.Config{
|
||||||
ServerConfig: cfg.Server,
|
ServerConfig: cfg.Server,
|
||||||
|
|
@ -129,20 +179,29 @@ func mountRouter(
|
||||||
|
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.Logger(logger))
|
r.Use(middleware.Logger(logger))
|
||||||
r.Use(
|
r.Use(middleware.Recovery(logger))
|
||||||
middleware.NewRateLimiter(rdb.Client, middleware.RateLimitConfig{
|
|
||||||
Limit: middleware.PerMinute(
|
|
||||||
cfg.RateLimit.Requests,
|
|
||||||
cfg.RateLimit.Burst,
|
|
||||||
),
|
|
||||||
FailOpen: true,
|
|
||||||
}).Handler,
|
|
||||||
)
|
|
||||||
r.Use(middleware.SecurityHeaders(cfg.App.Environment == "production"))
|
r.Use(middleware.SecurityHeaders(cfg.App.Environment == "production"))
|
||||||
r.Use(middleware.CORS(cfg.CORS))
|
|
||||||
|
|
||||||
healthH.RegisterRoutes(r)
|
healthH.RegisterRoutes(r)
|
||||||
r.Route("/api", func(_ chi.Router) {})
|
tokenH.RegisterTriggerRoutes(r)
|
||||||
|
|
||||||
|
r.Route("/api", func(api chi.Router) {
|
||||||
|
api.Use(middleware.CORS(cfg.CORS))
|
||||||
|
api.Use(
|
||||||
|
middleware.NewRateLimiter(rdb.Client, middleware.RateLimitConfig{
|
||||||
|
Limit: middleware.PerMinute(
|
||||||
|
cfg.RateLimit.Requests,
|
||||||
|
cfg.RateLimit.Burst,
|
||||||
|
),
|
||||||
|
KeyFunc: middleware.KeyByFingerprint,
|
||||||
|
FailOpen: true,
|
||||||
|
}).Handler,
|
||||||
|
)
|
||||||
|
api.Get("/tokens/types", tokenH.GetTypes)
|
||||||
|
api.With(middleware.TurnstileVerify(verifier)).
|
||||||
|
Post("/tokens", tokenH.CreateToken)
|
||||||
|
})
|
||||||
|
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +243,22 @@ func gracefulShutdown(
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initTelemetry(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg *config.Config,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *core.Telemetry {
|
||||||
|
if !cfg.Otel.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, err := core.NewTelemetry(ctx, cfg.Otel, cfg.App)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("telemetry init failed", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
func setupLogger(cfg config.LogConfig) *slog.Logger {
|
func setupLogger(cfg config.LogConfig) *slog.Logger {
|
||||||
level := slog.LevelInfo
|
level := slog.LevelInfo
|
||||||
switch cfg.Level {
|
switch cfg.Level {
|
||||||
|
|
@ -204,3 +279,61 @@ func setupLogger(cfg config.LogConfig) *slog.Logger {
|
||||||
}
|
}
|
||||||
return slog.New(handler)
|
return slog.New(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type registryAdapter struct{ r registry.Registry }
|
||||||
|
|
||||||
|
func (a registryAdapter) Get(t token.Type) (token.Generator, bool) {
|
||||||
|
g, ok := a.r[t]
|
||||||
|
return g, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type directEventRecorder struct {
|
||||||
|
repo *event.Repository
|
||||||
|
tokens *token.Repository
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *directEventRecorder) Record(
|
||||||
|
ctx context.Context,
|
||||||
|
t *token.Token,
|
||||||
|
evt *event.Event,
|
||||||
|
) error {
|
||||||
|
if err := d.repo.Insert(ctx, evt); err != nil {
|
||||||
|
return fmt.Errorf("insert event: %w", err)
|
||||||
|
}
|
||||||
|
if err := d.tokens.IncrementTriggerCount(ctx, t.ID); err != nil {
|
||||||
|
d.logger.WarnContext(ctx, "increment trigger count",
|
||||||
|
"error", err, "token_id", t.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mysqlTokenLookup struct{ svc *token.Service }
|
||||||
|
|
||||||
|
func (m *mysqlTokenLookup) GetByID(
|
||||||
|
ctx context.Context,
|
||||||
|
id string,
|
||||||
|
) (*token.Token, error) {
|
||||||
|
return m.svc.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mysqlEventRecorder struct {
|
||||||
|
repo *event.Repository
|
||||||
|
tokens *token.Repository
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mysqlEventRecorder) Record(
|
||||||
|
ctx context.Context,
|
||||||
|
t *token.Token,
|
||||||
|
evt *event.Event,
|
||||||
|
) error {
|
||||||
|
if err := m.repo.Insert(ctx, evt); err != nil {
|
||||||
|
return fmt.Errorf("insert event: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.tokens.IncrementTriggerCount(ctx, t.ID); err != nil {
|
||||||
|
m.logger.WarnContext(ctx, "mysql: increment trigger count",
|
||||||
|
"error", err, "token_id", t.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,26 @@ type Config struct {
|
||||||
CORS CORSConfig `koanf:"cors"`
|
CORS CORSConfig `koanf:"cors"`
|
||||||
Log LogConfig `koanf:"log"`
|
Log LogConfig `koanf:"log"`
|
||||||
Otel OtelConfig `koanf:"otel"`
|
Otel OtelConfig `koanf:"otel"`
|
||||||
|
Canary CanaryConfig `koanf:"canary"`
|
||||||
|
Turnstile TurnstileConfig `koanf:"turnstile"`
|
||||||
|
MySQL MySQLConfig `koanf:"mysql"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CanaryConfig struct {
|
||||||
|
BaseURL string `koanf:"base_url"`
|
||||||
|
ManageURL string `koanf:"manage_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TurnstileConfig struct {
|
||||||
|
SecretKey string `koanf:"secret_key"`
|
||||||
|
SiteKey string `koanf:"site_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MySQLConfig struct {
|
||||||
|
Enabled bool `koanf:"enabled"`
|
||||||
|
Addr string `koanf:"addr"`
|
||||||
|
PublicHost string `koanf:"public_host"`
|
||||||
|
PublicPort int `koanf:"public_port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
|
|
@ -191,6 +211,17 @@ func loadDefaults(k *koanf.Koanf) error {
|
||||||
"otel.insecure": true,
|
"otel.insecure": true,
|
||||||
"otel.sample_rate": 0.1,
|
"otel.sample_rate": 0.1,
|
||||||
"otel.service_name": "canary-token-generator",
|
"otel.service_name": "canary-token-generator",
|
||||||
|
|
||||||
|
"canary.base_url": "http://localhost:8080",
|
||||||
|
"canary.manage_url": "http://localhost:8080",
|
||||||
|
|
||||||
|
"turnstile.secret_key": "",
|
||||||
|
"turnstile.site_key": "",
|
||||||
|
|
||||||
|
"mysql.enabled": false,
|
||||||
|
"mysql.addr": "0.0.0.0:3306",
|
||||||
|
"mysql.public_host": "localhost",
|
||||||
|
"mysql.public_port": 3306,
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value := range defaults {
|
for key, value := range defaults {
|
||||||
|
|
@ -219,6 +250,14 @@ var envKeyMap = map[string]string{
|
||||||
"OTEL_ENABLED": "otel.enabled",
|
"OTEL_ENABLED": "otel.enabled",
|
||||||
"OTEL_INSECURE": "otel.insecure",
|
"OTEL_INSECURE": "otel.insecure",
|
||||||
"OTEL_SAMPLE_RATE": "otel.sample_rate",
|
"OTEL_SAMPLE_RATE": "otel.sample_rate",
|
||||||
|
"CANARY_BASE_URL": "canary.base_url",
|
||||||
|
"CANARY_MANAGE_URL": "canary.manage_url",
|
||||||
|
"TURNSTILE_SECRET_KEY": "turnstile.secret_key",
|
||||||
|
"TURNSTILE_SITE_KEY": "turnstile.site_key",
|
||||||
|
"MYSQL_ENABLED": "mysql.enabled",
|
||||||
|
"MYSQL_ADDR": "mysql.addr",
|
||||||
|
"MYSQL_PUBLIC_HOST": "mysql.public_host",
|
||||||
|
"MYSQL_PUBLIC_PORT": "mysql.public_port",
|
||||||
}
|
}
|
||||||
|
|
||||||
func envKeyReplacer(s string) string {
|
func envKeyReplacer(s string) string {
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,20 @@ package webbug
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware"
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token"
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators"
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/pixel"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
headerCFConnectingIP = "CF-Connecting-IP"
|
headerReferer = "Referer"
|
||||||
headerXForwardedFor = "X-Forwarded-For"
|
headerCacheControl = "Cache-Control"
|
||||||
headerXRealIP = "X-Real-IP"
|
headerPragma = "Pragma"
|
||||||
headerReferer = "Referer"
|
|
||||||
headerCacheControl = "Cache-Control"
|
|
||||||
headerPragma = "Pragma"
|
|
||||||
|
|
||||||
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
|
cacheControlNoStore = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
pragmaNoCache = "no-cache"
|
pragmaNoCache = "no-cache"
|
||||||
|
|
@ -67,46 +64,9 @@ func (g *Generator) Trigger(
|
||||||
|
|
||||||
evt := &event.Event{
|
evt := &event.Event{
|
||||||
TokenID: t.ID,
|
TokenID: t.ID,
|
||||||
SourceIP: realIP(r),
|
SourceIP: middleware.RealIP(r),
|
||||||
UserAgent: optionalHeader(r.UserAgent()),
|
UserAgent: middleware.OptionalHeader(r.UserAgent()),
|
||||||
Referer: optionalHeader(r.Header.Get(headerReferer)),
|
Referer: middleware.OptionalHeader(r.Header.Get(headerReferer)),
|
||||||
}
|
}
|
||||||
return evt, resp, nil
|
return evt, resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func optionalHeader(v string) *string {
|
|
||||||
v = strings.TrimSpace(v)
|
|
||||||
if v == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
func realIP(r *http.Request) string {
|
|
||||||
if v := strings.TrimSpace(r.Header.Get(headerCFConnectingIP)); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
if v := lastNonEmptyXFF(r.Header.Get(headerXForwardedFor)); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
return r.RemoteAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastNonEmptyXFF(header string) string {
|
|
||||||
if header == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parts := strings.Split(header, ",")
|
|
||||||
for i := len(parts) - 1; i >= 0; i-- {
|
|
||||||
if v := strings.TrimSpace(parts[i]); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue