From 50105e4e9d7844495f8a7ec4643ef1c116f0f39b Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 19 Sep 2025 14:44:22 +0300 Subject: [PATCH] feat: version info analytics (#363) * feat: version info analytics * refactor: don't create new client everytime --- cmd/root.go | 1 + internal/bootstrap/app_bootstrap.go | 129 +++++++++++++++++++------- internal/config/config.go | 5 + internal/utils/security_utils.go | 5 +- internal/utils/security_utils_test.go | 11 +-- 5 files changed, 109 insertions(+), 42 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index aeb96a5..c81a52a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -94,6 +94,7 @@ func init() { {"resources-dir", "/data/resources", "Path to a directory containing custom resources (e.g. background image)."}, {"database-path", "/data/tinyauth.db", "Path to the Sqlite database file."}, {"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses or CIDRs) for correct client IP detection."}, + {"disable-analytics", false, "Disable anonymous version collection."}, } for _, opt := range configOptions { diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index 18364da..d1f3373 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -1,10 +1,14 @@ package bootstrap import ( + "bytes" + "encoding/json" "fmt" + "net/http" "net/url" "os" "strings" + "time" "tinyauth/internal/config" "tinyauth/internal/controller" "tinyauth/internal/middleware" @@ -29,40 +33,43 @@ type Service interface { } type BootstrapApp struct { - Config config.Config + config config.Config + uuid string } func NewBootstrapApp(config config.Config) *BootstrapApp { return &BootstrapApp{ - Config: config, + config: config, } } func (app *BootstrapApp) Setup() error { // Parse users - users, err := utils.GetUsers(app.Config.Users, app.Config.UsersFile) + users, err := utils.GetUsers(app.config.Users, app.config.UsersFile) if err != nil { return err } // Get OAuth configs - oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.Config.AppURL) + oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.config.AppURL) if err != nil { return err } // Get cookie domain - cookieDomain, err := utils.GetCookieDomain(app.Config.AppURL) + cookieDomain, err := utils.GetCookieDomain(app.config.AppURL) if err != nil { return err } // Cookie names - appUrl, _ := url.Parse(app.Config.AppURL) // Already validated - cookieId := utils.GenerateIdentifier(appUrl.Hostname()) + appUrl, _ := url.Parse(app.config.AppURL) // Already validated + uuid := utils.GenerateUUID(appUrl.Hostname()) + app.uuid = uuid + cookieId := strings.Split(uuid, "-")[0] sessionCookieName := fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId) csrfCookieName := fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId) redirectCookieName := fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId) @@ -70,26 +77,26 @@ func (app *BootstrapApp) Setup() error { // Create configs authConfig := service.AuthServiceConfig{ Users: users, - OauthWhitelist: app.Config.OAuthWhitelist, - SessionExpiry: app.Config.SessionExpiry, - SecureCookie: app.Config.SecureCookie, + OauthWhitelist: app.config.OAuthWhitelist, + SessionExpiry: app.config.SessionExpiry, + SecureCookie: app.config.SecureCookie, CookieDomain: cookieDomain, - LoginTimeout: app.Config.LoginTimeout, - LoginMaxRetries: app.Config.LoginMaxRetries, + LoginTimeout: app.config.LoginTimeout, + LoginMaxRetries: app.config.LoginMaxRetries, SessionCookieName: sessionCookieName, } // Setup services var ldapService *service.LdapService - if app.Config.LdapAddress != "" { + if app.config.LdapAddress != "" { ldapConfig := service.LdapServiceConfig{ - Address: app.Config.LdapAddress, - BindDN: app.Config.LdapBindDN, - BindPassword: app.Config.LdapBindPassword, - BaseDN: app.Config.LdapBaseDN, - Insecure: app.Config.LdapInsecure, - SearchFilter: app.Config.LdapSearchFilter, + Address: app.config.LdapAddress, + BindDN: app.config.LdapBindDN, + BindPassword: app.config.LdapBindPassword, + BaseDN: app.config.LdapBaseDN, + Insecure: app.config.LdapInsecure, + SearchFilter: app.config.LdapSearchFilter, } ldapService = service.NewLdapService(ldapConfig) @@ -104,7 +111,7 @@ func (app *BootstrapApp) Setup() error { // Bootstrap database databaseService := service.NewDatabaseService(service.DatabaseServiceConfig{ - DatabasePath: app.Config.DatabasePath, + DatabasePath: app.config.DatabasePath, }) log.Debug().Str("service", fmt.Sprintf("%T", databaseService)).Msg("Initializing service") @@ -183,8 +190,8 @@ func (app *BootstrapApp) Setup() error { // Create engine engine := gin.New() - if len(app.Config.TrustedProxies) > 0 { - engine.SetTrustedProxies(strings.Split(app.Config.TrustedProxies, ",")) + if len(app.config.TrustedProxies) > 0 { + engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ",")) } if config.Version != "development" { @@ -219,24 +226,24 @@ func (app *BootstrapApp) Setup() error { // Create controllers contextController := controller.NewContextController(controller.ContextControllerConfig{ Providers: configuredProviders, - Title: app.Config.Title, - AppURL: app.Config.AppURL, + Title: app.config.Title, + AppURL: app.config.AppURL, CookieDomain: cookieDomain, - ForgotPasswordMessage: app.Config.ForgotPasswordMessage, - BackgroundImage: app.Config.BackgroundImage, - OAuthAutoRedirect: app.Config.OAuthAutoRedirect, + ForgotPasswordMessage: app.config.ForgotPasswordMessage, + BackgroundImage: app.config.BackgroundImage, + OAuthAutoRedirect: app.config.OAuthAutoRedirect, }, apiRouter) oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{ - AppURL: app.Config.AppURL, - SecureCookie: app.Config.SecureCookie, + AppURL: app.config.AppURL, + SecureCookie: app.config.SecureCookie, CSRFCookieName: csrfCookieName, RedirectCookieName: redirectCookieName, CookieDomain: cookieDomain, }, apiRouter, authService, oauthBrokerService) proxyController := controller.NewProxyController(controller.ProxyControllerConfig{ - AppURL: app.Config.AppURL, + AppURL: app.config.AppURL, }, apiRouter, dockerService, authService) userController := controller.NewUserController(controller.UserControllerConfig{ @@ -244,7 +251,7 @@ func (app *BootstrapApp) Setup() error { }, apiRouter, authService) resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{ - ResourcesDir: app.Config.ResourcesDir, + ResourcesDir: app.config.ResourcesDir, }, mainRouter) healthController := controller.NewHealthController(apiRouter) @@ -264,8 +271,14 @@ func (app *BootstrapApp) Setup() error { ctrl.SetupRoutes() } + // If analytics are not disabled, start heartbeat + if !app.config.DisableAnalytics { + log.Debug().Msg("Starting heartbeat routine") + go app.heartbeat() + } + // Start server - address := fmt.Sprintf("%s:%d", app.Config.Address, app.Config.Port) + address := fmt.Sprintf("%s:%d", app.config.Address, app.config.Port) log.Info().Msgf("Starting server on %s", address) if err := engine.Run(address); err != nil { log.Fatal().Err(err).Msg("Failed to start server") @@ -273,3 +286,55 @@ func (app *BootstrapApp) Setup() error { return nil } + +func (app *BootstrapApp) heartbeat() { + ticker := time.NewTicker(time.Duration(12) * time.Hour) + defer ticker.Stop() + + type heartbeat struct { + UUID string `json:"uuid"` + Version string `json:"version"` + } + + var body heartbeat + + body.UUID = app.uuid + body.Version = config.Version + + bodyJson, err := json.Marshal(body) + + if err != nil { + log.Error().Err(err).Msg("Failed to marshal heartbeat body") + return + } + + client := &http.Client{} + + heartbeatURL := config.ApiServer + "/v1/instances/heartbeat" + + for ; true; <-ticker.C { + log.Debug().Msg("Sending heartbeat") + + req, err := http.NewRequest(http.MethodPost, heartbeatURL, bytes.NewReader(bodyJson)) + + if err != nil { + log.Error().Err(err).Msg("Failed to create heartbeat request") + continue + } + + req.Header.Add("Content-Type", "application/json") + + res, err := client.Do(req) + + if err != nil { + log.Error().Err(err).Msg("Failed to send heartbeat") + continue + } + + res.Body.Close() + + if res.StatusCode != 200 { + log.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200 status") + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4fc66fc..e969ad0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,7 @@ type Config struct { ResourcesDir string `mapstructure:"resources-dir"` DatabasePath string `mapstructure:"database-path" validate:"required"` TrustedProxies string `mapstructure:"trusted-proxies"` + DisableAnalytics bool `mapstructure:"disable-analytics"` } // OAuth/OIDC config @@ -169,3 +170,7 @@ type AppPath struct { type Providers struct { Providers map[string]OAuthServiceConfig } + +// API server + +var ApiServer = "https://api.tinyauth.app" diff --git a/internal/utils/security_utils.go b/internal/utils/security_utils.go index 91e17ee..40fe713 100644 --- a/internal/utils/security_utils.go +++ b/internal/utils/security_utils.go @@ -101,8 +101,7 @@ func CheckFilter(filter string, str string) bool { return false } -func GenerateIdentifier(str string) string { +func GenerateUUID(str string) string { uuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str)) - uuidString := uuid.String() - return strings.Split(uuidString, "-")[0] + return uuid.String() } diff --git a/internal/utils/security_utils_test.go b/internal/utils/security_utils_test.go index 941f853..9adcd7c 100644 --- a/internal/utils/security_utils_test.go +++ b/internal/utils/security_utils_test.go @@ -136,16 +136,13 @@ func TestCheckFilter(t *testing.T) { assert.Equal(t, false, utils.CheckFilter("apple, banana, cherry", "grape")) } -func TestGenerateIdentifier(t *testing.T) { +func TestGenerateUUID(t *testing.T) { // Consistent output for same input - id1 := utils.GenerateIdentifier("teststring") - id2 := utils.GenerateIdentifier("teststring") + id1 := utils.GenerateUUID("teststring") + id2 := utils.GenerateUUID("teststring") assert.Equal(t, id1, id2) // Different output for different input - id3 := utils.GenerateIdentifier("differentstring") + id3 := utils.GenerateUUID("differentstring") assert.Assert(t, id1 != id3) - - // Check length (should be 8 characters from first segment of UUID) - assert.Equal(t, 8, len(id1)) }