diff --git a/.github/workflows/statictest.yml b/.github/workflows/statictest.yml index 935f9ad..bdc4547 100644 --- a/.github/workflows/statictest.yml +++ b/.github/workflows/statictest.yml @@ -1,33 +1,34 @@ name: go vet test on: - pull_request: - push: - branches: - - main + pull_request: + push: + branches: + - main jobs: - statictest: - runs-on: ubuntu-latest - container: golang:1.26 - steps: - - name: Checkout code - uses: actions/checkout@v2 + statictest: + runs-on: ubuntu-latest + container: golang:1.26 + steps: + - name: Checkout code + uses: actions/checkout@v2 - - name: Download statictest binary - uses: robinraju/release-downloader@v1.8 - with: - repository: Yandex-Practicum/go-autotests - latest: true - fileName: statictest - out-file-path: .tools - token: ${{ secrets.GITHUB_TOKEN }} + - name: Download statictest binary + uses: robinraju/release-downloader@v1.8 + with: + repository: Yandex-Practicum/go-autotests + latest: false + tag: v0.13.4 + fileName: statictest + out-file-path: .tools + token: ${{ secrets.GITHUB_TOKEN }} - - name: Setup autotest binary - run: | - chmod -R +x $GITHUB_WORKSPACE/.tools/statictest - mv $GITHUB_WORKSPACE/.tools/statictest /usr/local/bin/statictest + - name: Setup autotest binary + run: | + chmod -R +x $GITHUB_WORKSPACE/.tools/statictest + mv $GITHUB_WORKSPACE/.tools/statictest /usr/local/bin/statictest - - name: Run statictest - run: | - go vet -vettool=$(which statictest) ./... + - name: Run statictest + run: | + go vet -vettool=$(which statictest) ./... diff --git a/config.example.json b/config.example.json index 8017685..efcacc4 100644 --- a/config.example.json +++ b/config.example.json @@ -11,5 +11,6 @@ "audit_url": "", "enable_https": false, "cert_file": "cert.pem", - "key_file": "key.pem" + "key_file": "key.pem", + "trusted_subnet": "" } diff --git a/internal/app/app.go b/internal/app/app.go index ca16d4a..3936fab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,13 +26,14 @@ import ( // App — корневая структура приложения, содержащая HTTP-сервер, роутер и фоновые воркеры. type App struct { - server *http.Server - router *gin.Engine - urlHandler *handler.URLHandler - deleteWorker *worker.DeleteWorker - enableTLS bool - certFile string - keyFile string + server *http.Server + router *gin.Engine + urlHandler *handler.URLHandler + deleteWorker *worker.DeleteWorker + enableTLS bool + certFile string + keyFile string + trustedSubnet string } // New создаёт и настраивает новое приложение: @@ -151,13 +152,14 @@ func New() (*App, error) { } return &App{ - server: httpServer, - router: router, - urlHandler: urlHandler, - deleteWorker: deleteWorker, - enableTLS: cfg.Enable_HTTPS, - certFile: cfg.CertFile, - keyFile: cfg.KeyFile, + server: httpServer, + router: router, + urlHandler: urlHandler, + deleteWorker: deleteWorker, + enableTLS: cfg.Enable_HTTPS, + certFile: cfg.CertFile, + keyFile: cfg.KeyFile, + trustedSubnet: cfg.TrustedSubnet, }, nil } @@ -200,6 +202,13 @@ func (a *App) SetupRoutes() { a.router.POST("/api/shorten/batch", a.urlHandler.BatchShorten) a.router.GET("/api/user/urls", a.urlHandler.GetUserURLs) a.router.DELETE("/api/user/urls", a.urlHandler.DeleteURLs) + + // Внутренние эндпоинты: доступ только из доверенной подсети. + internalAPI := a.router.Group("/api/internal", middleware.TrustedSubnet(a.trustedSubnet)) + { + internalAPI.GET("/stats", a.urlHandler.GetStats) + } + a.router.NoMethod(a.urlHandler.NotAllowed) a.router.NoRoute(a.urlHandler.NotFound) diff --git a/internal/config/flag/config.go b/internal/config/flag/config.go index 5604066..2707ef4 100644 --- a/internal/config/flag/config.go +++ b/internal/config/flag/config.go @@ -28,6 +28,7 @@ type ServerConfig struct { CertFile string `env:"CERT_FILE"` KeyFile string `env:"KEY_FILE"` ConfigJSON string `env:"CONFIG"` + TrustedSubnet string `env:"TRUSTED_SUBNET"` } // JSONFileConfig — структура JSON-файла конфигурации. @@ -37,6 +38,7 @@ type JSONFileConfig struct { FileStoragePath string `json:"file_storage_path"` DatabaseDSN string `json:"database_dsn"` EnableHTTPS *bool `json:"enable_https"` + TrustedSubnet string `json:"trusted_subnet"` } var ( @@ -60,6 +62,7 @@ func initFlags() { 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") + fs.StringP("trusted-subnet", "t", "", "trusted subnet in CIDR notation") // Ошибки разбора флагов (например, неизвестные флаги go test) намеренно игнорируются. _ = fs.Parse(os.Args[1:]) } @@ -76,6 +79,7 @@ func loadJSONConfig(path string) (*JSONFileConfig, error) { BaseURL: v.GetString("base_url"), FileStoragePath: v.GetString("file_storage_path"), DatabaseDSN: v.GetString("database_dsn"), + TrustedSubnet: v.GetString("trusted_subnet"), } if v.IsSet("enable_https") { b := v.GetBool("enable_https") @@ -105,6 +109,7 @@ func NewServerConfig() *ServerConfig { v.SetDefault("enable_https", false) v.SetDefault("cert_file", "cert.pem") v.SetDefault("key_file", "key.pem") + v.SetDefault("trusted_subnet", "") // Привязка флагов командной строки к ключам Viper _ = v.BindPFlag("server_address", fs.Lookup("a")) @@ -121,6 +126,7 @@ func NewServerConfig() *ServerConfig { _ = v.BindPFlag("cert_file", fs.Lookup("cert")) _ = v.BindPFlag("key_file", fs.Lookup("key")) _ = v.BindPFlag("config", fs.Lookup("config")) + _ = v.BindPFlag("trusted_subnet", fs.Lookup("trusted-subnet")) // Переменные окружения: ключ автоматически преобразуется в верхний регистр // (server_address → SERVER_ADDRESS, base_url → BASE_URL и т.д.) @@ -148,6 +154,7 @@ func NewServerConfig() *ServerConfig { CertFile: v.GetString("cert_file"), KeyFile: v.GetString("key_file"), ConfigJSON: configPath, + TrustedSubnet: v.GetString("trusted_subnet"), } } diff --git a/internal/handler/example_test.go b/internal/handler/example_test.go index 086bb3e..45e3c25 100644 --- a/internal/handler/example_test.go +++ b/internal/handler/example_test.go @@ -11,6 +11,7 @@ import ( "github.com/anon-d/urlshortener/internal/handler" "github.com/anon-d/urlshortener/internal/model" + "github.com/anon-d/urlshortener/internal/repository" "github.com/anon-d/urlshortener/internal/service" "github.com/anon-d/urlshortener/internal/worker" "github.com/gin-gonic/gin" @@ -54,6 +55,9 @@ func (m *mockStorageEx) BatchMarkAsDeleted(ctx context.Context, r []worker.Delet return nil } func (m *mockStorageEx) Ping(ctx context.Context) error { return nil } +func (m *mockStorageEx) GetStats(ctx context.Context) (repository.Stats, error) { + return repository.Stats{}, nil +} func init() { gin.SetMode(gin.TestMode) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index e3c15a8..6bc3c85 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -50,6 +50,13 @@ type UserURLResponse struct { OriginalURL string `json:"original_url"` } +// StatsResponse — тело ответа для GET /api/internal/stats. +// Содержит количество сокращённых URL и пользователей в сервисе. +type StatsResponse struct { + URLs int `json:"urls"` + Users int `json:"users"` +} + // URLHandler содержит зависимости и методы для обработки HTTP-запросов // к сервису сокращения URL. type URLHandler struct { @@ -354,6 +361,22 @@ func (u *URLHandler) DeleteURLs(c *gin.Context) { c.Status(http.StatusAccepted) } +// GetStats возвращает сводную статистику сервиса: +// количество сокращённых URL и количество пользователей. +// Доступ ограничивается доверенной подсетью (см. middleware TrustedSubnet). +func (u *URLHandler) GetStats(c *gin.Context) { + stats, err := u.Service.GetStats(c) + if err != nil { + u.logger.Errorw("failed to get stats", "error", err) + c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + return + } + c.JSON(http.StatusOK, StatsResponse{ + URLs: stats.URLs, + Users: stats.Users, + }) +} + // publishAudit отправляет событие аудита во все зарегистрированные приёмники. func (u *URLHandler) publishAudit(action, userID, originalURL string) { if u.audit == nil { diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index f033230..319d120 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/anon-d/urlshortener/internal/model" + "github.com/anon-d/urlshortener/internal/repository" "github.com/anon-d/urlshortener/internal/service" "github.com/anon-d/urlshortener/internal/worker" "github.com/gin-gonic/gin" @@ -120,6 +121,13 @@ func (m *mockStorage) Ping(ctx context.Context) error { return nil } +func (m *mockStorage) GetStats(ctx context.Context) (repository.Stats, error) { + if m.shouldFail { + return repository.Stats{}, errors.New("get stats error") + } + return repository.Stats{URLs: 2, Users: 1}, nil +} + func TestPostURL_Success(t *testing.T) { testLogger := zap.NewNop().Sugar() @@ -747,3 +755,7 @@ func (m *mockStorageEmpty) UpdateBatch(ctx context.Context, urls []string) error func (m *mockStorageEmpty) Ping(ctx context.Context) error { return nil } + +func (m *mockStorageEmpty) GetStats(ctx context.Context) (repository.Stats, error) { + return repository.Stats{}, nil +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 71ef7ef..8d9916d 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "net" "net/http" "strings" "sync" @@ -208,6 +209,31 @@ func signValue(value string, secretKey string) string { return value + "." + signature } +// TrustedSubnet проверяет, что IP-адрес клиента из заголовка X-Real-IP +// входит в доверенную подсеть (CIDR). Если строка подсети пустая или +// невалидна, доступ запрещён для всех запросов (403 Forbidden). +func TrustedSubnet(subnet string) gin.HandlerFunc { + var ipNet *net.IPNet + if subnet != "" { + if _, parsed, err := net.ParseCIDR(subnet); err == nil { + ipNet = parsed + } + } + return func(c *gin.Context) { + if ipNet == nil { + c.AbortWithStatus(http.StatusForbidden) + return + } + realIP := strings.TrimSpace(c.GetHeader("X-Real-IP")) + ip := net.ParseIP(realIP) + if ip == nil || !ipNet.Contains(ip) { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Next() + } +} + // validateSignedValue проверяет подпись и возвращает оригинальное значение func validateSignedValue(signedValue string, secretKey string) (string, bool) { parts := strings.Split(signedValue, ".") diff --git a/internal/repository/db/postgres/postgres.go b/internal/repository/db/postgres/postgres.go index 5cf5c84..4555d3f 100644 --- a/internal/repository/db/postgres/postgres.go +++ b/internal/repository/db/postgres/postgres.go @@ -236,6 +236,25 @@ func (r *Repository) BatchMarkAsDeleted(ctx context.Context, requests []worker.D return nil } +// GetStats возвращает количество сокращённых URL и уникальных пользователей из БД. +func (r *Repository) GetStats(ctx context.Context) (repository.Stats, error) { + query := ` + SELECT + COUNT(*) FILTER (WHERE COALESCE(is_deleted, false) = false) AS urls, + COUNT(DISTINCT user_id) FILTER (WHERE COALESCE(is_deleted, false) = false AND user_id IS NOT NULL AND user_id <> '') AS users + FROM urls + ` + var stats repository.Stats + err := r.db.QueryRowContext(ctx, query).Scan(&stats.URLs, &stats.Users) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return repository.Stats{}, nil + } + return repository.Stats{}, fmt.Errorf("failed to get stats in GetStats: %w", err) + } + return stats, nil +} + // GetURLByShortURL получает URL по короткой ссылке func (r *Repository) GetURLByShortURL(ctx context.Context, shortURL string) (repository.Data, error) { query := "SELECT id, short_url, original_url, COALESCE(user_id, ''), COALESCE(is_deleted, false) FROM urls WHERE short_url = $1" diff --git a/internal/repository/models.go b/internal/repository/models.go index f59fb48..b607101 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -13,3 +13,9 @@ type Data struct { UserID string IsDeleted bool } + +// Stats — сводная статистика сервиса. +type Stats struct { + URLs int + Users int +} diff --git a/internal/repository/storage.go b/internal/repository/storage.go index 55a6304..88a8f55 100644 --- a/internal/repository/storage.go +++ b/internal/repository/storage.go @@ -18,6 +18,7 @@ type Storage interface { GetURLsByUser(ctx context.Context, userID string) ([]model.Data, error) GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error + GetStats(ctx context.Context) (Stats, error) Ping(ctx context.Context) error } @@ -118,6 +119,11 @@ func (d *DBAdapter) Ping(ctx context.Context) error { return d.db.Ping(ctx) } +// GetStats возвращает количество сокращённых URL и уникальных пользователей из БД. +func (d *DBAdapter) GetStats(ctx context.Context) (Stats, error) { + return d.db.GetStats(ctx) +} + // LocalAdapter адаптирует Local файловое хранилище к интерфейсу Storage type LocalAdapter struct { local Local @@ -221,6 +227,26 @@ func (l *LocalAdapter) Ping(ctx context.Context) error { return nil } +// GetStats возвращает количество URL и уникальных пользователей в локальном файле. +func (l *LocalAdapter) GetStats(ctx context.Context) (Stats, error) { + data, err := l.local.Load() + if err != nil { + return Stats{}, err + } + users := make(map[string]struct{}) + urls := 0 + for _, item := range data { + if item.IsDeleted { + continue + } + urls++ + if item.UserID != "" { + users[item.UserID] = struct{}{} + } + } + return Stats{URLs: urls, Users: len(users)}, nil +} + // DB — интерфейс работы с реляционной базой данных. type DB interface { InsertURL(ctx context.Context, id, shortURL, originalURL, userID string) error @@ -230,6 +256,7 @@ type DB interface { GetURLsByUser(ctx context.Context, userID string) ([]Data, error) GetURLByShortURL(ctx context.Context, shortURL string) (Data, error) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error + GetStats(ctx context.Context) (Stats, error) Ping(ctx context.Context) error } diff --git a/internal/service/service.go b/internal/service/service.go index e6934b4..6c1d761 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -110,6 +110,14 @@ func (s *Service) GetURLsByUser(ctx context.Context, userID string) ([]model.Dat return s.Storage.GetURLsByUser(ctx, userID) } +// GetStats возвращает количество сокращённых URL и уникальных пользователей в сервисе. +func (s *Service) GetStats(ctx context.Context) (repository.Stats, error) { + if s.Storage == nil { + return repository.Stats{}, errors.New("storage is not available") + } + return s.Storage.GetStats(ctx) +} + // GetURLByShortURL получает полные данные URL по короткой ссылке func (s *Service) GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) { // Сначала проверяем кэш diff --git a/profiles/base.pprof b/profiles/base.pprof index a7313f5..c72c86a 100644 Binary files a/profiles/base.pprof and b/profiles/base.pprof differ