diff --git a/cmd/certificate/main.go b/cmd/certificate/main.go new file mode 100644 index 0000000..383a1cc --- /dev/null +++ b/cmd/certificate/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "log" + "math/big" + "net" + "os" + "time" +) + +func main() { + certOut := flag.String("cert", "cert.pem", "path to output certificate file") + keyOut := flag.String("key", "key.pem", "path to output private key file") + flag.Parse() + + // создаём шаблон сертификата + cert := &x509.Certificate{ + // указываем уникальный номер сертификата + SerialNumber: big.NewInt(1658), + // заполняем базовую информацию о владельце сертификата + Subject: pkix.Name{ + Organization: []string{"Yandex.Praktikum"}, + Country: []string{"RU"}, + }, + // разрешаем использование сертификата для 127.0.0.1 и ::1 + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + // сертификат верен, начиная со времени создания + NotBefore: time.Now(), + // время жизни сертификата — 10 лет + NotAfter: time.Now().AddDate(10, 0, 0), + SubjectKeyId: []byte{1, 2, 3, 4, 6}, + // устанавливаем использование ключа для цифровой подписи, + // а также клиентской и серверной авторизации + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } + + // создаём новый приватный RSA-ключ длиной 4096 бит + // обратите внимание, что для генерации ключа и сертификата + // используется rand.Reader в качестве источника случайных данных + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + log.Fatalf("failed to generate RSA private key: %v", err) + } + + // создаём сертификат x.509 + certBytes, err := x509.CreateCertificate(rand.Reader, cert, cert, &privateKey.PublicKey, privateKey) + if err != nil { + log.Fatalf("failed to create x509 certificate: %v", err) + } + + // кодируем сертификат и ключ в формате PEM, который + // используется для хранения и обмена криптографическими ключами + var certPEM bytes.Buffer + err = pem.Encode(&certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + if err != nil { + log.Fatalf("failed to PEM-encode certificate: %v", err) + } + + var privateKeyPEM bytes.Buffer + err = pem.Encode(&privateKeyPEM, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + if err != nil { + log.Fatalf("failed to PEM-encode RSA private key: %v", err) + } + + // Сохраняем сертификат и приватный ключ в файлы + if err = os.WriteFile(*certOut, certPEM.Bytes(), 0644); err != nil { + log.Fatalf("failed to write certificate to %s: %v", *certOut, err) + } + + if err = os.WriteFile(*keyOut, privateKeyPEM.Bytes(), 0644); err != nil { + log.Fatalf("failed to write private key to %s: %v", *keyOut, err) + } +} diff --git a/cmd/shortener/main.go b/cmd/shortener/main.go index 0cfccc6..205584e 100644 --- a/cmd/shortener/main.go +++ b/cmd/shortener/main.go @@ -2,9 +2,10 @@ package main import ( "context" + "errors" "fmt" "log" - "os" + "net/http" "os/signal" "syscall" "time" @@ -29,34 +30,38 @@ func main() { fmt.Printf("Build version: %s\nBuild date: %s\nBuild commit: %s\n", checkLinkVar(buildVersion), checkLinkVar(buildDate), checkLinkVar(buildCommit)) application, err := app.New() if err != nil { - log.Fatalf("Error initializing application.\n%s", err.Error()) + log.Fatalf("failed to initialize application (check config flags, DB connection, file permissions): %s", err.Error()) } application.SetupRoutes() go func() { if err := application.Run(); err != nil { - if err.Error() != "http: Server closed" { - log.Printf("Error running application: %s", err.Error()) + if !errors.Is(err, http.ErrServerClosed) { + log.Printf("HTTP server exited with unexpected error: %s", err.Error()) } } }() - out := make(chan os.Signal, 1) - signal.Notify(out, syscall.SIGINT, syscall.SIGTERM) - <-out + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + defer stop() + + <-ctx.Done() + stop() // освобождаем ресурсы сигнального механизма досрочно + log.Print("Shutdown process is started...") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + + shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() shut := make(chan struct{}) go func() { - application.Shutdown(ctx) + application.Shutdown(shutCtx) close(shut) }() select { case <-shut: log.Print("Shutdown process is completed!") - case <-ctx.Done(): + case <-shutCtx.Done(): log.Print("Shutdown process timeout - exiting anyway") } } diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..8017685 --- /dev/null +++ b/config.example.json @@ -0,0 +1,15 @@ +{ + "server_address": ":8080", + "base_url": "http://localhost:8080", + "env": "dev", + "file_storage_path": "data.json", + "database_dsn": "", + "delete_worker_count": 2, + "delete_channel_size": 1000, + "secret_key": "my-super-secret-key-change-in-production", + "audit_file": "", + "audit_url": "", + "enable_https": false, + "cert_file": "cert.pem", + "key_file": "key.pem" +} diff --git a/go.mod b/go.mod index 9a70dca..513a8a4 100644 --- a/go.mod +++ b/go.mod @@ -20,11 +20,13 @@ require ( github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect @@ -42,11 +44,19 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect diff --git a/go.sum b/go.sum index a65942b..9b86bfa 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -29,6 +31,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -93,8 +97,20 @@ github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI1 github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -107,6 +123,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= @@ -126,6 +144,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/app/app.go b/internal/app/app.go index d6df4e5..ca16d4a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -30,6 +30,9 @@ type App struct { router *gin.Engine urlHandler *handler.URLHandler deleteWorker *worker.DeleteWorker + enableTLS bool + certFile string + keyFile string } // New создаёт и настраивает новое приложение: @@ -152,6 +155,9 @@ func New() (*App, error) { router: router, urlHandler: urlHandler, deleteWorker: deleteWorker, + enableTLS: cfg.Enable_HTTPS, + certFile: cfg.CertFile, + keyFile: cfg.KeyFile, }, nil } @@ -201,6 +207,9 @@ func (a *App) SetupRoutes() { // Run запускает HTTP-сервер. func (a *App) Run() error { + if a.enableTLS { + return a.server.ListenAndServeTLS(a.certFile, a.keyFile) + } return a.server.ListenAndServe() } @@ -210,6 +219,6 @@ func (a *App) Shutdown(ctx context.Context) { a.deleteWorker.Stop() } if err := a.server.Shutdown(ctx); err != nil { - fmt.Println("Failed shutdown. error: ", err) + fmt.Printf("failed to shutdown HTTP server gracefully: %v\n", err) } } diff --git a/internal/audit/file.go b/internal/audit/file.go index 58e7ab9..8672ac9 100644 --- a/internal/audit/file.go +++ b/internal/audit/file.go @@ -22,7 +22,7 @@ func NewFileObserver(path string) *FileObserver { func (f *FileObserver) Notify(event AuditEvent) { data, err := json.Marshal(event) if err != nil { - log.Printf("audit file: failed to marshal event: %v", err) + log.Printf("audit file: failed to marshal %q event: %v", event.Action, err) return } data = append(data, '\n') @@ -38,6 +38,6 @@ func (f *FileObserver) Notify(event AuditEvent) { defer func() { _ = file.Close() }() if _, err := file.Write(data); err != nil { - log.Printf("audit file: failed to write event: %v", err) + log.Printf("audit file: failed to write %q event to %s: %v", event.Action, f.path, err) } } diff --git a/internal/audit/http.go b/internal/audit/http.go index dc0b7c1..7f29913 100644 --- a/internal/audit/http.go +++ b/internal/audit/http.go @@ -28,13 +28,13 @@ func NewHTTPObserver(url string) *HTTPObserver { func (h *HTTPObserver) Notify(event AuditEvent) { data, err := json.Marshal(event) if err != nil { - log.Printf("audit http: failed to marshal event: %v", err) + log.Printf("audit http: failed to marshal %q event: %v", event.Action, err) return } resp, err := h.client.Post(h.url, "application/json", bytes.NewReader(data)) if err != nil { - log.Printf("audit http: failed to send event to %s: %v", h.url, err) + log.Printf("audit http: failed to send %q event to %s: %v", event.Action, h.url, err) return } defer func() { _ = resp.Body.Close() }() diff --git a/internal/config/flag/config.go b/internal/config/flag/config.go index 99101bf..5604066 100644 --- a/internal/config/flag/config.go +++ b/internal/config/flag/config.go @@ -1,12 +1,15 @@ -// Package config обеспечивает загрузку конфигурации из флагов командной строки -// и переменных окружения. Переменные окружения имеют приоритет. +// Package config обеспечивает загрузку конфигурации с помощью Viper. +// Источники читаются в следующем приоритете (от высшего к низшему): +// явно переданный флаг > переменная окружения > JSON-файл > значение по умолчанию. package config import ( - "flag" "os" "strconv" "sync" + + "github.com/spf13/pflag" + "github.com/spf13/viper" ) // ServerConfig — конфигурация сервера. @@ -21,116 +24,144 @@ type ServerConfig struct { SecretKey string `env:"SECRET_KEY"` AuditFile string `env:"AUDIT_FILE"` AuditURL string `env:"AUDIT_URL"` + Enable_HTTPS bool `env:"ENABLE_HTTPS"` + CertFile string `env:"CERT_FILE"` + KeyFile string `env:"KEY_FILE"` + ConfigJSON string `env:"CONFIG"` +} + +// JSONFileConfig — структура JSON-файла конфигурации. +type JSONFileConfig struct { + ServerAddress string `json:"server_address"` + BaseURL string `json:"base_url"` + FileStoragePath string `json:"file_storage_path"` + DatabaseDSN string `json:"database_dsn"` + EnableHTTPS *bool `json:"enable_https"` } var ( - addrServer *string - addrURL *string - envValue *string - fileValue *string - dsnValue *string - deleteWorkerCount *int - deleteChannelSize *int - secretKey *string - auditFile *string - auditURL *string - flagsOnce sync.Once + fs *pflag.FlagSet + flagsOnce sync.Once ) func initFlags() { - addrServer = flag.String("a", ":8080", "address to listen on") - addrURL = flag.String("b", "http://localhost:8080", "base URL for short URLs") - envValue = flag.String("e", "dev", "environment") - fileValue = flag.String("f", "data.json", "file to store data") - dsnValue = flag.String("d", "", "database DSN") - deleteWorkerCount = flag.Int("w", 2, "number of delete worker channels") - deleteChannelSize = flag.Int("c", 1000, "size of each delete channel buffer") - secretKey = flag.String("s", "my-super-secret-key-change-in-production", "secret key for signing cookies") - auditFile = flag.String("audit-file", "", "path to audit log file") - auditURL = flag.String("audit-url", "", "URL of remote audit server") + fs = pflag.NewFlagSet("shortener", pflag.ContinueOnError) + fs.String("a", ":8080", "address to listen on") + fs.String("b", "http://localhost:8080", "base URL for short URLs") + fs.String("e", "dev", "environment") + fs.String("f", "data.json", "file to store data") + fs.String("d", "", "database DSN") + fs.Int("wc", 2, "number of delete worker channels") + fs.Int("cs", 1000, "size of each delete channel buffer") + fs.String("sk", "my-super-secret-key-change-in-production", "secret key for signing cookies") + fs.String("audit-file", "", "path to audit log file") + fs.String("audit-url", "", "URL of remote audit server") + fs.Bool("s", false, "enable HTTPS") + fs.String("cert", "cert.pem", "path to TLS certificate file") + fs.String("key", "key.pem", "path to TLS private key file") + fs.StringP("config", "c", "", "path to JSON config file") + // Ошибки разбора флагов (например, неизвестные флаги go test) намеренно игнорируются. + _ = fs.Parse(os.Args[1:]) } -// NewServerConfig создаёт конфигурацию, читая флаги и переменные окружения. -func NewServerConfig() *ServerConfig { - flagsOnce.Do(initFlags) - - if !flag.Parsed() { - flag.Parse() +// loadJSONConfig загружает конфигурацию из JSON-файла. +func loadJSONConfig(path string) (*JSONFileConfig, error) { + v := viper.New() + v.SetConfigFile(path) + if err := v.ReadInConfig(); err != nil { + return nil, err } - - cfg := &ServerConfig{} - - if envAddr, ok := os.LookupEnv("SERVER_ADDRESS"); ok { - cfg.AddrServer = envAddr - } else { - cfg.AddrServer = *addrServer - } - - if envURL, ok := os.LookupEnv("BASE_URL"); ok { - cfg.AddrURL = envURL - } else { - cfg.AddrURL = *addrURL + cfg := &JSONFileConfig{ + ServerAddress: v.GetString("server_address"), + BaseURL: v.GetString("base_url"), + FileStoragePath: v.GetString("file_storage_path"), + DatabaseDSN: v.GetString("database_dsn"), } - - if envEnv, ok := os.LookupEnv("ENV"); ok { - cfg.Env = envEnv - } else { - cfg.Env = *envValue - } - - if envFile, ok := os.LookupEnv("FILE_STORAGE_PATH"); ok { - cfg.File = envFile - } else { - cfg.File = *fileValue - } - - if envDSN, ok := os.LookupEnv("DATABASE_DSN"); ok { - cfg.DSN = envDSN - } else { - cfg.DSN = *dsnValue - } - - if envWorkerCount, ok := os.LookupEnv("DELETE_WORKER_COUNT"); ok { - if count, err := parseIntFromEnv(envWorkerCount); err == nil { - cfg.DeleteWorkerCount = count - } else { - cfg.DeleteWorkerCount = *deleteWorkerCount - } - } else { - cfg.DeleteWorkerCount = *deleteWorkerCount + if v.IsSet("enable_https") { + b := v.GetBool("enable_https") + cfg.EnableHTTPS = &b } + return cfg, nil +} - if envChannelSize, ok := os.LookupEnv("DELETE_CHANNEL_SIZE"); ok { - if size, err := parseIntFromEnv(envChannelSize); err == nil { - cfg.DeleteChannelSize = size - } else { - cfg.DeleteChannelSize = *deleteChannelSize - } - } else { - cfg.DeleteChannelSize = *deleteChannelSize - } +// NewServerConfig создаёт конфигурацию с помощью Viper. +// Приоритет: явно переданный флаг > переменная окружения > JSON-конфиг > значение по умолчанию. +func NewServerConfig() *ServerConfig { + flagsOnce.Do(initFlags) - if envSecretKey, ok := os.LookupEnv("SECRET_KEY"); ok { - cfg.SecretKey = envSecretKey - } else { - cfg.SecretKey = *secretKey + v := viper.New() + + // Значения по умолчанию + v.SetDefault("server_address", ":8080") + v.SetDefault("base_url", "http://localhost:8080") + v.SetDefault("env", "dev") + v.SetDefault("file_storage_path", "data.json") + v.SetDefault("database_dsn", "") + v.SetDefault("delete_worker_count", 2) + v.SetDefault("delete_channel_size", 1000) + v.SetDefault("secret_key", "my-super-secret-key-change-in-production") + v.SetDefault("audit_file", "") + v.SetDefault("audit_url", "") + v.SetDefault("enable_https", false) + v.SetDefault("cert_file", "cert.pem") + v.SetDefault("key_file", "key.pem") + + // Привязка флагов командной строки к ключам Viper + _ = v.BindPFlag("server_address", fs.Lookup("a")) + _ = v.BindPFlag("base_url", fs.Lookup("b")) + _ = v.BindPFlag("env", fs.Lookup("e")) + _ = v.BindPFlag("file_storage_path", fs.Lookup("f")) + _ = v.BindPFlag("database_dsn", fs.Lookup("d")) + _ = v.BindPFlag("delete_worker_count", fs.Lookup("wc")) + _ = v.BindPFlag("delete_channel_size", fs.Lookup("cs")) + _ = v.BindPFlag("secret_key", fs.Lookup("sk")) + _ = v.BindPFlag("audit_file", fs.Lookup("audit-file")) + _ = v.BindPFlag("audit_url", fs.Lookup("audit-url")) + _ = v.BindPFlag("enable_https", fs.Lookup("s")) + _ = v.BindPFlag("cert_file", fs.Lookup("cert")) + _ = v.BindPFlag("key_file", fs.Lookup("key")) + _ = v.BindPFlag("config", fs.Lookup("config")) + + // Переменные окружения: ключ автоматически преобразуется в верхний регистр + // (server_address → SERVER_ADDRESS, base_url → BASE_URL и т.д.) + v.AutomaticEnv() + + // Загрузка JSON-конфига, если путь задан через флаг --config/-c или переменную CONFIG + configPath := v.GetString("config") + if configPath != "" { + v.SetConfigFile(configPath) + _ = v.ReadInConfig() } - if envAuditFile, ok := os.LookupEnv("AUDIT_FILE"); ok { - cfg.AuditFile = envAuditFile - } else { - cfg.AuditFile = *auditFile + return &ServerConfig{ + AddrServer: v.GetString("server_address"), + AddrURL: v.GetString("base_url"), + Env: v.GetString("env"), + File: v.GetString("file_storage_path"), + DSN: v.GetString("database_dsn"), + DeleteWorkerCount: getIntWithFallback(v, "delete_worker_count", 2), + DeleteChannelSize: getIntWithFallback(v, "delete_channel_size", 1000), + SecretKey: v.GetString("secret_key"), + AuditFile: v.GetString("audit_file"), + AuditURL: v.GetString("audit_url"), + Enable_HTTPS: v.GetBool("enable_https"), + CertFile: v.GetString("cert_file"), + KeyFile: v.GetString("key_file"), + ConfigJSON: configPath, } +} - if envAuditURL, ok := os.LookupEnv("AUDIT_URL"); ok { - cfg.AuditURL = envAuditURL - } else { - cfg.AuditURL = *auditURL +// getIntWithFallback возвращает целочисленное значение ключа из Viper. +// Если строковое представление нельзя преобразовать в int (например, невалидная строка в env), +// возвращает fallback вместо нуля. +func getIntWithFallback(v *viper.Viper, key string, fallback int) int { + if n, err := strconv.Atoi(v.GetString(key)); err == nil { + return n } - - return cfg + return fallback } +// parseIntFromEnv преобразует строку переменной окружения в int. func parseIntFromEnv(s string) (int, error) { return strconv.Atoi(s) } diff --git a/internal/config/flag/config_test.go b/internal/config/flag/config_test.go index 7e01afc..638159b 100644 --- a/internal/config/flag/config_test.go +++ b/internal/config/flag/config_test.go @@ -1,6 +1,8 @@ package config import ( + "os" + "path/filepath" "testing" ) @@ -104,3 +106,151 @@ func TestNewServerConfig(t *testing.T) { }) } } + +func TestLoadJSONConfig(t *testing.T) { + t.Run("valid JSON file", func(t *testing.T) { + content := `{"server_address":"localhost:9090","base_url":"http://example.com","file_storage_path":"/tmp/test.db","database_dsn":"postgres://test","enable_https":true}` + tmpFile := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := loadJSONConfig(tmpFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.ServerAddress != "localhost:9090" { + t.Errorf("ServerAddress = %v, want localhost:9090", cfg.ServerAddress) + } + if cfg.BaseURL != "http://example.com" { + t.Errorf("BaseURL = %v, want http://example.com", cfg.BaseURL) + } + if cfg.FileStoragePath != "/tmp/test.db" { + t.Errorf("FileStoragePath = %v, want /tmp/test.db", cfg.FileStoragePath) + } + if cfg.DatabaseDSN != "postgres://test" { + t.Errorf("DatabaseDSN = %v, want postgres://test", cfg.DatabaseDSN) + } + if cfg.EnableHTTPS == nil || !*cfg.EnableHTTPS { + t.Errorf("EnableHTTPS = %v, want true", cfg.EnableHTTPS) + } + }) + + t.Run("file not found", func(t *testing.T) { + _, err := loadJSONConfig("/nonexistent/config.json") + if err == nil { + t.Error("expected error for missing file") + } + }) + + t.Run("invalid JSON", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.json") + if err := os.WriteFile(tmpFile, []byte("{invalid"), 0644); err != nil { + t.Fatal(err) + } + _, err := loadJSONConfig(tmpFile) + if err == nil { + t.Error("expected error for invalid JSON") + } + }) +} + +func TestParseIntFromEnv(t *testing.T) { + if v, err := parseIntFromEnv("42"); err != nil || v != 42 { + t.Errorf("parseIntFromEnv(\"42\") = %v, %v; want 42, nil", v, err) + } + if _, err := parseIntFromEnv("abc"); err == nil { + t.Error("expected error for non-numeric string") + } +} + +func TestNewServerConfigWithJSONFile(t *testing.T) { + content := `{"server_address":"json-host:3000","base_url":"http://json-url","file_storage_path":"/json/path.db","database_dsn":"json-dsn","enable_https":true}` + tmpFile := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + t.Run("JSON config used as base", func(t *testing.T) { + t.Setenv("CONFIG", tmpFile) + + got := NewServerConfig() + + if got.AddrServer != "json-host:3000" { + t.Errorf("AddrServer = %v, want json-host:3000", got.AddrServer) + } + if got.AddrURL != "http://json-url" { + t.Errorf("AddrURL = %v, want http://json-url", got.AddrURL) + } + if got.File != "/json/path.db" { + t.Errorf("File = %v, want /json/path.db", got.File) + } + if got.DSN != "json-dsn" { + t.Errorf("DSN = %v, want json-dsn", got.DSN) + } + if !got.Enable_HTTPS { + t.Errorf("Enable_HTTPS = %v, want true", got.Enable_HTTPS) + } + }) + + t.Run("env vars override JSON config", func(t *testing.T) { + t.Setenv("CONFIG", tmpFile) + t.Setenv("SERVER_ADDRESS", ":9999") + t.Setenv("BASE_URL", "http://env-url") + t.Setenv("FILE_STORAGE_PATH", "/env/path.db") + t.Setenv("DATABASE_DSN", "env-dsn") + t.Setenv("ENABLE_HTTPS", "false") + t.Setenv("DELETE_WORKER_COUNT", "5") + t.Setenv("DELETE_CHANNEL_SIZE", "500") + t.Setenv("SECRET_KEY", "env-secret") + t.Setenv("AUDIT_FILE", "/env/audit.log") + t.Setenv("AUDIT_URL", "http://env-audit") + + got := NewServerConfig() + + if got.AddrServer != ":9999" { + t.Errorf("AddrServer = %v, want :9999", got.AddrServer) + } + if got.AddrURL != "http://env-url" { + t.Errorf("AddrURL = %v, want http://env-url", got.AddrURL) + } + if got.File != "/env/path.db" { + t.Errorf("File = %v, want /env/path.db", got.File) + } + if got.DSN != "env-dsn" { + t.Errorf("DSN = %v, want env-dsn", got.DSN) + } + if got.Enable_HTTPS { + t.Errorf("Enable_HTTPS = %v, want false", got.Enable_HTTPS) + } + if got.DeleteWorkerCount != 5 { + t.Errorf("DeleteWorkerCount = %v, want 5", got.DeleteWorkerCount) + } + if got.DeleteChannelSize != 500 { + t.Errorf("DeleteChannelSize = %v, want 500", got.DeleteChannelSize) + } + if got.SecretKey != "env-secret" { + t.Errorf("SecretKey = %v, want env-secret", got.SecretKey) + } + if got.AuditFile != "/env/audit.log" { + t.Errorf("AuditFile = %v, want /env/audit.log", got.AuditFile) + } + if got.AuditURL != "http://env-audit" { + t.Errorf("AuditURL = %v, want http://env-audit", got.AuditURL) + } + }) + + t.Run("invalid env ints fallback to flag defaults", func(t *testing.T) { + t.Setenv("DELETE_WORKER_COUNT", "not-a-number") + t.Setenv("DELETE_CHANNEL_SIZE", "not-a-number") + + got := NewServerConfig() + + if got.DeleteWorkerCount != 2 { + t.Errorf("DeleteWorkerCount = %v, want 2 (flag default)", got.DeleteWorkerCount) + } + if got.DeleteChannelSize != 1000 { + t.Errorf("DeleteChannelSize = %v, want 1000 (flag default)", got.DeleteChannelSize) + } + }) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 9c029db..e3c15a8 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -273,7 +273,7 @@ func (u *URLHandler) BatchShorten(c *gin.Context) { // Возвращает 200 OK при успешном подключении, 500 Internal Server Error в противном случае. func (u *URLHandler) PingDB(c *gin.Context) { if u.Service.Storage == nil { - u.logger.Warnw("storage is not initialized") + u.logger.Warnw("ping check failed: storage is not initialized") c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) return } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 9a3f44c..535653b 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -11,7 +11,7 @@ import ( func New() (*zap.SugaredLogger, error) { logger, err := zap.NewProduction() if err != nil { - log.Printf("Init logger: error %v", err) + log.Printf("failed to initialize zap production logger: %v", err) return nil, err } sugar := logger.Sugar() diff --git a/internal/service/service.go b/internal/service/service.go index c649781..e6934b4 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -86,7 +86,7 @@ func (s *Service) ShortenURL(ctx context.Context, longURL string, userID string) s.logger.Infow("URL already exists, returning conflict", "short_url", existingShortURL) return existingShortURL, &ConflictError{ShortURL: existingShortURL} } else if storageErr != nil { - s.logger.Warnw("Failed to insert URL into storage", "error", storageErr) + s.logger.Warnw("failed to insert URL into storage", "error", storageErr, "original_url", longURL) } } @@ -158,7 +158,7 @@ func (s *Service) ShortenBatchURL(ctx context.Context, dataMap map[string]string if s.Storage != nil { if err := s.Storage.InsertBatch(ctx, dataList); err != nil { - s.logger.Warnw("Failed to insert batch into storage", "error", err) + s.logger.Warnw("failed to insert batch into storage", "error", err, "batch_size", len(dataList)) } }