Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 26 additions & 25 deletions .github/workflows/statictest.yml
Original file line number Diff line number Diff line change
@@ -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) ./...
3 changes: 2 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@
"audit_url": "",
"enable_https": false,
"cert_file": "cert.pem",
"key_file": "key.pem"
"key_file": "key.pem",
"trusted_subnet": ""
}
37 changes: 23 additions & 14 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 создаёт и настраивает новое приложение:
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions internal/config/flag/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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-файла конфигурации.
Expand All @@ -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 (
Expand All @@ -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:])
}
Expand All @@ -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")
Expand Down Expand Up @@ -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"))
Expand All @@ -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 и т.д.)
Expand Down Expand Up @@ -148,6 +154,7 @@ func NewServerConfig() *ServerConfig {
CertFile: v.GetString("cert_file"),
KeyFile: v.GetString("key_file"),
ConfigJSON: configPath,
TrustedSubnet: v.GetString("trusted_subnet"),
}
}

Expand Down
4 changes: 4 additions & 0 deletions internal/handler/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions internal/handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
}
26 changes: 26 additions & 0 deletions internal/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"net"
"net/http"
"strings"
"sync"
Expand Down Expand Up @@ -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, ".")
Expand Down
19 changes: 19 additions & 0 deletions internal/repository/db/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions internal/repository/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ type Data struct {
UserID string
IsDeleted bool
}

// Stats — сводная статистика сервиса.
type Stats struct {
URLs int
Users int
}
27 changes: 27 additions & 0 deletions internal/repository/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
Loading
Loading