config/config.go
Rama develop · réplica de lectura del repositorio original.
package config
import (
"crypto/rand"
_ "embed"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap/zapcore"
"github.com/mcuadros/go-defaults"
"github.com/spf13/viper"
)
var (
Version = "dev"
Commit = "dev"
BuildDate = "dev"
)
type Config struct {
Name string `mapstructure:"name" yaml:"name"`
Telegram TelegramConfig `mapstructure:"telegram" yaml:"telegram"`
Pushover PushoverConfig `mapstructure:"pushover" yaml:"pushover"`
Database DatabaseConfig `mapstructure:"database" yaml:"database"`
Jwt JwtConfig `mapstructure:"jwt" yaml:"jwt"`
Server ServerConfig `mapstructure:"server" yaml:"server"`
SchedulerJobs SchedulerConfig `mapstructure:"scheduler_jobs" yaml:"scheduler_jobs"`
EmailConfig EmailConfig `mapstructure:"email" yaml:"email"`
StripeConfig StripeConfig `mapstructure:"stripe" yaml:"stripe"`
RevenueCatConfig RevenueCatConfig `mapstructure:"revenuecat" yaml:"revenuecat"`
IAPConfig IAPConfig `mapstructure:"iap" yaml:"iap"`
OAuth2Config OAuth2Config `mapstructure:"oauth2" yaml:"oauth2"`
WebhookConfig WebhookConfig `mapstructure:"webhook" yaml:"webhook"`
RealTimeConfig RealTimeConfig `mapstructure:"realtime" yaml:"realtime"`
MFAConfig MFAConfig `mapstructure:"mfa" yaml:"mfa"`
Logging LogConfig `mapstructure:"logging" yaml:"logging"`
IsDoneTickDotCom bool `mapstructure:"is_done_tick_dot_com" yaml:"is_done_tick_dot_com"`
IsUserCreationDisabled bool `mapstructure:"is_user_creation_disabled" yaml:"is_user_creation_disabled"`
MinVersion string `mapstructure:"min_version" yaml:"min_version"`
DonetickCloudConfig DonetickCloudConfig `mapstructure:"donetick_cloud" yaml:"donetick_cloud"`
FCM FCMConfig `mapstructure:"fcm" yaml:"fcm"`
Bridge BridgeConfig `mapstructure:"bridge" yaml:"bridge"`
FeatureLimits FeatureLimitsConfig `mapstructure:"feature_limits" yaml:"feature_limits"`
Storage StorageConfig `mapstructure:"storage" yaml:"storage"`
SingleCircleInstance bool `mapstructure:"single_circle_instance" yaml:"single_circle_instance"`
DisablePasswordAuth bool `mapstructure:"disable_password_auth" yaml:"disable_password_auth"`
Info Info
}
type Info struct {
Version string
Commit string
BuildDate string
}
type StorageConfig struct {
StorageType string `mapstructure:"storage_type" yaml:"storage_type"`
// CloudStorage:
BucketName string `mapstructure:"bucket_name" yaml:"bucket_name"`
Region string `mapstructure:"region" yaml:"region"`
BasePath string `mapstructure:"base_path" yaml:"base_path"`
AccessKey string `mapstructure:"access_key" yaml:"access_key"`
SecretKey string `mapstructure:"secret_key" yaml:"secret_key"`
Endpoint string `mapstructure:"endpoint" yaml:"endpoint"`
MaxUserStorage int `mapstructure:"max_user_storage" yaml:"max_user_storage"`
MaxFileSize int64 `mapstructure:"max_file_size" yaml:"max_file_size"`
PublicHost string `mapstructure:"public_host" yaml:"public_host"`
// PublicRead returns unsigned URLs instead of SigV4 presigned URLs.
// The bucket/prefix must be configured for public anonymous reads
// (e.g. via a bucket policy). Intended for self-hosted deployments
// that don't want the 7-day presigned URL ceiling.
PublicRead bool `mapstructure:"public_read" yaml:"public_read"`
// PublicBucketName is an optional separate bucket for publicly readable
// assets (e.g. profile photos). If empty, public uploads fall back to the
// private bucket (BucketName).
PublicBucketName string `mapstructure:"public_bucket_name" yaml:"public_bucket_name"`
// PublicBucketHost is the CDN / custom-domain host used to construct bare
// (unsigned) URLs for objects in PublicBucketName. Example: "pub.donetick.com".
// If empty, GetPublicURL falls back to a presigned URL from the private bucket.
PublicBucketHost string `mapstructure:"public_bucket_host" yaml:"public_bucket_host"`
// PathStyle forces path-style S3 addressing (endpoint/bucket/key)
// instead of the default virtual-hosted style (bucket.endpoint/key).
// Required for S3-compatible backends like MinIO that don't expose
// a DNS wildcard at `*.<endpoint-host>`, as well as for plain HTTP
// endpoints without matching TLS wildcard certs.
PathStyle bool `mapstructure:"path_style" yaml:"path_style"`
}
type DonetickCloudConfig struct {
GoogleClientID string `mapstructure:"google_client_id" yaml:"google_client_id"`
GoogleAndroidClientID string `mapstructure:"google_android_client_id" yaml:"google_android_client_id"`
GoogleIOSClientID string `mapstructure:"google_ios_client_id" yaml:"google_ios_client_id"`
AppleClientID string `mapstructure:"apple_client_id" yaml:"apple_client_id"`
}
type FeatureLimitsConfig struct {
MaxCircleMembers int `mapstructure:"max_circle_members" yaml:"max_circle_members" default:"2"`
PlusCircleMaxMembers int `mapstructure:"plus_circle_max_members" yaml:"plus_circle_max_members" default:"6"`
MaxSubaccounts int `mapstructure:"max_subaccounts" yaml:"max_subaccounts" default:"1"`
PlusMaxSubaccounts int `mapstructure:"plus_max_subaccounts" yaml:"plus_max_subaccounts" default:"5"`
}
type TelegramConfig struct {
Token string `mapstructure:"token" yaml:"token"`
}
type PushoverConfig struct {
Token string `mapstructure:"token" yaml:"token"`
}
type DatabaseConfig struct {
Type string `mapstructure:"type" yaml:"type" default:"sqlite"`
Host string `mapstructure:"host" yaml:"host"`
Port int `mapstructure:"port" yaml:"port"`
User string `mapstructure:"user" yaml:"user"`
Password string `mapstructure:"password" yaml:"password"`
Name string `mapstructure:"name" yaml:"name"`
Migration bool `mapstructure:"migration" yaml:"migration" default:"true"`
LogLevel int `mapstructure:"logger" yaml:"logger"`
}
type JwtConfig struct {
Secret string `mapstructure:"secret" yaml:"secret"`
SessionTime time.Duration `mapstructure:"session_time" yaml:"session_time" default:"24h"` // 24 hours
MaxRefresh time.Duration `mapstructure:"max_refresh" yaml:"max_refresh" default:"1440h"` // 60 days
}
type ServerConfig struct {
Port int `mapstructure:"port" yaml:"port" default:"2021"`
RatePeriod time.Duration `mapstructure:"rate_period" yaml:"rate_period" default:"60s"`
RateLimit int `mapstructure:"rate_limit" yaml:"rate_limit" default:"300"`
ReadTimeout time.Duration `mapstructure:"read_timeout" yaml:"read_timeout" default:"5s"`
WriteTimeout time.Duration `mapstructure:"write_timeout" yaml:"write_timeout" default:"5s"`
WebhookTimeout time.Duration `mapstructure:"webhook_timeout" yaml:"webhook_timeout" default:"5s"`
CorsAllowOrigins []string `mapstructure:"cors_allow_origins" yaml:"cors_allow_origins"`
ServeFrontend bool `mapstructure:"serve_frontend" yaml:"serve_frontend"`
ServeSwagger bool `mapstructure:"serve_swagger" yaml:"serve_swagger"`
PublicHost string `mapstructure:"public_host" yaml:"public_host" default:"http://localhost:2021"`
}
type SchedulerConfig struct {
DueJob time.Duration `mapstructure:"due_job" yaml:"due_job"`
OverdueJob time.Duration `mapstructure:"overdue_job" yaml:"overdue_job"`
PreDueJob time.Duration `mapstructure:"pre_due_job" yaml:"pre_due_job"`
}
type StripeConfig struct {
APIKey string `mapstructure:"api_key"`
WhitelistedIPs []string `mapstructure:"whitelisted_ips"`
Prices []StripePrices `mapstructure:"prices"`
SuccessURL string `mapstructure:"success_url"`
CancelURL string `mapstructure:"cancel_url"`
}
type StripePrices struct {
PriceID string `mapstructure:"id"`
Name string `mapstructure:"name"`
}
type RevenueCatConfig struct {
AuthSecret string `mapstructure:"auth_secret" yaml:"auth_secret"`
WhitelistedIPs []string `mapstructure:"whitelisted_ips" yaml:"whitelisted_ips"`
SharedSecret string `mapstructure:"shared_secret" yaml:"shared_secret"`
EnableValidation bool `mapstructure:"enable_validation" yaml:"enable_validation"`
}
type IAPConfig struct {
Apple AppleIAPConfig `mapstructure:"apple" yaml:"apple"`
Google GoogleIAPConfig `mapstructure:"google" yaml:"google"`
}
type AppleIAPConfig struct {
BundleID string `mapstructure:"bundle_id" yaml:"bundle_id"`
SharedSecret string `mapstructure:"shared_secret" yaml:"shared_secret"`
Sandbox bool `mapstructure:"sandbox" yaml:"sandbox"`
KeyID string `mapstructure:"key_id" yaml:"key_id"`
IssuerID string `mapstructure:"issuer_id" yaml:"issuer_id"`
PrivateKey string `mapstructure:"private_key" yaml:"private_key"`
}
type GoogleIAPConfig struct {
PackageName string `mapstructure:"package_name" yaml:"package_name"`
ServiceAccountJSON string `mapstructure:"service_account_json" yaml:"service_account_json"`
}
type FCMConfig struct {
CredentialsPath string `json:"credentials_path" mapstructure:"credentials_path"`
ProjectID string `json:"project_id" mapstructure:"project_id"`
}
// BridgeConfig configures Donetick Bridge, the hosted push-notification
// relay for self-hosted installations. When Enabled is true, device registration
// and notification delivery go through Bridge instead of Core's own direct
// FCM config above. InstanceToken is a secret -- supply it via the
// BRIDGE_INSTANCE_TOKEN environment variable (viper.AutomaticEnv plus the
// "." -> "_" key replacer already configured in LoadConfig map this
// automatically from "bridge.instance_token") rather than committing it to
// a YAML file. It must never be logged or returned by any Core API after
// initial setup.
type BridgeConfig struct {
Enabled bool `json:"enabled" mapstructure:"enabled" yaml:"enabled"`
BaseURL string `json:"base_url" mapstructure:"base_url" yaml:"base_url"`
InstanceID string `json:"instance_id" mapstructure:"instance_id" yaml:"instance_id"`
InstanceToken string `json:"-" mapstructure:"instance_token" yaml:"instance_token"`
TimeoutSeconds int `json:"timeout_seconds" mapstructure:"timeout_seconds" yaml:"timeout_seconds" default:"15"`
}
type EmailConfig struct {
Email string `mapstructure:"email"`
User string `mapstructure:"user"`
Key string `mapstructure:"key"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
// Renamed from "appHost" to match this struct's snake_case convention;
// see the legacy fallback in LoadConfig.
AppHost string `mapstructure:"app_host" yaml:"app_host"`
LogRawURL bool `mapstructure:"log_raw_url" yaml:"log_raw_url"`
// Provider selects which email sender implementation to use.
// "smtp" (default, empty also means smtp) uses host/port/user/key as SMTP creds.
// "smtp2go" uses the SMTP2Go HTTP API with `key` as the API key and the
// template IDs below.
Provider string `mapstructure:"provider" yaml:"provider" default:"smtp"`
VerifyTemplateID string `mapstructure:"verify_template_id" yaml:"verify_template_id"`
ResetPasswordTemplateID string `mapstructure:"reset_password_template_id" yaml:"reset_password_template_id"`
}
type OAuth2Config struct {
ClientID string `mapstructure:"client_id" yaml:"client_id"`
ClientSecret string `mapstructure:"client_secret" yaml:"client_secret"`
RedirectURL string `mapstructure:"redirect_url" yaml:"redirect_url"`
PKCE bool `mapstructure:"pkce" yaml:"pkce"`
Scopes []string `mapstructure:"scopes" yaml:"scopes"`
AuthURL string `mapstructure:"auth_url" yaml:"auth_url"`
TokenURL string `mapstructure:"token_url" yaml:"token_url"`
UserInfoURL string `mapstructure:"user_info_url" yaml:"user_info_url"`
Name string `mapstructure:"name" yaml:"name"`
AdminGroups []string `mapstructure:"admin_groups" yaml:"admin_groups"`
ManagerGroups []string `mapstructure:"manager_groups" yaml:"manager_groups"`
}
type WebhookConfig struct {
Timeout time.Duration `mapstructure:"timeout" yaml:"timeout" default:"5s"`
QueueSize int `mapstructure:"queue_size" yaml:"queue_size" default:"100"`
}
type RealTimeConfig struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled" default:"true"`
WebSocketEnabled bool `mapstructure:"websocket_enabled" yaml:"websocket_enabled" default:"true"`
SSEEnabled bool `mapstructure:"sse_enabled" yaml:"sse_enabled" default:"true"`
HeartbeatInterval time.Duration `mapstructure:"heartbeat_interval" yaml:"heartbeat_interval" default:"30s"`
ConnectionTimeout time.Duration `mapstructure:"connection_timeout" yaml:"connection_timeout" default:"60s"`
MinConnectionInterval time.Duration `mapstructure:"min_connection_interval" yaml:"min_connection_interval" default:"5s"`
MaxConnections int `mapstructure:"max_connections" yaml:"max_connections" default:"1000"`
MaxConnectionsPerUser int `mapstructure:"max_connections_per_user" yaml:"max_connections_per_user" default:"5"`
EventQueueSize int `mapstructure:"event_queue_size" yaml:"event_queue_size" default:"2048"`
CleanupInterval time.Duration `mapstructure:"cleanup_interval" yaml:"cleanup_interval" default:"2m"`
StaleThreshold time.Duration `mapstructure:"stale_threshold" yaml:"stale_threshold" default:"5m"`
EnableCompression bool `mapstructure:"enable_compression" yaml:"enable_compression" default:"true"`
EnableStats bool `mapstructure:"enable_stats" yaml:"enable_stats" default:"true"`
AllowedOrigins []string `mapstructure:"allowed_origins" yaml:"allowed_origins"`
MaxSSETickets int `mapstructure:"max_sse_tickets" yaml:"max_sse_tickets" default:"10000"`
}
type MFAConfig struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled" default:"true"`
SessionTimeoutMinutes int `mapstructure:"session_timeout_minutes" yaml:"session_timeout_minutes" default:"15"`
BackupCodeCount int `mapstructure:"backup_code_count" yaml:"backup_code_count" default:"10"`
MaxVerificationAttempts int `mapstructure:"max_verification_attempts" yaml:"max_verification_attempts" default:"5"`
RateLimitWindow time.Duration `mapstructure:"rate_limit_window" yaml:"rate_limit_window" default:"5m"`
}
type LogConfig struct {
Level string `mapstructure:"level" yaml:"level" default:"info"`
Encoding string `mapstructure:"encoding" yaml:"encoding" default:"console"`
Development bool `mapstructure:"development" yaml:"development" default:"false"`
}
func NewConfig() *Config {
// Generate a secure secret instead of using a weak default
secureSecret, err := generateSecureSecret()
if err != nil {
// If we can't generate a secure secret, panic - this is a security requirement
panic(fmt.Sprintf("Failed to generate secure JWT secret: %v", err))
}
config := &Config{
Telegram: TelegramConfig{
Token: "",
},
Database: DatabaseConfig{
Type: "sqlite",
Migration: true,
},
Jwt: JwtConfig{
Secret: secureSecret,
SessionTime: 24 * time.Hour,
MaxRefresh: 60 * 24 * time.Hour,
},
RealTimeConfig: RealTimeConfig{
Enabled: true,
WebSocketEnabled: false,
SSEEnabled: true,
HeartbeatInterval: 30 * time.Second,
ConnectionTimeout: 60 * time.Second,
MinConnectionInterval: 5 * time.Second,
MaxConnections: 1000,
MaxConnectionsPerUser: 5,
EventQueueSize: 2048,
CleanupInterval: 2 * time.Minute,
StaleThreshold: 5 * time.Minute,
EnableCompression: true,
EnableStats: true,
AllowedOrigins: []string{"*"},
MaxSSETickets: 10000,
},
Logging: LogConfig{
Level: "info",
Encoding: "console",
Development: false,
},
}
// Apply default values for fields with default tags
defaults.SetDefaults(config)
return config
}
func configEnvironmentOverrides(config *Config) {
if os.Getenv("DONETICK_TELEGRAM_TOKEN") != "" {
config.Telegram.Token = os.Getenv("DONETICK_TELEGRAM_TOKEN")
}
if os.Getenv("DONETICK_PUSHOVER_TOKEN") != "" {
config.Pushover.Token = os.Getenv("DONETICK_PUSHOVER_TOKEN")
}
if os.Getenv("DONETICK_DISABLE_SIGNUP") == "true" {
config.IsUserCreationDisabled = true
}
// Logging environment overrides
if os.Getenv("DONETICK_LOG_LEVEL") != "" {
config.Logging.Level = os.Getenv("DONETICK_LOG_LEVEL")
}
if os.Getenv("DONETICK_LOG_ENCODING") != "" {
config.Logging.Encoding = os.Getenv("DONETICK_LOG_ENCODING")
}
if os.Getenv("DONETICK_LOG_DEVELOPMENT") == "true" {
config.Logging.Development = true
}
}
func LoadConfig() *Config {
// https://github.com/spf13/viper/issues/1895#issuecomment-3316091229
viper.SetOptions(viper.ExperimentalBindStruct())
// set the config name based on the environment:
switch env := os.Getenv("DT_ENV"); env {
case "local":
viper.SetConfigName("local")
case "prod":
viper.SetConfigName("prod")
case "selfhosted":
viper.SetConfigName("selfhosted")
case "r2":
viper.SetConfigName("r2")
default:
viper.SetConfigName("local")
}
// get logger and log the current environment:
fmt.Printf("--ConfigLoad config for environment: %s\n", os.Getenv("DT_ENV"))
viper.SetEnvPrefix("DT")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AddConfigPath("./config")
viper.SetConfigType("yaml")
err := viper.ReadInConfig()
// print a useful error:
if err != nil {
var configNotFoundError viper.ConfigFileNotFoundError
if errors.As(err, &configNotFoundError) {
if os.Getenv("DT_ENV") == "selfhosted" {
fmt.Println("Config file not found, generating default config...")
if writeErr := generateDefaultConfigFile("./config"); writeErr != nil {
fmt.Printf("Warning: could not write default config: %v\n", writeErr)
fmt.Println("Continuing with defaults and environment variables")
} else {
// Re-read the newly written config
if readErr := viper.ReadInConfig(); readErr != nil {
fmt.Printf("Warning: could not read generated config: %v\n", readErr)
}
}
} else {
fmt.Println("Config file not found, using defaults and environment variables")
}
} else {
fmt.Printf("Error reading config file: %v", err)
panic(err)
}
}
// Override with environment variables if set:
viper.AutomaticEnv()
var config Config
err = viper.Unmarshal(&config)
if err != nil {
panic(err)
}
// Legacy fallback for YAML configs still using the old "appHost" key.
if config.EmailConfig.AppHost == "" {
if legacy := viper.GetString("email.appHost"); legacy != "" {
config.EmailConfig.AppHost = legacy
}
}
// Apply default values for fields with default tags
defaults.SetDefaults(&config)
fmt.Printf("--ConfigLoad name : %s ", config.Name)
configEnvironmentOverrides(&config)
// Validate JWT secret strength
if config.Name != "local" {
validateJWTSecret(config.Jwt.Secret)
}
config.Info.Version = Version
config.Info.Commit = Commit
config.Info.BuildDate = BuildDate
// set the timezone to UTC if not set:
if os.Getenv("TZ") == "" {
os.Setenv("TZ", "UTC")
}
time.Local, _ = time.LoadLocation(os.Getenv("TZ"))
return &config
// return LocalConfig()
}
// List of known weak secrets that should not be allowed
var weakSecrets = []string{
"secret",
"mysecret",
"jwt_secret",
"default",
"password",
"123456",
"changeme",
"donetick",
"jwt",
"token",
"key",
"secretkey",
"change_this_to_a_secure_random_string_32_characters_long",
}
// validateJWTSecret validates that the JWT secret is strong enough
func validateJWTSecret(secret string) error {
var err error
if len(secret) < 32 {
err = fmt.Errorf("JWT secret must be at least 32 characters long, got %d characters", len(secret))
}
// Check against known weak secrets (case-insensitive)
secretLower := strings.ToLower(secret)
for _, weak := range weakSecrets {
if secretLower == weak {
err = fmt.Errorf("JWT secret is too weak: '%s' is not allowed", weak)
break
}
}
if err != nil {
fmt.Printf("\n\n🚨 SECURITY ERROR: %s\n", err.Error())
fmt.Printf("\n💡 To fix this issue:\n")
fmt.Printf(" 1. Generate a secure JWT secret:\n")
if secureSecret, genErr := generateSecureSecret(); genErr == nil {
fmt.Printf(" Suggested secret: %s\n", secureSecret)
}
fmt.Printf(" 2. Set it in your configuration:\n")
fmt.Printf(" - YAML: jwt.secret: \"<your_secure_secret>\"\n")
fmt.Printf(" - ENV: export DT_JWT_SECRET=\"<your_secure_secret>\"\n")
fmt.Printf("\n❌ Application will not start with weak JWT secrets for security reasons.\n\n")
panic("Weak JWT secret detected - application startup aborted for security")
}
return nil
}
// selfhostedConfigTemplate is written as selfhosted.yaml on first startup,
// with the placeholder JWT secret replaced by a generated one.
//
//go:embed selfhosted.yaml.example
var selfhostedConfigTemplate string
const placeholderJWTSecret = "change_this_to_a_secure_random_string_32_characters_long"
// generateDefaultConfigFile writes a default selfhosted.yaml to the given directory.
// This persists the generated JWT secret so it survives restarts.
func generateDefaultConfigFile(configDir string) error {
secret, err := generateSecureSecret()
if err != nil {
return fmt.Errorf("generate JWT secret: %w", err)
}
if !strings.Contains(selfhostedConfigTemplate, placeholderJWTSecret) {
return errors.New("config template is missing the placeholder JWT secret")
}
configContent := strings.Replace(selfhostedConfigTemplate, placeholderJWTSecret, secret, 1)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
configPath := filepath.Join(configDir, "selfhosted.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
return fmt.Errorf("write config file: %w", err)
}
fmt.Printf("Generated default config at %s\n", configPath)
return nil
}
// generateSecureSecret generates a cryptographically secure random secret
func generateSecureSecret() (string, error) {
bytes := make([]byte, 32) // 256 bits
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
// ParseLogLevel converts a string log level to zapcore.Level
func (c *LogConfig) ParseLogLevel() zapcore.Level {
switch strings.ToLower(c.Level) {
case "debug":
return zapcore.DebugLevel
case "info":
return zapcore.InfoLevel
case "warn", "warning":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
case "dpanic":
return zapcore.DPanicLevel
case "panic":
return zapcore.PanicLevel
case "fatal":
return zapcore.FatalLevel
default:
return zapcore.InfoLevel
}
}