737 lines
35 KiB
Go
737 lines
35 KiB
Go
|
|
// Команда api — точка входа HTTP-сервера: конфигурация, роуты,
|
|||
|
|
// лимитеры и graceful shutdown. Запуск: go run ./cmd/api.
|
|||
|
|
package main
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"log/slog"
|
|||
|
|
"net/http"
|
|||
|
|
"os"
|
|||
|
|
"os/signal"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
"syscall"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/joho/godotenv"
|
|||
|
|
|
|||
|
|
"building-mgmt/internal/auth"
|
|||
|
|
"building-mgmt/internal/crypt"
|
|||
|
|
"building-mgmt/internal/db"
|
|||
|
|
"building-mgmt/internal/handlers"
|
|||
|
|
"building-mgmt/internal/logging"
|
|||
|
|
"building-mgmt/internal/mailer"
|
|||
|
|
"building-mgmt/internal/middleware"
|
|||
|
|
"building-mgmt/internal/notify"
|
|||
|
|
"building-mgmt/internal/ratelimit"
|
|||
|
|
"building-mgmt/internal/repository"
|
|||
|
|
"building-mgmt/internal/storage"
|
|||
|
|
"building-mgmt/internal/telegram"
|
|||
|
|
|
|||
|
|
// Импорт регистрирует сгенерированную схему (docs/swagger.json)
|
|||
|
|
// для Swagger UI — без него /swagger/index.html не найдёт doc.json.
|
|||
|
|
_ "building-mgmt/docs"
|
|||
|
|
|
|||
|
|
swaggerFiles "github.com/swaggo/files"
|
|||
|
|
ginSwagger "github.com/swaggo/gin-swagger"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// @title Building Management API
|
|||
|
|
// @version 1.0
|
|||
|
|
// @description Управление заявками на обслуживание: админ создаёт клиента, клиент заводит проект, объекты в нём и мастеров, назначает заявку мастеру проекта, мастер выполняет, клиент подтверждает работу или отклоняет её на доработку. Финальный статус — closed.
|
|||
|
|
// @BasePath /
|
|||
|
|
// @host localhost:8080
|
|||
|
|
// @schemes http
|
|||
|
|
// @securityDefinitions.apikey Bearer
|
|||
|
|
// @in header
|
|||
|
|
// @name Authorization
|
|||
|
|
// @description JWT формата Bearer, получить через POST /auth/login. Срок жизни — 1 час, дальше POST /auth/refresh.
|
|||
|
|
func main() {
|
|||
|
|
os.Exit(run())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// run возвращает код выхода, а не вызывает os.Exit внутри: так defer
|
|||
|
|
// (закрытие пула) гарантированно отрабатывает — в том числе после
|
|||
|
|
// graceful shutdown. `log.Fatal` здесь был бы той же ловушкой.
|
|||
|
|
func run() int {
|
|||
|
|
_ = godotenv.Load()
|
|||
|
|
logger := logging.Setup()
|
|||
|
|
|
|||
|
|
// Фейл-фаст до подключения к БД: без секрета токены подписывались бы
|
|||
|
|
// пустой строкой, и любой знал бы «секрет».
|
|||
|
|
jwtSecret := os.Getenv("JWT_SECRET")
|
|||
|
|
if jwtSecret == "" {
|
|||
|
|
logger.Error("JWT_SECRET is not set — refusing to start")
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
if len(jwtSecret) < 32 {
|
|||
|
|
logger.Warn("JWT_SECRET is short", "length", len(jwtSecret), "recommended", 32)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// gin по умолчанию пишет баннер и свой цветной access-log поверх
|
|||
|
|
// структурных логов. GIN_MODE задаётся явно — режим не трогаем.
|
|||
|
|
if os.Getenv("GIN_MODE") == "" {
|
|||
|
|
gin.SetMode(gin.ReleaseMode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx := context.Background()
|
|||
|
|
pool, err := db.Connect(ctx, os.Getenv("DATABASE_URL"))
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Error("db connect failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
defer pool.Close()
|
|||
|
|
logger.Info("DB connected")
|
|||
|
|
|
|||
|
|
if err := db.Migrate(ctx, pool); err != nil {
|
|||
|
|
logger.Error("migrations failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
profileRepo := repository.NewProfileRepo(pool)
|
|||
|
|
refreshRepo := repository.NewRefreshTokenRepo(pool)
|
|||
|
|
// Secure-флаг на refresh-куке: локально сайт по HTTP и браузер
|
|||
|
|
// отверг бы Secure-куку, поэтому включается только в проде (COOKIE_SECURE).
|
|||
|
|
cookieSecure := os.Getenv("COOKIE_SECURE") == "true"
|
|||
|
|
logger.Info("refresh cookie", "secure", cookieSecure)
|
|||
|
|
|
|||
|
|
// Почта подтверждений (SMTP, go-smtp): пустой SMTP_HOST — заглушка
|
|||
|
|
// без отправки (dev), неверные настройки — фейл-фаст на старте,
|
|||
|
|
// как у S3 и JWT: сюрприз на первом подтверждении не нужен.
|
|||
|
|
ml, err := mailer.New(mailer.Config{
|
|||
|
|
Host: os.Getenv("SMTP_HOST"),
|
|||
|
|
Port: os.Getenv("SMTP_PORT"),
|
|||
|
|
User: os.Getenv("SMTP_USER"),
|
|||
|
|
Password: os.Getenv("SMTP_PASSWORD"),
|
|||
|
|
From: os.Getenv("SMTP_FROM"),
|
|||
|
|
TLS: os.Getenv("SMTP_TLS"),
|
|||
|
|
BaseURL: os.Getenv("APP_BASE_URL"),
|
|||
|
|
}, logger)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Error("smtp config failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Лимит писем «забыли пароль» на ящик: 3 в сутки, ключ — email
|
|||
|
|
// (смотрит сам хендлер — middleware тут не подходит, ключ зависит
|
|||
|
|
// от тела запроса). Стоит здесь, потому что уходит в конструктор
|
|||
|
|
// хендлера; IP-лимиты того же эндпоинта — в общем блоке ниже.
|
|||
|
|
forgotMailLimiter := ratelimit.NewDB(pool, "forgot_mail", 3, 24*time.Hour)
|
|||
|
|
|
|||
|
|
authHandler := handlers.NewAuthHandler(profileRepo, refreshRepo, jwtSecret, cookieSecure, ml, forgotMailLimiter)
|
|||
|
|
userHandler := handlers.NewUserHandler(profileRepo, refreshRepo, ml)
|
|||
|
|
|
|||
|
|
projectRepo := repository.NewProjectRepo(pool)
|
|||
|
|
|
|||
|
|
ticketRepo := repository.NewTicketRepo(pool)
|
|||
|
|
ticketHandler := handlers.NewTicketHandler(ticketRepo, profileRepo, projectRepo)
|
|||
|
|
|
|||
|
|
// Правила повторяющихся заявок (миграция 0014): репозиторий — здесь,
|
|||
|
|
// хендлер собирается ниже, после Telegram-блока (ему нужно на уведомление
|
|||
|
|
// при ручном запуске), фоновое создание заявок — горутина runRecurring.
|
|||
|
|
recurringRepo := repository.NewRecurringRepo(pool)
|
|||
|
|
|
|||
|
|
// Объектное хранилище фото (S3): локально MinIO в compose, в проде —
|
|||
|
|
// любой S3-совместимый. Фейл-фаст как с JWT: без бакета загрузка
|
|||
|
|
// упала бы на первом же фото, а не на старте.
|
|||
|
|
s3Cfg, err := storage.ConfigFromEnv()
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Error("s3 config failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
photoStore, err := storage.NewS3(ctx, s3Cfg)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Error("s3 init failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
if err := photoStore.EnsureBucket(ctx); err != nil {
|
|||
|
|
logger.Error("s3 bucket ensure failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
logger.Info("S3 ready", "endpoint", s3Cfg.Endpoint, "bucket", photoStore.Bucket())
|
|||
|
|
|
|||
|
|
// Проектный хендлер — после хранилища: удаление проекта чистит
|
|||
|
|
// объекты фото заявок (store в конструкторе).
|
|||
|
|
projectHandler := handlers.NewProjectHandler(projectRepo, profileRepo, photoStore)
|
|||
|
|
|
|||
|
|
// Лимиты фотоотчёта: 0 → дефолт внутри хендлера (10 МБ / 20 файлов).
|
|||
|
|
photoMaxBytes := int64(0)
|
|||
|
|
if v := os.Getenv("PHOTO_MAX_BYTES"); v != "" {
|
|||
|
|
n, err := strconv.ParseInt(v, 10, 64)
|
|||
|
|
if err != nil || n <= 0 {
|
|||
|
|
logger.Error("PHOTO_MAX_BYTES must be a positive number of bytes", "value", v)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
photoMaxBytes = n
|
|||
|
|
}
|
|||
|
|
photoMaxPer := 0
|
|||
|
|
if v := os.Getenv("PHOTO_MAX_PER_TICKET"); v != "" {
|
|||
|
|
n, err := strconv.Atoi(v)
|
|||
|
|
if err != nil || n <= 0 {
|
|||
|
|
logger.Error("PHOTO_MAX_PER_TICKET must be a positive number", "value", v)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
photoMaxPer = n
|
|||
|
|
}
|
|||
|
|
photoRepo := repository.NewPhotoRepo(pool)
|
|||
|
|
photoHandler := handlers.NewPhotoHandler(ticketRepo, photoRepo, projectRepo, photoStore, photoMaxBytes, photoMaxPer)
|
|||
|
|
|
|||
|
|
// Протухшие сессии никому не нужны: чистим один раз при старте,
|
|||
|
|
// дальше таблица держится в актуальном состоянии сама.
|
|||
|
|
if n, err := refreshRepo.DeleteExpired(ctx); err != nil {
|
|||
|
|
logger.Warn("refresh tokens cleanup failed", "err", err)
|
|||
|
|
} else if n > 0 {
|
|||
|
|
logger.Info("refresh tokens cleanup", "removed", n)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Автоочистка старых заявок: закрытые старше TICKET_RETENTION_DAYS
|
|||
|
|
// (дефолт 60 дней, 0 — чистка выключена). Прогон сразу при старте и
|
|||
|
|
// дальше раз в сутки: заявки накапливаются медленно, суточного тика
|
|||
|
|
// хватает, а таблица не растёт без верхней границы.
|
|||
|
|
retentionDays := 60
|
|||
|
|
if v := os.Getenv("TICKET_RETENTION_DAYS"); v != "" {
|
|||
|
|
n, err := strconv.Atoi(v)
|
|||
|
|
if err != nil || n < 0 {
|
|||
|
|
logger.Error("TICKET_RETENTION_DAYS must be a non-negative number of days", "value", v)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
retentionDays = n
|
|||
|
|
}
|
|||
|
|
if retentionDays > 0 {
|
|||
|
|
ticketsCtx, ticketsCancel := context.WithCancel(context.Background())
|
|||
|
|
defer ticketsCancel()
|
|||
|
|
go runTicketsCleanup(ticketsCtx, logger, ticketRepo, photoStore,
|
|||
|
|
time.Duration(retentionDays)*24*time.Hour)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Брутфорс-лимиты по IP — в БД (таблица rate_limits, миграция 0009):
|
|||
|
|
// счётчик переживает рестарт API и общий для нескольких инстансов.
|
|||
|
|
// Числа — здесь же, рядом с тем, куда вешаются.
|
|||
|
|
loginLimiter := ratelimit.NewDB(pool, "login", 10, time.Minute)
|
|||
|
|
refreshLimiter := ratelimit.NewDB(pool, "refresh", 30, time.Minute)
|
|||
|
|
resetLimiter := ratelimit.NewDB(pool, "reset", 30, time.Minute)
|
|||
|
|
// forgot — IP-лимит «забыли пароль»; лимит на сам ящик (3 письма
|
|||
|
|
// в сутки) живёт в forgotMailLimiter рядом с конструктором хендлера.
|
|||
|
|
forgotLimiter := ratelimit.NewDB(pool, "forgot", 10, time.Minute)
|
|||
|
|
const rateLimitMsg = "too many attempts, try again later"
|
|||
|
|
|
|||
|
|
// Чистка протухших строк лимитов — фоном, до самого выхода процесса.
|
|||
|
|
limiterCtx, limiterCancel := context.WithCancel(context.Background())
|
|||
|
|
defer limiterCancel()
|
|||
|
|
go ratelimit.RunCleanup(limiterCtx, pool, time.Minute)
|
|||
|
|
|
|||
|
|
// Telegram: напоминание клиенту о заявке, которая простояла в статусе
|
|||
|
|
// 'new' дольше порога, и привязка его чата по одноразовому коду.
|
|||
|
|
// Два источника бота: собственный токен пользователя (вводится в
|
|||
|
|
// «Настройках», хранится зашифрованным) и глобальный бот сервера
|
|||
|
|
// TELEGRAM_BOT_TOKEN как запасной для тех, кто токен не ввёл.
|
|||
|
|
tgCodeLimiter := ratelimit.NewDB(pool, "tg_code", 5, 5*time.Minute)
|
|||
|
|
// Сохранение токена — сетевой getMe, поэтому свой лимит: подбор
|
|||
|
|
// токенов упирается в потолок до сети и до базы.
|
|||
|
|
tgTokenLimiter := ratelimit.NewDB(pool, "tg_token", 5, 5*time.Minute)
|
|||
|
|
tgRepo := repository.NewTelegramRepo(pool)
|
|||
|
|
tgAPIURL := os.Getenv("TELEGRAM_API_URL")
|
|||
|
|
tgBot := telegram.New(telegram.Config{
|
|||
|
|
Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
|||
|
|
APIURL: tgAPIURL,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Контекст фоновых задач Telegram живёт до выхода процесса: на нём
|
|||
|
|
// висят нотификатор, поллер глобального бота и все поллеры личных
|
|||
|
|
// токенов (defer гасит их разом при graceful shutdown).
|
|||
|
|
tgCtx, tgCancel := context.WithCancel(context.Background())
|
|||
|
|
defer tgCancel()
|
|||
|
|
|
|||
|
|
// Реестр решает, чьим ботом слать (личный токен приоритетнее
|
|||
|
|
// глобального) и владеет поллерами личных ботов.
|
|||
|
|
tgRegistry := notify.NewRegistry(tgCtx, tgRepo, tgBot, logger)
|
|||
|
|
|
|||
|
|
// Шифрование токенов ботов (AES-256-GCM, ключ — DATA_ENCRYPTION_KEY).
|
|||
|
|
// Без ключа ввод токена выключен: сохранять его некуда, а лежащие
|
|||
|
|
// в БД прочитать невозможно — это осознанная блокировка, а не сбой.
|
|||
|
|
var tgCipher *crypt.Cipher
|
|||
|
|
if v := os.Getenv("DATA_ENCRYPTION_KEY"); v != "" {
|
|||
|
|
c, err := crypt.New(v)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Error("DATA_ENCRYPTION_KEY is invalid (32 bytes, base64 or hex)", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
tgCipher = c
|
|||
|
|
} else {
|
|||
|
|
logger.Info("telegram user bot tokens disabled (DATA_ENCRYPTION_KEY is empty)")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Порог напоминания и период тика — env с явными дефолтами:
|
|||
|
|
// 24 часа, как в требованиях, и минута — чтобы «взяли в работу»
|
|||
|
|
// заметилось почти сразу после гашения уведомления.
|
|||
|
|
tgAfter := 24 * time.Hour
|
|||
|
|
if v := os.Getenv("TELEGRAM_NOTIFY_AFTER"); v != "" {
|
|||
|
|
d, err := time.ParseDuration(v)
|
|||
|
|
if err != nil || d <= 0 {
|
|||
|
|
logger.Error("TELEGRAM_NOTIFY_AFTER must be a positive duration, e.g. 24h", "value", v)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
tgAfter = d
|
|||
|
|
}
|
|||
|
|
tgTick := time.Minute
|
|||
|
|
if v := os.Getenv("TELEGRAM_TICK_EVERY"); v != "" {
|
|||
|
|
d, err := time.ParseDuration(v)
|
|||
|
|
if err != nil || d <= 0 {
|
|||
|
|
logger.Error("TELEGRAM_TICK_EVERY must be a positive duration, e.g. 1m", "value", v)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
tgTick = d
|
|||
|
|
}
|
|||
|
|
// Ссылка в уведомлении — как и в письмах: пустой APP_BASE_URL
|
|||
|
|
// означает локальный запуск (см. mailer).
|
|||
|
|
appBaseURL := os.Getenv("APP_BASE_URL")
|
|||
|
|
if appBaseURL == "" {
|
|||
|
|
appBaseURL = "http://localhost:3000"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tgHandler := handlers.NewTelegramHandler(handlers.TelegramConfig{
|
|||
|
|
Repo: tgRepo,
|
|||
|
|
Bots: tgRegistry,
|
|||
|
|
Cipher: tgCipher,
|
|||
|
|
// Фабрика без сети: клиент создаётся здесь, getMe делает сам
|
|||
|
|
// хендлер — он же отвечает за то, чтобы невалидный токен не
|
|||
|
|
// добрался до базы.
|
|||
|
|
NewBot: func(token string) telegram.API {
|
|||
|
|
return telegram.New(telegram.Config{Token: token, APIURL: tgAPIURL})
|
|||
|
|
},
|
|||
|
|
CodeLimiter: tgCodeLimiter,
|
|||
|
|
TokenLimiter: tgTokenLimiter,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Сохранённые токены читаем один раз: поднимаем клиенты и поллеры
|
|||
|
|
// личных ботов. Ключ сменили — токены не расшифровываются: такие
|
|||
|
|
// строки пропускаем с warn'ом, пользователь введёт токен заново.
|
|||
|
|
if tgCipher != nil {
|
|||
|
|
saved, err := tgRepo.BotTokens(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Error("telegram bot tokens load failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
for _, ub := range saved {
|
|||
|
|
token, err := tgCipher.Open(ub.TokenEnc)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warn("telegram bot token unreadable (encryption key changed?)",
|
|||
|
|
"user_id", ub.UserID, "err", err)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
tgRegistry.Register(ub.UserID, telegram.New(telegram.Config{
|
|||
|
|
Token: token,
|
|||
|
|
APIURL: tgAPIURL,
|
|||
|
|
}))
|
|||
|
|
}
|
|||
|
|
if n := tgRegistry.Users(); n > 0 {
|
|||
|
|
logger.Info("telegram user bots loaded", "count", n)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if tgBot.Enabled() {
|
|||
|
|
// getMe — имя бота для ссылки привязки; сбой не фатален:
|
|||
|
|
// повтор сделает редкий запрос кода (см. TelegramHandler.username).
|
|||
|
|
if err := tgBot.Init(tgCtx); err != nil {
|
|||
|
|
logger.Warn("telegram getMe failed", "err", err)
|
|||
|
|
} else {
|
|||
|
|
logger.Info("telegram bot ready", "username", tgBot.Username())
|
|||
|
|
}
|
|||
|
|
// Поллер глобального бота один на процесс: несколько инстансов
|
|||
|
|
// драли бы одну очередь getUpdates (в контейнере их один — см. README).
|
|||
|
|
go notify.RunPoller(tgCtx, tgRepo, tgBot, logger)
|
|||
|
|
} else {
|
|||
|
|
logger.Info("telegram global bot disabled (TELEGRAM_BOT_TOKEN is empty)")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Нотификатор крутится, пока есть хоть какой-то бот: без единого
|
|||
|
|
// токена он просто не найдёт кандидатов и не шлёт ничего.
|
|||
|
|
if tgBot.Enabled() || tgCipher != nil {
|
|||
|
|
go notify.Run(tgCtx, tgRepo, tgRegistry, tgAfter, tgTick, appBaseURL, logger)
|
|||
|
|
} else {
|
|||
|
|
logger.Info("telegram notifications disabled (no bot configured)")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Повторяющиеся заявки: прогон сразу при старте и дальше раз в сутки
|
|||
|
|
// (интервалы в месяцах, суточной точности хватает). Созданные заявки
|
|||
|
|
// клиенту уведомляются тем же Telegram, что и обычные напоминания;
|
|||
|
|
// сбой отправки — warn, заявка уже создана (fail-open, как в notify).
|
|||
|
|
recurringCtx, recurringCancel := context.WithCancel(context.Background())
|
|||
|
|
defer recurringCancel()
|
|||
|
|
go runRecurring(recurringCtx, logger, recurringRepo, tgRegistry, appBaseURL)
|
|||
|
|
|
|||
|
|
// Хендлер правил — после Telegram-блока: ручной «Запустить сейчас»
|
|||
|
|
// шлёт уведомление тем же кодом, что и фоновое срабатывание.
|
|||
|
|
recurringHandler := handlers.NewRecurringHandler(recurringRepo, projectRepo,
|
|||
|
|
func(ctx context.Context, run repository.DueRun) {
|
|||
|
|
notifyRecurring(ctx, logger, tgRegistry, appBaseURL, run)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// CORS: разрешённые источники фронта через запятую (CORS_ORIGINS).
|
|||
|
|
// Пусто — заголовки не ставятся вовсе: одностраничнику на том же
|
|||
|
|
// домене они не нужны, а чужому домену их и не должно быть.
|
|||
|
|
allowedOrigins := middleware.ParseOrigins(os.Getenv("CORS_ORIGINS"))
|
|||
|
|
if len(allowedOrigins) > 0 {
|
|||
|
|
logger.Info("CORS origins", "origins", allowedOrigins)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
r := gin.New()
|
|||
|
|
|
|||
|
|
// Доверенные прокси: gin по умолчанию верит любому X-Forwarded-For —
|
|||
|
|
// это подделка ключа рейт-лимитов (login/reset/forgot) и ложный
|
|||
|
|
// client_ip в логах. Без TRUSTED_PROXIES доверяем только loopback;
|
|||
|
|
// за reverse proxy (app-nginx в контейнерной сети) указываем его
|
|||
|
|
// подсеть: TRUSTED_PROXIES=172.0.0.0/12 (loopback доверяем всегда).
|
|||
|
|
// Прокси обязан ПЕРЕЗАПИСЫВАТЬ X-Forwarded-For ($remote_addr),
|
|||
|
|
// а не дополнять: gin берёт левый элемент списка.
|
|||
|
|
trusted := []string{"127.0.0.1/32", "::1/32"}
|
|||
|
|
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
|||
|
|
for _, s := range strings.Split(v, ",") {
|
|||
|
|
if s = strings.TrimSpace(s); s != "" {
|
|||
|
|
trusted = append(trusted, s)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if err := r.SetTrustedProxies(trusted); err != nil {
|
|||
|
|
logger.Error("invalid TRUSTED_PROXIES", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
logger.Info("trusted proxies", "proxies", trusted)
|
|||
|
|
|
|||
|
|
// Порядок важен: request_id — первый, чтобы recovery и access-log
|
|||
|
|
// уже видели id запроса; CORS — сразу после логов, но до роутов,
|
|||
|
|
// чтобы preflight-OPTIONS отвечал 204 ещё до авторизации.
|
|||
|
|
r.Use(
|
|||
|
|
middleware.RequestID(),
|
|||
|
|
middleware.Recovery(),
|
|||
|
|
middleware.AccessLog(),
|
|||
|
|
middleware.CORS(allowedOrigins),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
r.GET("/ping", func(c *gin.Context) {
|
|||
|
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Swagger UI: /swagger/index.html. Схема генерируется из аннотаций
|
|||
|
|
// над хендлерами командой `swag init -g cmd/api/main.go` (см. README).
|
|||
|
|
// По умолчанию выключен: открытая схема в проде не нужна —
|
|||
|
|
// включается флагом SWAGGER=true (так в локальной разработке).
|
|||
|
|
if os.Getenv("SWAGGER") == "true" {
|
|||
|
|
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
|||
|
|
logger.Info("swagger enabled")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Состояние БД — отдельный хелпер, раньше был открыт всем:
|
|||
|
|
// по ответу было видно, что база живёт и какая версия (500 vs 200).
|
|||
|
|
adminOnly := r.Group("/")
|
|||
|
|
adminOnly.Use(auth.Middleware(jwtSecret))
|
|||
|
|
adminOnly.Use(auth.RequireRole("admin"))
|
|||
|
|
adminOnly.GET("/db-ping", func(c *gin.Context) {
|
|||
|
|
if err := pool.Ping(c.Request.Context()); err != nil {
|
|||
|
|
// Детали (host, user, DSN) наружу не уходят — только в лог.
|
|||
|
|
logger.Error("db ping failed", "err", err, "request_id", middleware.RequestIDFrom(c))
|
|||
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "db unavailable"})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.JSON(http.StatusOK, gin.H{"db": "ok"})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Auth — открытые роуты (лимитируются, чтобы логин нельзя было перебирать)
|
|||
|
|
r.POST("/auth/login",
|
|||
|
|
ratelimit.Middleware(loginLimiter, ratelimit.ByIP, rateLimitMsg),
|
|||
|
|
authHandler.Login)
|
|||
|
|
r.POST("/auth/refresh",
|
|||
|
|
ratelimit.Middleware(refreshLimiter, ratelimit.ByIP, rateLimitMsg),
|
|||
|
|
authHandler.Refresh)
|
|||
|
|
r.POST("/auth/logout",
|
|||
|
|
ratelimit.Middleware(refreshLimiter, ratelimit.ByIP, rateLimitMsg),
|
|||
|
|
authHandler.Logout)
|
|||
|
|
|
|||
|
|
// Сброс пароля: письмо со ссылкой запрашивается здесь (лимит IP +
|
|||
|
|
// лимит на ящик в самом хендлере), токен применяется ниже.
|
|||
|
|
// Лимит отдельный от логина: подбор токена должен упереться
|
|||
|
|
// в потолок раньше, чем в чужую сессию.
|
|||
|
|
r.POST("/auth/forgot-password",
|
|||
|
|
ratelimit.Middleware(forgotLimiter, ratelimit.ByIP, rateLimitMsg),
|
|||
|
|
authHandler.ForgotPassword)
|
|||
|
|
r.POST("/auth/reset-password",
|
|||
|
|
ratelimit.Middleware(resetLimiter, ratelimit.ByIP, rateLimitMsg),
|
|||
|
|
authHandler.ResetPassword)
|
|||
|
|
|
|||
|
|
// Защищённые роуты — любой авторизованный
|
|||
|
|
authGroup := r.Group("/")
|
|||
|
|
authGroup.Use(auth.Middleware(jwtSecret))
|
|||
|
|
{
|
|||
|
|
authGroup.GET("/auth/me", authHandler.Me)
|
|||
|
|
authGroup.POST("/auth/logout-all", authHandler.LogoutAll)
|
|||
|
|
|
|||
|
|
// Привязка Telegram для уведомлений: свой статус, одноразовый
|
|||
|
|
// код привязки, ручной ввод группы/канала, отключение — плюс
|
|||
|
|
// собственный бот пользователя (токен хранится зашифрованным,
|
|||
|
|
// наружу не отдаётся; см. internal/crypt).
|
|||
|
|
authGroup.GET("/auth/telegram", tgHandler.Status)
|
|||
|
|
authGroup.POST("/auth/telegram/code", tgHandler.IssueCode)
|
|||
|
|
authGroup.PUT("/auth/telegram", tgHandler.Set)
|
|||
|
|
authGroup.DELETE("/auth/telegram", tgHandler.Unbind)
|
|||
|
|
authGroup.POST("/auth/telegram/bot", tgHandler.SetBot)
|
|||
|
|
authGroup.DELETE("/auth/telegram/bot", tgHandler.DeleteBot)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Проекты клиентов: объекты внутри проекта и мастера проекта.
|
|||
|
|
// Роли проверяются внутри хендлера (админ — всё, клиент — своё);
|
|||
|
|
// удаление проекта вынесено на RequireRole("admin") в роуте.
|
|||
|
|
projectGroup := r.Group("/projects")
|
|||
|
|
projectGroup.Use(auth.Middleware(jwtSecret))
|
|||
|
|
{
|
|||
|
|
projectGroup.GET("", projectHandler.List)
|
|||
|
|
projectGroup.POST("", projectHandler.Create)
|
|||
|
|
projectGroup.GET("/:id", projectHandler.Get)
|
|||
|
|
projectGroup.PATCH("/:id", projectHandler.Update)
|
|||
|
|
// Удаление проекта — только админ (решение): проект уходит
|
|||
|
|
// вместе с заявками, объектами и участиями (каскад 0013).
|
|||
|
|
projectGroup.DELETE("/:id", auth.RequireRole("admin"), projectHandler.Delete)
|
|||
|
|
|
|||
|
|
projectGroup.GET("/:id/objects", projectHandler.ListObjects)
|
|||
|
|
projectGroup.POST("/:id/objects", projectHandler.CreateObject)
|
|||
|
|
|
|||
|
|
projectGroup.GET("/:id/workers", projectHandler.ListWorkers)
|
|||
|
|
projectGroup.POST("/:id/workers", projectHandler.AddWorker)
|
|||
|
|
projectGroup.DELETE("/:id/workers/:worker_id", projectHandler.RemoveWorker)
|
|||
|
|
|
|||
|
|
// Приглашения: решает только сам мастер (роль worker).
|
|||
|
|
projectGroup.POST("/:id/invitation/accept", auth.RequireRole("worker"), projectHandler.AcceptInvitation)
|
|||
|
|
projectGroup.POST("/:id/invitation/decline", auth.RequireRole("worker"), projectHandler.DeclineInvitation)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Мои приглашения: каждый мастер видит только своё (worker_id берётся
|
|||
|
|
// из токена, не из запроса).
|
|||
|
|
invitationsGroup := r.Group("/invitations")
|
|||
|
|
invitationsGroup.Use(auth.Middleware(jwtSecret))
|
|||
|
|
invitationsGroup.GET("", auth.RequireRole("worker"), projectHandler.ListInvitations)
|
|||
|
|
|
|||
|
|
// Правка объекта вынесена на свой путь: /objects/:id, те же права.
|
|||
|
|
objectGroup := r.Group("/objects")
|
|||
|
|
objectGroup.Use(auth.Middleware(jwtSecret))
|
|||
|
|
{
|
|||
|
|
objectGroup.PATCH("/:id", projectHandler.UpdateObject)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ticketGroup := r.Group("/tickets")
|
|||
|
|
ticketGroup.Use(auth.Middleware(jwtSecret))
|
|||
|
|
{
|
|||
|
|
// Создавать могут только клиенты
|
|||
|
|
ticketGroup.POST("", auth.RequireRole("client"), ticketHandler.Create)
|
|||
|
|
|
|||
|
|
// Список и детали — все авторизованные, но фильтруются по роли внутри
|
|||
|
|
ticketGroup.GET("", ticketHandler.List)
|
|||
|
|
ticketGroup.GET("/:id", ticketHandler.Get)
|
|||
|
|
// Журнал — те же права, что на чтение заявки (проверка внутри).
|
|||
|
|
ticketGroup.GET("/:id/events", ticketHandler.Events)
|
|||
|
|
|
|||
|
|
// Фотоотчёт: список видят все, кто видит заявку; загружает и
|
|||
|
|
// удаляет мастер или админ — дальше те же правила проверяются
|
|||
|
|
// в canView и в самом SQL (статус, членство, лимит).
|
|||
|
|
ticketGroup.GET("/:id/photos", photoHandler.List)
|
|||
|
|
ticketGroup.POST("/:id/photos", auth.RequireRole("worker", "admin"), photoHandler.Upload)
|
|||
|
|
ticketGroup.DELETE("/:id/photos/:photo_id", auth.RequireRole("worker", "admin"), photoHandler.Delete)
|
|||
|
|
|
|||
|
|
// Назначает админ или клиент-владелец, либо сам мастер берёт
|
|||
|
|
// свободную заявку (take) — дальше проверка внутри и в SQL.
|
|||
|
|
ticketGroup.PATCH("/:id/assign", auth.RequireRole("admin", "client"), ticketHandler.Assign)
|
|||
|
|
ticketGroup.PATCH("/:id/take", auth.RequireRole("worker"), ticketHandler.Take)
|
|||
|
|
ticketGroup.PATCH("/:id/start", auth.RequireRole("worker"), ticketHandler.Start)
|
|||
|
|
ticketGroup.PATCH("/:id/done", auth.RequireRole("worker"), ticketHandler.Done)
|
|||
|
|
|
|||
|
|
// Закрытие цикла: принимаем / отклоняем (на доработку) / отменяем
|
|||
|
|
ticketGroup.PATCH("/:id/confirm", auth.RequireRole("client"), ticketHandler.Confirm)
|
|||
|
|
ticketGroup.PATCH("/:id/reject", auth.RequireRole("client"), ticketHandler.Reject)
|
|||
|
|
ticketGroup.PATCH("/:id/cancel", auth.RequireRole("client", "admin"), ticketHandler.Cancel)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Повторяющиеся заявки: правила периодических работ («осмотр
|
|||
|
|
// вентиляции раз в 3 месяца»). Роли — админ и клиент; чужое правило
|
|||
|
|
// неотличимо от несуществующего (проверка внутри хендлера).
|
|||
|
|
recurringGroup := r.Group("/recurring")
|
|||
|
|
recurringGroup.Use(auth.Middleware(jwtSecret))
|
|||
|
|
{
|
|||
|
|
recurringGroup.GET("", auth.RequireRole("admin", "client"), recurringHandler.List)
|
|||
|
|
recurringGroup.POST("", auth.RequireRole("admin", "client"), recurringHandler.Create)
|
|||
|
|
recurringGroup.GET("/:id", auth.RequireRole("admin", "client"), recurringHandler.Get)
|
|||
|
|
recurringGroup.PATCH("/:id", auth.RequireRole("admin", "client"), recurringHandler.Update)
|
|||
|
|
recurringGroup.DELETE("/:id", auth.RequireRole("admin", "client"), recurringHandler.Delete)
|
|||
|
|
// Ручной запуск: заявка сразу, без ожидания интервала (иначе — 409).
|
|||
|
|
recurringGroup.POST("/:id/run", auth.RequireRole("admin", "client"), recurringHandler.Run)
|
|||
|
|
// История срабатываний: какая заявка создавалась по правилу и когда.
|
|||
|
|
recurringGroup.GET("/:id/runs", auth.RequireRole("admin", "client"), recurringHandler.Runs)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Пользователи: создание и список открыты админу и клиенту
|
|||
|
|
// (клиенту — только мастера и только свои), удаление — только админу,
|
|||
|
|
// деактивация — админу и клиенту (клиенту — только свой мастер).
|
|||
|
|
usersGroup := r.Group("/users")
|
|||
|
|
usersGroup.Use(auth.Middleware(jwtSecret))
|
|||
|
|
{
|
|||
|
|
usersGroup.POST("", auth.RequireRole("admin", "client"), userHandler.Create)
|
|||
|
|
usersGroup.GET("", auth.RequireRole("admin", "client"), userHandler.List)
|
|||
|
|
// Подтверждение аккаунта: админ — любой неподтверждённый, клиент —
|
|||
|
|
// только созданного им мастера (право целиком в SQL).
|
|||
|
|
usersGroup.POST("/:id/confirm", auth.RequireRole("admin", "client"), userHandler.Confirm)
|
|||
|
|
usersGroup.PATCH("/:id", auth.RequireRole("admin", "client"), userHandler.Update)
|
|||
|
|
usersGroup.DELETE("/:id", auth.RequireRole("admin", "client"), userHandler.Delete)
|
|||
|
|
// Токен сброса пароля — только админ: он и передаст его пользователю.
|
|||
|
|
usersGroup.POST("/:id/password-reset", auth.RequireRole("admin"), userHandler.PasswordReset)
|
|||
|
|
// Прямая установка пароля админом — та же граница: пароль меняется
|
|||
|
|
// без участия пользователя, сессии при этом закрываются.
|
|||
|
|
usersGroup.POST("/:id/password-set", auth.RequireRole("admin"), userHandler.SetPassword)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
port := os.Getenv("PORT")
|
|||
|
|
if port == "" {
|
|||
|
|
port = "8080"
|
|||
|
|
}
|
|||
|
|
addr := ":" + port
|
|||
|
|
|
|||
|
|
// Таймауты — защита от Slowloris и висящих соединений: заголовки
|
|||
|
|
// должны прийти быстро, тело и ответ — с запасом на загрузку
|
|||
|
|
// фотоотчёта (PHOTO_MAX_BYTES, по умолчанию 10 МБ).
|
|||
|
|
srv := &http.Server{
|
|||
|
|
Addr: addr,
|
|||
|
|
Handler: r,
|
|||
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|||
|
|
ReadTimeout: 60 * time.Second,
|
|||
|
|
WriteTimeout: 60 * time.Second,
|
|||
|
|
IdleTimeout: 120 * time.Second,
|
|||
|
|
}
|
|||
|
|
errCh := make(chan error, 1)
|
|||
|
|
go func() { errCh <- srv.ListenAndServe() }()
|
|||
|
|
logger.Info("listening", "addr", addr)
|
|||
|
|
|
|||
|
|
// SIGTERM (деплой) и SIGINT (Ctrl+C) — оба повод остановиться
|
|||
|
|
// аккуратно: дождаться текущих запросов, закрыть пул.
|
|||
|
|
quit := make(chan os.Signal, 1)
|
|||
|
|
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
|||
|
|
|
|||
|
|
select {
|
|||
|
|
case err := <-errCh:
|
|||
|
|
if !errors.Is(err, http.ErrServerClosed) {
|
|||
|
|
logger.Error("server failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
return 0
|
|||
|
|
case sig := <-quit:
|
|||
|
|
logger.Info("shutdown signal received", "signal", sig.String())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|||
|
|
defer cancel()
|
|||
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|||
|
|
logger.Error("graceful shutdown failed", "err", err)
|
|||
|
|
return 1
|
|||
|
|
}
|
|||
|
|
<-errCh // ListenAndServe вернул ErrServerClosed
|
|||
|
|
logger.Info("server stopped")
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// runTicketsCleanup — фоновая чистка закрытых заявок старше retention:
|
|||
|
|
// прогон сразу при старте и дальше каждые сутки, пока жив контекст.
|
|||
|
|
// Файлы в хранилище удаляются после строк best-effort: сбой чистки
|
|||
|
|
// объекта уходит в лог, строка в БД уже удалена — порядок «сначала
|
|||
|
|
// строка, потом объект», как в чистке фотоотчёта.
|
|||
|
|
func runTicketsCleanup(ctx context.Context, logger *slog.Logger,
|
|||
|
|
repo *repository.TicketRepo, store *storage.S3Store, retention time.Duration,
|
|||
|
|
) {
|
|||
|
|
cleanup := func() {
|
|||
|
|
ids, keys, err := repo.DeleteExpired(ctx, time.Now().Add(-retention))
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warn("tickets cleanup failed", "err", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
photos := 0
|
|||
|
|
for _, key := range keys {
|
|||
|
|
if err := store.Delete(ctx, key); err != nil {
|
|||
|
|
logger.Warn("photo object cleanup failed", "key", key, "err", err)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
photos++
|
|||
|
|
}
|
|||
|
|
if len(ids) > 0 {
|
|||
|
|
logger.Info("tickets cleanup", "deleted", len(ids), "photo_objects", photos)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cleanup()
|
|||
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|||
|
|
defer ticker.Stop()
|
|||
|
|
for {
|
|||
|
|
select {
|
|||
|
|
case <-ticker.C:
|
|||
|
|
cleanup()
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// notifyRecurring — уведомление клиенту о заявке, созданной по правилу
|
|||
|
|
// повторения (фоновым планировщиком или ручным «Запустить сейчас»):
|
|||
|
|
// один текст и одна точка отказа на двоих. Fail-open, как в notify:
|
|||
|
|
// созданная заявка остаётся созданной, даже если Telegram недоступен —
|
|||
|
|
// ошибка уходит в warn, а напоминание уйдёт штатным нотификатором позже.
|
|||
|
|
func notifyRecurring(ctx context.Context, logger *slog.Logger,
|
|||
|
|
bots notify.Bots, baseURL string, run repository.DueRun,
|
|||
|
|
) {
|
|||
|
|
if run.Target == "" {
|
|||
|
|
return // слать некуда — уведомит штатный нотификатор
|
|||
|
|
}
|
|||
|
|
bot := bots.BotFor(run.ClientID)
|
|||
|
|
if bot == nil || !bot.Enabled() {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := bot.SendMessage(ctx, run.Target, notify.RecurringMessage(run, baseURL)); err != nil {
|
|||
|
|
logger.Warn("recurring notify failed", "rule_id", run.RuleID,
|
|||
|
|
"ticket_id", run.TicketID, "err", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// runRecurring — фоновый планировщик повторяющихся заявок (миграция 0014):
|
|||
|
|
// прогон сразу при старте и дальше каждые сутки, пока жив контекст.
|
|||
|
|
// Интервалы в месяцах, поэтому суточного тика хватает.
|
|||
|
|
//
|
|||
|
|
// Уведомления шлётся общим хелпером notifyRecurring — fail-open,
|
|||
|
|
// как в notify.
|
|||
|
|
func runRecurring(ctx context.Context, logger *slog.Logger,
|
|||
|
|
repo *repository.RecurringRepo, bots notify.Bots, baseURL string,
|
|||
|
|
) {
|
|||
|
|
run := func() {
|
|||
|
|
created, err := repo.RunDueRules(ctx, time.Now())
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warn("recurring rules run failed", "err", err)
|
|||
|
|
}
|
|||
|
|
if len(created) == 0 {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
logger.Info("recurring tickets created", "count", len(created))
|
|||
|
|
for _, t := range created {
|
|||
|
|
if ctx.Err() != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
notifyRecurring(ctx, logger, bots, baseURL, t)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
run()
|
|||
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|||
|
|
defer ticker.Stop()
|
|||
|
|
for {
|
|||
|
|
select {
|
|||
|
|
case <-ticker.C:
|
|||
|
|
run()
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|