diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b01d17f --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +SERVER_PORT=8080 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/aegisguard?sslmode=disable +JWT_SECRET=change_me_to_a_random_secret_at_least_32_chars +JWT_EXPIRATION=24h +JWT_REFRESH_EXPIRATION=168h diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..1d87a18 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci + +on: + push: + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Go setup + uses: actions/setup-go@v6 + with: + go-version: "1.26.1" + cache: false + - name: Install deps + run: go mod tidy + - name: Golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + args: --timeout=5m + skip-cache: true + - name: Run tests + run: go test ./... + - name: Build + run: go build ./cmd/backend diff --git a/README.md b/README.md index baae514..682806d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,155 @@ -# Control-plane +# Control-plane API + +## Аутентификация + +### POST /api/auth/register + +```json +{ + "username": "john", + "email": "john@example.com", + "password": "Secret123" +} +``` + +| Поле | Описание | +|------|----------| +| `username` | 3–30 символов | +| `email` | Валидный email | +| `password` | От 8 символов, заглавная + строчная + цифра | + +`201` — `{ "token": "...", "refresh_token": "...", "user": { "id": "...", "username": "...", "email": "...", "created_at": "..." } }` +`400` — ошибка валидации +`409` — email уже занят + +### POST /api/auth/login + +```json +{ "email": "john@example.com", "password": "Secret123" } +``` + +`200` — `{ "token": "...", "refresh_token": "...", "user": { ... } }` +`401` — неверный email или пароль +`429` — превышен лимит (10 попыток/мин с IP) + +### POST /api/auth/refresh + +Обновить токены. Старый refresh_token удаляется, выдаётся новая пара. + +```json +{ "refresh_token": "..." } +``` + +`200` — `{ "token": "...", "refresh_token": "...", "user": { ... } }` +`401` — токен невалиден или умер + +### POST /api/auth/logout + +Удалить refresh_token из БД. + +```json +{ "refresh_token": "..." } +``` + +`200` — `{ "message": "logged out successfully" }` + +--- + +## Защищённые (требуют Bearer-токен) + +Заголовок: `Authorization: Bearer ` + +### GET /api/auth/me + +Профиль текущего пользователя. + +`200` — `{ "user": { "id": "...", "username": "...", "email": "...", "created_at": "..." } }` + +### PUT /api/auth/me + +Обновить username. + +```json +{ "username": "john_updated" } +``` + +`200` — `{ "user": { "id": "...", "username": "john_updated", ... } }` + +### PUT /api/auth/password + +Сменить пароль. Старый пароль обязателен для подтверждения. + +```json +{ "old_password": "Secret123", "new_password": "NewSecret456!" } +``` + +`200` — `{ "message": "password changed successfully" }` +`400` — неверный старый пароль, слабый новый или совпадают + +--- + +## Организации (все Bearer) + +### POST /api/organizations + +```json +{ "name": "My Corp", "slug": "my-corp" } +``` + +`201` — `{ "organization": { "id": "...", "name": "My Corp", "slug": "my-corp", "created_at": "...", "updated_at": "..." } }` +`409` — slug уже занят + +### GET /api/organizations + +`200` — `{ "organizations": [...], "total": 1 }` + +### GET /api/organizations/:id + +`200` — `{ "organization": { ... } }` +`404` — не найдена + +### PUT /api/organizations/:id + +```json +{ "name": "Updated Corp" } +``` + +`200` — `{ "organization": { ... } }` + +### DELETE /api/organizations/:id + +`200` — `{ "message": "organization deleted" }` + +--- + +## Формат JWT + +```json +{ + "user_id": "uuid", + "email": "john@example.com", + "sub": "uuid", + "exp": 1718000000, + "iat": 1717913600 +} +``` + +- `user_id` / `sub` — UUID пользователя +- `exp` — timestamp истечения (24ч) +- `iat` — timestamp выпуска + +## Ошибки + +```json +{ "error": "описание" } +``` + +| Статус | Описание | +|--------|----------| +| 400 | Ошибка валидации | +| 401 | Неверные данные, токен протух или невалиден | +| 404 | Пользователь или организация не найдены | +| 409 | Email или slug уже заняты | +| 429 | Превышен лимит попыток логина | +| 500 | Внутренняя ошибка | diff --git a/cmd/backend/main.go b/cmd/backend/main.go index e887927..0c743dd 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1,28 +1,152 @@ package main import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + docs "gitea.d3m0k1d.ru/HellreigN/Control-plane/docs" + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/auth" + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/config" + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/org" "github.com/gin-gonic/gin" - "github.com/swaggo/files" - "github.com/swaggo/gin-swagger" + "github.com/pressly/goose/v3" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + "gorm.io/driver/postgres" + "gorm.io/gorm" ) +// @title AegisGuard API +// @version 1.0 +// @description API системы управления AegisGuard. Позволяет управлять пользователями и организациями. +// @description Все защищённые эндпоинты требуют заголовок `Authorization: Bearer `. +// @description Токен получается при регистрации или входе. +// @schemes http +// @BasePath /api/v1 +// // @securityDefinitions.apikey Bearer // @in header // @name Authorization -// @description Type "Bearer" followed by a space and the JWT token. +// @description Введите `Bearer `, где token — access_token из ответа /auth/login или /auth/register func main() { - r := gin.Default() + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect to postgres: %v", err) + } + + sqlDB, err := gormDB.DB() + if err != nil { + log.Fatalf("failed to get underlying sql.DB: %v", err) + } + + if err := sqlDB.PingContext(ctx); err != nil { + log.Fatalf("failed to ping postgres: %v", err) + } + log.Println("connected to postgres") + + if err := goose.Up(sqlDB, "migrations"); err != nil { + log.Fatalf("failed to run migrations: %v", err) + } + log.Println("migrations applied") + + repo := auth.NewRepository(gormDB) + orgRepo := org.NewRepository(gormDB) + + svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration, cfg.JWTRefreshExpiration) + handler := auth.NewHandler(svc) + + orgSvc := org.NewService(orgRepo) + orgHandler := org.NewHandler(orgSvc) + + authMW := auth.AuthMiddleware([]byte(cfg.JWTSecret)) + + go func() { + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + for range ticker.C { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := repo.DeleteExpiredRefreshTokens(cleanupCtx); err != nil { + log.Printf("failed to cleanup expired tokens: %v", err) + } + cleanupCancel() + } + }() + + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Logger(), gin.Recovery()) + docs.SwaggerInfo.Title = "AegisGuard API" docs.SwaggerInfo.Version = "1.0" - docs.SwaggerInfo.Description = "API for AegisGuard" + docs.SwaggerInfo.Description = "API системы управления AegisGuard. Позволяет управлять пользователями и организациями." docs.SwaggerInfo.Schemes = []string{"http"} + docs.SwaggerInfo.BasePath = "/api/v1" r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) r.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "ok", - }) + c.JSON(200, gin.H{"status": "ok"}) }) - r.Run(":8080") + + api := r.Group("/api/v1/auth") + { + api.POST("/register", handler.Register) + api.POST("/login", handler.Login) + api.POST("/refresh", handler.Refresh) + api.POST("/logout", handler.Logout) + api.GET("/me", authMW, handler.Me) + api.PUT("/me", authMW, handler.UpdateProfile) + api.PUT("/password", authMW, handler.ChangePassword) + } + + orgs := r.Group("/api/v1/organizations", authMW) + { + orgs.POST("", orgHandler.Create) + orgs.GET("", orgHandler.List) + orgs.GET("/:id", orgHandler.GetByID) + orgs.PUT("/:id", orgHandler.Update) + orgs.DELETE("/:id", orgHandler.Delete) + } + + srv := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: r, + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("server starting on :%s", cfg.ServerPort) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("failed to start server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutting down server...") + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Fatalf("server forced to shutdown: %v", err) + } + + _ = sqlDB.Close() + + log.Println("server stopped") } diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..63c41a7 --- /dev/null +++ b/dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26.1 as builder + +WORKDIR /app + +COPY . . +ENV CGO_ENABLED=0 +ENV GIN_MODE=release +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go mod download && \ + go build -ldflags "-s -w" -o backend ./cmd/main.go + +FROM alpine:3.23.0 + +RUN apk add --no-cache curl openssl bash + +COPY --from=builder /app/backend . + +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "curl --fail http://localhost:8080/health" ] + +CMD ["./backend"] diff --git a/docs/docs.go b/docs/docs.go index a91df67..a7a108f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -14,10 +14,816 @@ const docTemplate = `{ }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", - "paths": {}, + "paths": { + "/api/v1/auth/login": { + "post": { + "description": "Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Вход", + "parameters": [ + { + "description": "Email и пароль", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "Успешный вход, токены в ответе", + "schema": { + "$ref": "#/definitions/auth.AuthResponse" + } + }, + "400": { + "description": "Ошибка валидации полей", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Неверный email или пароль", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "description": "Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выход", + "parameters": [ + { + "description": "Refresh_token для аннулирования", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "{\"message\": \"logged out successfully\"}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Не указан refresh_token", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Refresh_token не найден или уже аннулирован", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Получение профиля текущего авторизованного пользователя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Профиль пользователя", + "responses": { + "200": { + "description": "Данные пользователя", + "schema": { + "$ref": "#/definitions/auth.UserResponse" + } + }, + "401": { + "description": "Токен не указан или недействителен", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Обновление username текущего пользователя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Обновление профиля", + "parameters": [ + { + "description": "Новый username", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "Обновлённый профиль", + "schema": { + "$ref": "#/definitions/auth.UserResponse" + } + }, + "400": { + "description": "Ошибка валидации: username от 3 до 30 символов", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Токен не указан или недействителен", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/password": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Смена пароля", + "parameters": [ + { + "description": "Старый и новый пароль", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.PasswordChangeRequest" + } + } + ], + "responses": { + "200": { + "description": "{\"message\": \"password changed successfully\"}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Ошибка валидации: неверный старый пароль, слабый новый или совпадают", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Токен не указан или недействителен", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).\nЕсли refresh_token истёк или уже был использован — придёт 401.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Обновление токенов", + "parameters": [ + { + "description": "Действительный refresh_token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "Новая пара токенов", + "schema": { + "$ref": "#/definitions/auth.AuthResponse" + } + }, + "400": { + "description": "Не указан refresh_token", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Refresh_token недействителен или истёк", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Регистрация", + "parameters": [ + { + "description": "Данные для регистрации", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Пользователь создан, токены в ответе", + "schema": { + "$ref": "#/definitions/auth.AuthResponse" + } + }, + "400": { + "description": "Ошибка валидации полей (некорректный email, слабый пароль)", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "409": { + "description": "Email уже зарегистрирован", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/organizations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Получение списка всех организаций с пагинацией.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Список организаций", + "parameters": [ + { + "type": "integer", + "description": "Количество записей на странице (по умолчанию 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Смещение от начала списка (по умолчанию 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Список организаций", + "schema": { + "$ref": "#/definitions/org.OrgListResponse" + } + }, + "500": { + "description": "Внутренняя ошибка сервера", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Создание новой организации. slug используется в URL и должен быть уникальным.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Создание организации", + "parameters": [ + { + "description": "Название и slug организации", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.CreateOrgRequest" + } + } + ], + "responses": { + "201": { + "description": "Организация создана", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Ошибка валидации полей", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "409": { + "description": "Slug уже занят", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + } + }, + "/api/v1/organizations/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Получение информации об организации по её ID.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Получить организацию", + "parameters": [ + { + "type": "string", + "description": "UUID организации", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Данные организации", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "404": { + "description": "Организация не найдена", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Обновление названия организации. slug изменить нельзя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Обновление организации", + "parameters": [ + { + "type": "string", + "description": "UUID организации", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Новое название организации", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.UpdateOrgRequest" + } + } + ], + "responses": { + "200": { + "description": "Обновлённая организация", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Ошибка валидации полей", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "404": { + "description": "Организация не найдена", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Безвозвратное удаление организации по её ID.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Удаление организации", + "parameters": [ + { + "type": "string", + "description": "UUID организации", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"message\": \"organization deleted\"}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Организация не найдена", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "auth.AuthResponse": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + }, + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIs..." + }, + "user": { + "$ref": "#/definitions/auth.UserPublic" + } + } + }, + "auth.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid email or password" + } + } + }, + "auth.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "example": "secret123" + } + } + }, + "auth.LogoutRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, + "auth.PasswordChangeRequest": { + "type": "object", + "required": [ + "new_password", + "old_password" + ], + "properties": { + "new_password": { + "type": "string", + "minLength": 8, + "example": "NewSecret456!" + }, + "old_password": { + "type": "string", + "example": "Secret123!" + } + } + }, + "auth.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, + "auth.RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "minLength": 8, + "example": "Secret123!" + }, + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john" + } + } + }, + "auth.UpdateProfileRequest": { + "type": "object", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john_updated" + } + } + }, + "auth.UserPublic": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "auth.UserResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/auth.UserPublic" + } + } + }, + "org.CreateOrgRequest": { + "type": "object", + "required": [ + "name", + "slug" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "My Corp" + }, + "slug": { + "type": "string", + "maxLength": 50, + "minLength": 2, + "example": "my-corp" + } + } + }, + "org.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "org.OrgListResponse": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "organizations": { + "type": "array", + "items": { + "$ref": "#/definitions/org.Organization" + } + }, + "total": { + "type": "integer" + } + } + }, + "org.OrgResponse": { + "type": "object", + "properties": { + "organization": { + "$ref": "#/definitions/org.Organization" + } + } + }, + "org.Organization": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "org.UpdateOrgRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "My Corp Updated" + } + } + } + }, "securityDefinitions": { "Bearer": { - "description": "Type \"Bearer\" followed by a space and the JWT token.", + "description": "Введите ` + "`" + `Bearer \u003ctoken\u003e` + "`" + `, где token — access_token из ответа /auth/login или /auth/register", "type": "apiKey", "name": "Authorization", "in": "header" @@ -27,12 +833,12 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "", + Version: "1.0", Host: "", - BasePath: "", - Schemes: []string{}, - Title: "", - Description: "", + BasePath: "/api/v1", + Schemes: []string{"http"}, + Title: "AegisGuard API", + Description: "API системы управления AegisGuard. Позволяет управлять пользователями и организациями.\nВсе защищённые эндпоинты требуют заголовок `Authorization: Bearer `.\nТокен получается при регистрации или входе.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 5173354..d9731e7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,12 +1,825 @@ { + "schemes": [ + "http" + ], "swagger": "2.0", "info": { - "contact": {} + "description": "API системы управления AegisGuard. Позволяет управлять пользователями и организациями.\nВсе защищённые эндпоинты требуют заголовок `Authorization: Bearer \u003ctoken\u003e`.\nТокен получается при регистрации или входе.", + "title": "AegisGuard API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/api/v1", + "paths": { + "/api/v1/auth/login": { + "post": { + "description": "Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Вход", + "parameters": [ + { + "description": "Email и пароль", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "Успешный вход, токены в ответе", + "schema": { + "$ref": "#/definitions/auth.AuthResponse" + } + }, + "400": { + "description": "Ошибка валидации полей", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Неверный email или пароль", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "description": "Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Выход", + "parameters": [ + { + "description": "Refresh_token для аннулирования", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "{\"message\": \"logged out successfully\"}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Не указан refresh_token", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Refresh_token не найден или уже аннулирован", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Получение профиля текущего авторизованного пользователя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Профиль пользователя", + "responses": { + "200": { + "description": "Данные пользователя", + "schema": { + "$ref": "#/definitions/auth.UserResponse" + } + }, + "401": { + "description": "Токен не указан или недействителен", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Обновление username текущего пользователя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Обновление профиля", + "parameters": [ + { + "description": "Новый username", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "Обновлённый профиль", + "schema": { + "$ref": "#/definitions/auth.UserResponse" + } + }, + "400": { + "description": "Ошибка валидации: username от 3 до 30 символов", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Токен не указан или недействителен", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/password": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Смена пароля", + "parameters": [ + { + "description": "Старый и новый пароль", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.PasswordChangeRequest" + } + } + ], + "responses": { + "200": { + "description": "{\"message\": \"password changed successfully\"}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Ошибка валидации: неверный старый пароль, слабый новый или совпадают", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Токен не указан или недействителен", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).\nЕсли refresh_token истёк или уже был использован — придёт 401.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Обновление токенов", + "parameters": [ + { + "description": "Действительный refresh_token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "Новая пара токенов", + "schema": { + "$ref": "#/definitions/auth.AuthResponse" + } + }, + "400": { + "description": "Не указан refresh_token", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Refresh_token недействителен или истёк", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Регистрация", + "parameters": [ + { + "description": "Данные для регистрации", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Пользователь создан, токены в ответе", + "schema": { + "$ref": "#/definitions/auth.AuthResponse" + } + }, + "400": { + "description": "Ошибка валидации полей (некорректный email, слабый пароль)", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "409": { + "description": "Email уже зарегистрирован", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/v1/organizations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Получение списка всех организаций с пагинацией.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Список организаций", + "parameters": [ + { + "type": "integer", + "description": "Количество записей на странице (по умолчанию 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Смещение от начала списка (по умолчанию 0)", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Список организаций", + "schema": { + "$ref": "#/definitions/org.OrgListResponse" + } + }, + "500": { + "description": "Внутренняя ошибка сервера", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Создание новой организации. slug используется в URL и должен быть уникальным.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Создание организации", + "parameters": [ + { + "description": "Название и slug организации", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.CreateOrgRequest" + } + } + ], + "responses": { + "201": { + "description": "Организация создана", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Ошибка валидации полей", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "409": { + "description": "Slug уже занят", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + } + }, + "/api/v1/organizations/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Получение информации об организации по её ID.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Получить организацию", + "parameters": [ + { + "type": "string", + "description": "UUID организации", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Данные организации", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "404": { + "description": "Организация не найдена", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Обновление названия организации. slug изменить нельзя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Обновление организации", + "parameters": [ + { + "type": "string", + "description": "UUID организации", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Новое название организации", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.UpdateOrgRequest" + } + } + ], + "responses": { + "200": { + "description": "Обновлённая организация", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Ошибка валидации полей", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "404": { + "description": "Организация не найдена", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Безвозвратное удаление организации по её ID.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Удаление организации", + "parameters": [ + { + "type": "string", + "description": "UUID организации", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "{\"message\": \"organization deleted\"}", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Организация не найдена", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "auth.AuthResponse": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + }, + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIs..." + }, + "user": { + "$ref": "#/definitions/auth.UserPublic" + } + } + }, + "auth.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid email or password" + } + } + }, + "auth.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "example": "secret123" + } + } + }, + "auth.LogoutRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, + "auth.PasswordChangeRequest": { + "type": "object", + "required": [ + "new_password", + "old_password" + ], + "properties": { + "new_password": { + "type": "string", + "minLength": 8, + "example": "NewSecret456!" + }, + "old_password": { + "type": "string", + "example": "Secret123!" + } + } + }, + "auth.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, + "auth.RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "minLength": 8, + "example": "Secret123!" + }, + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john" + } + } + }, + "auth.UpdateProfileRequest": { + "type": "object", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john_updated" + } + } + }, + "auth.UserPublic": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "auth.UserResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/auth.UserPublic" + } + } + }, + "org.CreateOrgRequest": { + "type": "object", + "required": [ + "name", + "slug" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "My Corp" + }, + "slug": { + "type": "string", + "maxLength": 50, + "minLength": 2, + "example": "my-corp" + } + } + }, + "org.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + }, + "org.OrgListResponse": { + "type": "object", + "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "organizations": { + "type": "array", + "items": { + "$ref": "#/definitions/org.Organization" + } + }, + "total": { + "type": "integer" + } + } + }, + "org.OrgResponse": { + "type": "object", + "properties": { + "organization": { + "$ref": "#/definitions/org.Organization" + } + } + }, + "org.Organization": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "org.UpdateOrgRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "My Corp Updated" + } + } + } }, - "paths": {}, "securityDefinitions": { "Bearer": { - "description": "Type \"Bearer\" followed by a space and the JWT token.", + "description": "Введите `Bearer \u003ctoken\u003e`, где token — access_token из ответа /auth/login или /auth/register", "type": "apiKey", "name": "Authorization", "in": "header" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0dbe493..b2b3947 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,9 +1,569 @@ +basePath: /api/v1 +definitions: + auth.AuthResponse: + properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string + token: + example: eyJhbGciOiJIUzI1NiIs... + type: string + user: + $ref: '#/definitions/auth.UserPublic' + type: object + auth.ErrorResponse: + properties: + error: + example: invalid email or password + type: string + type: object + auth.LoginRequest: + properties: + email: + example: john@example.com + type: string + password: + example: secret123 + type: string + required: + - email + - password + type: object + auth.LogoutRequest: + properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string + required: + - refresh_token + type: object + auth.PasswordChangeRequest: + properties: + new_password: + example: NewSecret456! + minLength: 8 + type: string + old_password: + example: Secret123! + type: string + required: + - new_password + - old_password + type: object + auth.RefreshRequest: + properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string + required: + - refresh_token + type: object + auth.RegisterRequest: + properties: + email: + example: john@example.com + type: string + password: + example: Secret123! + minLength: 8 + type: string + username: + example: john + maxLength: 30 + minLength: 3 + type: string + required: + - email + - password + - username + type: object + auth.UpdateProfileRequest: + properties: + username: + example: john_updated + maxLength: 30 + minLength: 3 + type: string + required: + - username + type: object + auth.UserPublic: + properties: + created_at: + type: string + email: + type: string + id: + type: string + username: + type: string + type: object + auth.UserResponse: + properties: + user: + $ref: '#/definitions/auth.UserPublic' + type: object + org.CreateOrgRequest: + properties: + name: + example: My Corp + maxLength: 100 + minLength: 2 + type: string + slug: + example: my-corp + maxLength: 50 + minLength: 2 + type: string + required: + - name + - slug + type: object + org.ErrorResponse: + properties: + error: + type: string + type: object + org.OrgListResponse: + properties: + limit: + type: integer + offset: + type: integer + organizations: + items: + $ref: '#/definitions/org.Organization' + type: array + total: + type: integer + type: object + org.OrgResponse: + properties: + organization: + $ref: '#/definitions/org.Organization' + type: object + org.Organization: + properties: + created_at: + type: string + id: + type: string + name: + type: string + slug: + type: string + updated_at: + type: string + type: object + org.UpdateOrgRequest: + properties: + name: + example: My Corp Updated + maxLength: 100 + minLength: 2 + type: string + required: + - name + type: object info: contact: {} -paths: {} + description: |- + API системы управления AegisGuard. Позволяет управлять пользователями и организациями. + Все защищённые эндпоинты требуют заголовок `Authorization: Bearer `. + Токен получается при регистрации или входе. + title: AegisGuard API + version: "1.0" +paths: + /api/v1/auth/login: + post: + consumes: + - application/json + description: Аутентификация по email и паролю. Возвращает access_token (JWT) + и refresh_token. + parameters: + - description: Email и пароль + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.LoginRequest' + produces: + - application/json + responses: + "200": + description: Успешный вход, токены в ответе + schema: + $ref: '#/definitions/auth.AuthResponse' + "400": + description: Ошибка валидации полей + schema: + $ref: '#/definitions/auth.ErrorResponse' + "401": + description: Неверный email или пароль + schema: + $ref: '#/definitions/auth.ErrorResponse' + summary: Вход + tags: + - auth + /api/v1/auth/logout: + post: + consumes: + - application/json + description: Аннулирование refresh_token. После выхода повторное использование + того же refresh_token вернёт 401. + parameters: + - description: Refresh_token для аннулирования + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.LogoutRequest' + produces: + - application/json + responses: + "200": + description: '{"message": "logged out successfully"}' + schema: + additionalProperties: + type: string + type: object + "400": + description: Не указан refresh_token + schema: + $ref: '#/definitions/auth.ErrorResponse' + "401": + description: Refresh_token не найден или уже аннулирован + schema: + $ref: '#/definitions/auth.ErrorResponse' + summary: Выход + tags: + - auth + /api/v1/auth/me: + get: + consumes: + - application/json + description: |- + Получение профиля текущего авторизованного пользователя. + **Требуется:** заголовок `Authorization: Bearer `. + produces: + - application/json + responses: + "200": + description: Данные пользователя + schema: + $ref: '#/definitions/auth.UserResponse' + "401": + description: Токен не указан или недействителен + schema: + $ref: '#/definitions/auth.ErrorResponse' + security: + - Bearer: [] + summary: Профиль пользователя + tags: + - auth + put: + consumes: + - application/json + description: |- + Обновление username текущего пользователя. + **Требуется:** заголовок `Authorization: Bearer `. + parameters: + - description: Новый username + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.UpdateProfileRequest' + produces: + - application/json + responses: + "200": + description: Обновлённый профиль + schema: + $ref: '#/definitions/auth.UserResponse' + "400": + description: 'Ошибка валидации: username от 3 до 30 символов' + schema: + $ref: '#/definitions/auth.ErrorResponse' + "401": + description: Токен не указан или недействителен + schema: + $ref: '#/definitions/auth.ErrorResponse' + security: + - Bearer: [] + summary: Обновление профиля + tags: + - auth + /api/v1/auth/password: + put: + consumes: + - application/json + description: |- + Изменение пароля текущего пользователя. Требуется указать старый и новый пароль. + **Требуется:** заголовок `Authorization: Bearer `. + Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. + parameters: + - description: Старый и новый пароль + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.PasswordChangeRequest' + produces: + - application/json + responses: + "200": + description: '{"message": "password changed successfully"}' + schema: + additionalProperties: + type: string + type: object + "400": + description: 'Ошибка валидации: неверный старый пароль, слабый новый или + совпадают' + schema: + $ref: '#/definitions/auth.ErrorResponse' + "401": + description: Токен не указан или недействителен + schema: + $ref: '#/definitions/auth.ErrorResponse' + security: + - Bearer: [] + summary: Смена пароля + tags: + - auth + /api/v1/auth/refresh: + post: + consumes: + - application/json + description: |- + Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация). + Если refresh_token истёк или уже был использован — придёт 401. + parameters: + - description: Действительный refresh_token + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.RefreshRequest' + produces: + - application/json + responses: + "200": + description: Новая пара токенов + schema: + $ref: '#/definitions/auth.AuthResponse' + "400": + description: Не указан refresh_token + schema: + $ref: '#/definitions/auth.ErrorResponse' + "401": + description: Refresh_token недействителен или истёк + schema: + $ref: '#/definitions/auth.ErrorResponse' + summary: Обновление токенов + tags: + - auth + /api/v1/auth/register: + post: + consumes: + - application/json + description: |- + Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token. + Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. + parameters: + - description: Данные для регистрации + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Пользователь создан, токены в ответе + schema: + $ref: '#/definitions/auth.AuthResponse' + "400": + description: Ошибка валидации полей (некорректный email, слабый пароль) + schema: + $ref: '#/definitions/auth.ErrorResponse' + "409": + description: Email уже зарегистрирован + schema: + $ref: '#/definitions/auth.ErrorResponse' + summary: Регистрация + tags: + - auth + /api/v1/organizations: + get: + consumes: + - application/json + description: |- + Получение списка всех организаций с пагинацией. + **Требуется:** заголовок `Authorization: Bearer `. + parameters: + - description: Количество записей на странице (по умолчанию 20) + in: query + name: limit + type: integer + - description: Смещение от начала списка (по умолчанию 0) + in: query + name: offset + type: integer + produces: + - application/json + responses: + "200": + description: Список организаций + schema: + $ref: '#/definitions/org.OrgListResponse' + "500": + description: Внутренняя ошибка сервера + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Список организаций + tags: + - organizations + post: + consumes: + - application/json + description: |- + Создание новой организации. slug используется в URL и должен быть уникальным. + **Требуется:** заголовок `Authorization: Bearer `. + parameters: + - description: Название и slug организации + in: body + name: request + required: true + schema: + $ref: '#/definitions/org.CreateOrgRequest' + produces: + - application/json + responses: + "201": + description: Организация создана + schema: + $ref: '#/definitions/org.OrgResponse' + "400": + description: Ошибка валидации полей + schema: + $ref: '#/definitions/org.ErrorResponse' + "409": + description: Slug уже занят + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Создание организации + tags: + - organizations + /api/v1/organizations/{id}: + delete: + consumes: + - application/json + description: |- + Безвозвратное удаление организации по её ID. + **Требуется:** заголовок `Authorization: Bearer `. + parameters: + - description: UUID организации + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: '{"message": "organization deleted"}' + schema: + additionalProperties: + type: string + type: object + "404": + description: Организация не найдена + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Удаление организации + tags: + - organizations + get: + consumes: + - application/json + description: |- + Получение информации об организации по её ID. + **Требуется:** заголовок `Authorization: Bearer `. + parameters: + - description: UUID организации + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: Данные организации + schema: + $ref: '#/definitions/org.OrgResponse' + "404": + description: Организация не найдена + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Получить организацию + tags: + - organizations + put: + consumes: + - application/json + description: |- + Обновление названия организации. slug изменить нельзя. + **Требуется:** заголовок `Authorization: Bearer `. + parameters: + - description: UUID организации + in: path + name: id + required: true + type: string + - description: Новое название организации + in: body + name: request + required: true + schema: + $ref: '#/definitions/org.UpdateOrgRequest' + produces: + - application/json + responses: + "200": + description: Обновлённая организация + schema: + $ref: '#/definitions/org.OrgResponse' + "400": + description: Ошибка валидации полей + schema: + $ref: '#/definitions/org.ErrorResponse' + "404": + description: Организация не найдена + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Обновление организации + tags: + - organizations +schemes: +- http securityDefinitions: Bearer: - description: Type "Bearer" followed by a space and the JWT token. + description: Введите `Bearer `, где token — access_token из ответа /auth/login + или /auth/register in: header name: Authorization type: apiKey diff --git a/go.mod b/go.mod index a6791c5..784c449 100644 --- a/go.mod +++ b/go.mod @@ -2,60 +2,64 @@ module gitea.d3m0k1d.ru/HellreigN/Control-plane go 1.26.1 +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/pressly/goose/v3 v3.24.2 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.53.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/PuerkitoBio/purell v1.2.2 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect - github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.2 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cloudwego/base64x v0.1.7 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect - github.com/gin-gonic/gin v1.12.0 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/spec v0.22.5 // indirect - github.com/go-openapi/swag v0.26.1 // indirect - github.com/go-openapi/swag/conv v0.26.1 // indirect - github.com/go-openapi/swag/jsonname v0.26.1 // indirect - github.com/go-openapi/swag/jsonutils v0.26.1 // indirect - github.com/go-openapi/swag/loading v0.26.1 // indirect - github.com/go-openapi/swag/stringutils v0.26.1 // indirect - github.com/go-openapi/swag/typeutils v0.26.1 // indirect - github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // 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.30.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.4 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.9.2 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.60.0 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - github.com/swaggo/files v1.0.1 // indirect - github.com/swaggo/gin-swagger v1.6.1 // indirect - github.com/swaggo/swag v1.16.6 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect - github.com/urfave/cli/v2 v2.27.7 // indirect - github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect - go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.28.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/mod v0.37.0 // indirect + golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect @@ -63,5 +67,4 @@ require ( golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 6fdb5f6..4cfb7c0 100644 --- a/go.sum +++ b/go.sum @@ -1,49 +1,43 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.2.2 h1:irlAloIzZaJ5RP/+UcaT1Nw0H9on2HKWdRehCsbJWJw= -github.com/PuerkitoBio/purell v1.2.2/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= -github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= -github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= -github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo= -github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74= -github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= -github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= -github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg= -github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A= -github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= -github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= -github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= -github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= -github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= -github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= -github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= -github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= -github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU= -github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o= -github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= -github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -54,45 +48,87 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= -github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU= +github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= -github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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/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= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= @@ -103,28 +139,27 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= -github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= -go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= @@ -135,6 +170,7 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -146,6 +182,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= @@ -159,9 +196,26 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA= +modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..b7a82d2 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,9 @@ +package api + +import "github.com/gin-gonic/gin" + +func GetUserID(c *gin.Context) string { + raw, _ := c.Get("user_id") + id, _ := raw.(string) + return id +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..05d4639 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,52 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +func GenerateToken(userID, email string, secret []byte, expiration time.Duration) (string, error) { + claims := Claims{ + UserID: userID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} + +func ValidateToken(tokenString string, secret []byte) (*Claims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &Claims{}, + func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return secret, nil + }, + ) + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..a67a465 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,261 @@ +package auth + +import ( + "errors" + "log" + "net/http" + + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/api" + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// @Summary Регистрация +// @Description Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token. +// @Description Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "Данные для регистрации" +// @Success 201 {object} AuthResponse "Пользователь создан, токены в ответе" +// @Failure 400 {object} ErrorResponse "Ошибка валидации полей (некорректный email, слабый пароль)" +// @Failure 409 {object} ErrorResponse "Email уже зарегистрирован" +// @Router /api/v1/auth/register [post] +func (h *Handler) Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + resp, err := h.service.Register(c.Request.Context(), req) + if err != nil { + if errors.Is(err, ErrEmailExists) { + c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()}) + return + } + if errors.Is(err, ErrWeakPassword) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("register error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusCreated, resp) +} + +// @Summary Вход +// @Description Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Email и пароль" +// @Success 200 {object} AuthResponse "Успешный вход, токены в ответе" +// @Failure 400 {object} ErrorResponse "Ошибка валидации полей" +// @Failure 401 {object} ErrorResponse "Неверный email или пароль" +// @Router /api/v1/auth/login [post] +func (h *Handler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + resp, err := h.service.Login(c.Request.Context(), req) + if err != nil { + if errors.Is(err, ErrInvalidCreds) { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("login error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// @Summary Обновление токенов +// @Description Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация). +// @Description Если refresh_token истёк или уже был использован — придёт 401. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body RefreshRequest true "Действительный refresh_token" +// @Success 200 {object} AuthResponse "Новая пара токенов" +// @Failure 400 {object} ErrorResponse "Не указан refresh_token" +// @Failure 401 {object} ErrorResponse "Refresh_token недействителен или истёк" +// @Router /api/v1/auth/refresh [post] +func (h *Handler) Refresh(c *gin.Context) { + var req RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + resp, err := h.service.Refresh(c.Request.Context(), req.RefreshToken) + if err != nil { + if errors.Is(err, ErrInvalidRefresh) || errors.Is(err, ErrRefreshExpired) { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("refresh error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// @Summary Выход +// @Description Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401. +// @Tags auth +// @Accept json +// @Produce json +// @Param request body LogoutRequest true "Refresh_token для аннулирования" +// @Success 200 {object} map[string]string "{"message": "logged out successfully"}" +// @Failure 400 {object} ErrorResponse "Не указан refresh_token" +// @Failure 401 {object} ErrorResponse "Refresh_token не найден или уже аннулирован" +// @Router /api/v1/auth/logout [post] +func (h *Handler) Logout(c *gin.Context) { + var req LogoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + if err := h.service.Logout(c.Request.Context(), req.RefreshToken); err != nil { + if errors.Is(err, ErrLogoutInvalid) { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("logout error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"}) +} + +// @Summary Профиль пользователя +// @Description Получение профиля текущего авторизованного пользователя. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Tags auth +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} UserResponse "Данные пользователя" +// @Failure 401 {object} ErrorResponse "Токен не указан или недействителен" +// @Router /api/v1/auth/me [get] +func (h *Handler) Me(c *gin.Context) { + userID := api.GetUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) + return + } + + user, err := h.service.GetUserByID(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("me error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, UserResponse{User: *user}) +} + +// @Summary Смена пароля +// @Description Изменение пароля текущего пользователя. Требуется указать старый и новый пароль. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Description Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. +// @Tags auth +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body PasswordChangeRequest true "Старый и новый пароль" +// @Success 200 {object} map[string]string "{"message": "password changed successfully"}" +// @Failure 400 {object} ErrorResponse "Ошибка валидации: неверный старый пароль, слабый новый или совпадают" +// @Failure 401 {object} ErrorResponse "Токен не указан или недействителен" +// @Router /api/v1/auth/password [put] +func (h *Handler) ChangePassword(c *gin.Context) { + userID := api.GetUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) + return + } + + var req PasswordChangeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + if err := h.service.ChangePassword(c.Request.Context(), userID, req); err != nil { + if errors.Is(err, ErrWrongPassword) || errors.Is(err, ErrSamePassword) || + errors.Is(err, ErrWeakPassword) { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("change password error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "password changed successfully"}) +} + +// @Summary Обновление профиля +// @Description Обновление username текущего пользователя. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Tags auth +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body UpdateProfileRequest true "Новый username" +// @Success 200 {object} UserResponse "Обновлённый профиль" +// @Failure 400 {object} ErrorResponse "Ошибка валидации: username от 3 до 30 символов" +// @Failure 401 {object} ErrorResponse "Токен не указан или недействителен" +// @Router /api/v1/auth/me [put] +func (h *Handler) UpdateProfile(c *gin.Context) { + userID := api.GetUserID(c) + if userID == "" { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) + return + } + + var req UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + user, err := h.service.UpdateProfile(c.Request.Context(), userID, req) + if err != nil { + if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("update profile error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, UserResponse{User: *user}) +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..6856d87 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,43 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func AuthMiddleware(jwtSecret []byte) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON( + http.StatusUnauthorized, + ErrorResponse{Error: "authorization header required"}, + ) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON( + http.StatusUnauthorized, + ErrorResponse{Error: "invalid authorization header format"}, + ) + return + } + + claims, err := ValidateToken(parts[1], jwtSecret) + if err != nil { + c.AbortWithStatusJSON( + http.StatusUnauthorized, + ErrorResponse{Error: "invalid or expired token"}, + ) + return + } + + c.Set("user_id", claims.UserID) + c.Set("email", claims.Email) + c.Next() + } +} diff --git a/internal/auth/models.go b/internal/auth/models.go new file mode 100644 index 0000000..1cb8748 --- /dev/null +++ b/internal/auth/models.go @@ -0,0 +1,81 @@ +package auth + +import ( + "time" +) + +type User struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + Username string `gorm:"type:text;not null" json:"username"` + Email string `gorm:"type:text;not null;uniqueIndex" json:"email"` + PasswordHash string `gorm:"column:password_hash;type:text;not null" json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=30" example:"john"` + Email string `json:"email" binding:"required,email" example:"john@example.com"` + Password string `json:"password" binding:"required,min=8" example:"Secret123!"` +} + +type LoginRequest struct { + Email string `json:"email" binding:"required,email" example:"john@example.com"` + Password string `json:"password" binding:"required" example:"secret123"` +} + +type AuthResponse struct { + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."` + RefreshToken string `json:"refresh_token" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="` + User UserPublic `json:"user"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="` +} + +type LogoutRequest struct { + RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="` +} + +type RefreshTokenDoc struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + UserID string `gorm:"column:user_id;type:uuid;not null;index" json:"user_id"` + TokenHash string `gorm:"column:token_hash;type:text;not null;uniqueIndex" json:"token_hash"` + ExpiresAt time.Time `gorm:"column:expires_at;type:timestamptz;not null" json:"expires_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (RefreshTokenDoc) TableName() string { return "refresh_tokens" } + +type UserPublic struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +func NewUserPublic(u *User) UserPublic { + return UserPublic{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + CreatedAt: u.CreatedAt, + } +} + +type UserResponse struct { + User UserPublic `json:"user"` +} + +type PasswordChangeRequest struct { + OldPassword string `json:"old_password" binding:"required" example:"Secret123!"` + NewPassword string `json:"new_password" binding:"required,min=8" example:"NewSecret456!"` +} + +type UpdateProfileRequest struct { + Username string `json:"username" binding:"required,min=3,max=30" example:"john_updated"` +} + +type ErrorResponse struct { + Error string `json:"error" example:"invalid email or password"` +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go new file mode 100644 index 0000000..30c974a --- /dev/null +++ b/internal/auth/repository.go @@ -0,0 +1,102 @@ +package auth + +import ( + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserRepository interface { + CreateUser(ctx context.Context, user *User) error + FindByEmail(ctx context.Context, email string) (*User, error) + FindByID(ctx context.Context, id string) (*User, error) + CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error + FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenDoc, error) + DeleteRefreshToken(ctx context.Context, id string) error + DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) + UpdateUserUsername(ctx context.Context, id, username string) error + UpdateUserPassword(ctx context.Context, id, passwordHash string) error + DeleteExpiredRefreshTokens(ctx context.Context) error +} + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) CreateUser(ctx context.Context, user *User) error { + user.ID = uuid.New().String() + user.CreatedAt = time.Now().UTC() + return r.db.WithContext(ctx).Create(user).Error +} + +func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) { + var user User + err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) FindByID(ctx context.Context, id string) (*User, error) { + var user User + err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error { + doc.ID = uuid.New().String() + doc.CreatedAt = time.Now().UTC() + return r.db.WithContext(ctx).Create(doc).Error +} + +func (r *Repository) FindRefreshTokenByHash( + ctx context.Context, + hash string, +) (*RefreshTokenDoc, error) { + var doc RefreshTokenDoc + err := r.db.WithContext(ctx). + Where("token_hash = ? AND expires_at > NOW()", hash). + First(&doc). + Error + if err != nil { + return nil, err + } + return &doc, nil +} + +func (r *Repository) DeleteRefreshToken(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Where("id = ?", id).Delete(&RefreshTokenDoc{}).Error +} + +func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) { + result := r.db.WithContext(ctx).Where("token_hash = ?", hash).Delete(&RefreshTokenDoc{}) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +func (r *Repository) UpdateUserUsername(ctx context.Context, id, username string) error { + return r.db.WithContext(ctx).Model(&User{}).Where("id = ?", id). + Update("username", username).Error +} + +func (r *Repository) UpdateUserPassword(ctx context.Context, id, passwordHash string) error { + return r.db.WithContext(ctx).Model(&User{}).Where("id = ?", id). + Update("password_hash", passwordHash).Error +} + +func (r *Repository) DeleteExpiredRefreshTokens(ctx context.Context) error { + return r.db.WithContext(ctx). + Where("expires_at <= NOW()").Delete(&RefreshTokenDoc{}).Error +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..e211c6e --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,294 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + "unicode" + + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/db" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrEmailExists = errors.New("email already registered") + ErrInvalidCreds = errors.New("invalid email or password") + ErrUserNotFound = errors.New("user not found") + ErrInvalidUserID = errors.New("invalid user ID") + ErrInvalidRefresh = errors.New("invalid refresh token") + ErrRefreshExpired = errors.New("refresh token expired") + ErrLogoutInvalid = errors.New("refresh token not found or already used") + ErrWrongPassword = errors.New("current password is incorrect") + ErrWeakPassword = errors.New( + "password must be at least 8 characters with uppercase, lowercase, and digit", + ) + ErrSamePassword = errors.New("new password must differ from current password") +) + +type Service struct { + repo UserRepository + jwtSecret []byte + jwtExp time.Duration + refreshExp time.Duration +} + +func NewService(repo UserRepository, jwtSecret string, jwtExp, refreshExp time.Duration) *Service { + return &Service{ + repo: repo, + jwtSecret: []byte(jwtSecret), + jwtExp: jwtExp, + refreshExp: refreshExp, + } +} + +func sha256Hex(data string) string { + h := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", h) +} + +func generateRandomToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func validatePasswordStrength(password string) error { + if len(password) < 8 { + return ErrWeakPassword + } + var hasUpper, hasLower, hasDigit bool + for _, ch := range password { + switch { + case unicode.IsUpper(ch): + hasUpper = true + case unicode.IsLower(ch): + hasLower = true + case unicode.IsDigit(ch): + hasDigit = true + } + } + if !hasUpper || !hasLower || !hasDigit { + return ErrWeakPassword + } + return nil +} + +func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse, error) { + accessToken, err := GenerateToken(user.ID, user.Email, s.jwtSecret, s.jwtExp) + if err != nil { + return nil, fmt.Errorf("failed to generate access token: %w", err) + } + + rawRefresh, err := generateRandomToken() + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + + refreshDoc := &RefreshTokenDoc{ + UserID: user.ID, + TokenHash: sha256Hex(rawRefresh), + ExpiresAt: time.Now().UTC().Add(s.refreshExp), + } + + if err := s.repo.CreateRefreshToken(ctx, refreshDoc); err != nil { + return nil, fmt.Errorf("failed to store refresh token: %w", err) + } + + return &AuthResponse{ + Token: accessToken, + RefreshToken: rawRefresh, + User: NewUserPublic(user), + }, nil +} + +func (s *Service) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) { + if err := validatePasswordStrength(req.Password); err != nil { + return nil, err + } + + req.Email = strings.ToLower(req.Email) + + existing, err := s.repo.FindByEmail(ctx, req.Email) + if err != nil && !errors.Is(err, db.ErrNoRows) { + return nil, fmt.Errorf("failed to check existing user: %w", err) + } + if existing != nil { + return nil, ErrEmailExists + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + user := &User{ + Username: req.Username, + Email: req.Email, + PasswordHash: string(hash), + } + + if err := s.repo.CreateUser(ctx, user); err != nil { + if isPGUniqueViolation(err) { + return nil, ErrEmailExists + } + return nil, fmt.Errorf("failed to create user: %w", err) + } + + return s.issueTokenPair(ctx, user) +} + +func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) { + req.Email = strings.ToLower(req.Email) + user, err := s.repo.FindByEmail(ctx, req.Email) + if err != nil { + if errors.Is(err, db.ErrNoRows) { + return nil, ErrInvalidCreds + } + return nil, fmt.Errorf("failed to find user: %w", err) + } + + if err := bcrypt.CompareHashAndPassword( + []byte(user.PasswordHash), + []byte(req.Password), + ); err != nil { + return nil, ErrInvalidCreds + } + + return s.issueTokenPair(ctx, user) +} + +func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse, error) { + hash := sha256Hex(rawRefresh) + + doc, err := s.repo.FindRefreshTokenByHash(ctx, hash) + if err != nil { + if errors.Is(err, db.ErrNoRows) { + return nil, ErrInvalidRefresh + } + return nil, fmt.Errorf("failed to find refresh token: %w", err) + } + + if err := s.repo.DeleteRefreshToken(ctx, doc.ID); err != nil { + return nil, fmt.Errorf("failed to delete old refresh token: %w", err) + } + + user, err := s.repo.FindByID(ctx, doc.UserID) + if err != nil { + return nil, fmt.Errorf("failed to find user: %w", err) + } + + return s.issueTokenPair(ctx, user) +} + +func (s *Service) Logout(ctx context.Context, rawRefresh string) error { + hash := sha256Hex(rawRefresh) + + found, err := s.repo.DeleteRefreshTokenByHash(ctx, hash) + if err != nil { + return fmt.Errorf("failed to delete refresh token: %w", err) + } + if !found { + return ErrLogoutInvalid + } + + return nil +} + +func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) { + if userID == "" { + return nil, ErrInvalidUserID + } + + user, err := s.repo.FindByID(ctx, userID) + if err != nil { + if errors.Is(err, db.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("failed to find user: %w", err) + } + + public := NewUserPublic(user) + return &public, nil +} + +func (s *Service) ChangePassword( + ctx context.Context, + userID string, + req PasswordChangeRequest, +) error { + if userID == "" { + return ErrInvalidUserID + } + + user, err := s.repo.FindByID(ctx, userID) + if err != nil { + if errors.Is(err, db.ErrNoRows) { + return ErrUserNotFound + } + return fmt.Errorf("failed to find user: %w", err) + } + + if err := bcrypt.CompareHashAndPassword( + []byte(user.PasswordHash), + []byte(req.OldPassword), + ); err != nil { + return ErrWrongPassword + } + + if req.OldPassword == req.NewPassword { + return ErrSamePassword + } + + if err := validatePasswordStrength(req.NewPassword); err != nil { + return err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + if err := s.repo.UpdateUserPassword(ctx, userID, string(hash)); err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} + +func (s *Service) UpdateProfile( + ctx context.Context, + userID string, + req UpdateProfileRequest, +) (*UserPublic, error) { + if userID == "" { + return nil, ErrInvalidUserID + } + + user, err := s.repo.FindByID(ctx, userID) + if err != nil { + if errors.Is(err, db.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("failed to find user: %w", err) + } + + if err := s.repo.UpdateUserUsername(ctx, userID, req.Username); err != nil { + return nil, fmt.Errorf("failed to update username: %w", err) + } + + user.Username = req.Username + public := NewUserPublic(user) + return &public, nil +} + +func isPGUniqueViolation(err error) bool { + return err != nil && + (strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505")) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..839b85a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,67 @@ +package config + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/joho/godotenv" +) + +type Config struct { + ServerPort string + DatabaseURL string + JWTSecret string + JWTExpiration time.Duration + JWTRefreshExpiration time.Duration +} + +func Load() (*Config, error) { + if err := godotenv.Load(); err != nil { + log.Printf("warning: .env file not loaded: %v", err) + } + + cfg := &Config{ + ServerPort: getEnv("SERVER_PORT", "8080"), + DatabaseURL: getEnv( + "DATABASE_URL", + "postgres://localhost:5432/aegisguard?sslmode=disable", + ), + JWTSecret: getEnv("JWT_SECRET", ""), + JWTExpiration: 24 * time.Hour, + JWTRefreshExpiration: 7 * 24 * time.Hour, + } + + if cfg.JWTSecret == "" { + return nil, fmt.Errorf("JWT_SECRET is required in .env file") + } + if len(cfg.JWTSecret) < 32 { + return nil, fmt.Errorf("JWT_SECRET must be at least 32 characters long") + } + + if expStr := os.Getenv("JWT_EXPIRATION"); expStr != "" { + d, err := time.ParseDuration(expStr) + if err != nil { + return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err) + } + cfg.JWTExpiration = d + } + + if expStr := os.Getenv("JWT_REFRESH_EXPIRATION"); expStr != "" { + d, err := time.ParseDuration(expStr) + if err != nil { + return nil, fmt.Errorf("invalid JWT_REFRESH_EXPIRATION: %w", err) + } + cfg.JWTRefreshExpiration = d + } + + return cfg, nil +} + +func getEnv(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} diff --git a/internal/db/errors.go b/internal/db/errors.go new file mode 100644 index 0000000..6a033de --- /dev/null +++ b/internal/db/errors.go @@ -0,0 +1,5 @@ +package db + +import "gorm.io/gorm" + +var ErrNoRows = gorm.ErrRecordNotFound diff --git a/internal/org/handler.go b/internal/org/handler.go new file mode 100644 index 0000000..b613d79 --- /dev/null +++ b/internal/org/handler.go @@ -0,0 +1,168 @@ +package org + +import ( + "errors" + "log" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// @Summary Создание организации +// @Description Создание новой организации. slug используется в URL и должен быть уникальным. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body CreateOrgRequest true "Название и slug организации" +// @Success 201 {object} OrgResponse "Организация создана" +// @Failure 400 {object} ErrorResponse "Ошибка валидации полей" +// @Failure 409 {object} ErrorResponse "Slug уже занят" +// @Router /api/v1/organizations [post] +func (h *Handler) Create(c *gin.Context) { + var req CreateOrgRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + org, err := h.service.Create(c.Request.Context(), req) + if err != nil { + if errors.Is(err, ErrSlugExists) { + c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("create org error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusCreated, OrgResponse{Organization: *org}) +} + +// @Summary Получить организацию +// @Description Получение информации об организации по её ID. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "UUID организации" +// @Success 200 {object} OrgResponse "Данные организации" +// @Failure 404 {object} ErrorResponse "Организация не найдена" +// @Router /api/v1/organizations/{id} [get] +func (h *Handler) GetByID(c *gin.Context) { + id := c.Param("id") + + org, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("get org error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, OrgResponse{Organization: *org}) +} + +// @Summary Список организаций +// @Description Получение списка всех организаций с пагинацией. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param limit query int false "Количество записей на странице (по умолчанию 20)" +// @Param offset query int false "Смещение от начала списка (по умолчанию 0)" +// @Success 200 {object} OrgListResponse "Список организаций" +// @Failure 500 {object} ErrorResponse "Внутренняя ошибка сервера" +// @Router /api/v1/organizations [get] +func (h *Handler) List(c *gin.Context) { + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + + resp, err := h.service.List(c.Request.Context(), limit, offset) + if err != nil { + log.Printf("list orgs error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// @Summary Обновление организации +// @Description Обновление названия организации. slug изменить нельзя. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "UUID организации" +// @Param request body UpdateOrgRequest true "Новое название организации" +// @Success 200 {object} OrgResponse "Обновлённая организация" +// @Failure 400 {object} ErrorResponse "Ошибка валидации полей" +// @Failure 404 {object} ErrorResponse "Организация не найдена" +// @Router /api/v1/organizations/{id} [put] +func (h *Handler) Update(c *gin.Context) { + id := c.Param("id") + + var req UpdateOrgRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + org, err := h.service.Update(c.Request.Context(), id, req) + if err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("update org error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, OrgResponse{Organization: *org}) +} + +// @Summary Удаление организации +// @Description Безвозвратное удаление организации по её ID. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "UUID организации" +// @Success 200 {object} map[string]string "{"message": "organization deleted"}" +// @Failure 404 {object} ErrorResponse "Организация не найдена" +// @Router /api/v1/organizations/{id} [delete] +func (h *Handler) Delete(c *gin.Context) { + id := c.Param("id") + + if err := h.service.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, ErrNotFound) { + c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()}) + return + } + log.Printf("delete org error: %v", err) + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "organization deleted"}) +} diff --git a/internal/org/models.go b/internal/org/models.go new file mode 100644 index 0000000..ed75aa0 --- /dev/null +++ b/internal/org/models.go @@ -0,0 +1,35 @@ +package org + +import "time" + +type Organization struct { + ID string `gorm:"type:uuid;primaryKey" json:"id"` + Name string `gorm:"type:text;not null" json:"name"` + Slug string `gorm:"type:text;not null;uniqueIndex" json:"slug"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +type CreateOrgRequest struct { + Name string `json:"name" binding:"required,min=2,max=100" example:"My Corp"` + Slug string `json:"slug" binding:"required,min=2,max=50" example:"my-corp"` +} + +type UpdateOrgRequest struct { + Name string `json:"name" binding:"required,min=2,max=100" example:"My Corp Updated"` +} + +type OrgResponse struct { + Organization Organization `json:"organization"` +} + +type OrgListResponse struct { + Organizations []Organization `json:"organizations"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} diff --git a/internal/org/repository.go b/internal/org/repository.go new file mode 100644 index 0000000..ea7554e --- /dev/null +++ b/internal/org/repository.go @@ -0,0 +1,71 @@ +package org + +import ( + "context" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type OrgRepository interface { + Create(ctx context.Context, org *Organization) error + FindByID(ctx context.Context, id string) (*Organization, error) + FindAll(ctx context.Context, limit, offset int) ([]Organization, error) + Count(ctx context.Context) (int, error) + Update(ctx context.Context, org *Organization) error + Delete(ctx context.Context, id string) (bool, error) +} + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(ctx context.Context, org *Organization) error { + org.ID = uuid.New().String() + now := time.Now().UTC() + org.CreatedAt = now + org.UpdatedAt = now + return r.db.WithContext(ctx).Create(org).Error +} + +func (r *Repository) FindByID(ctx context.Context, id string) (*Organization, error) { + var org Organization + err := r.db.WithContext(ctx).Where("id = ?", id).First(&org).Error + if err != nil { + return nil, err + } + return &org, nil +} + +func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]Organization, error) { + var orgs []Organization + err := r.db.WithContext(ctx). + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&orgs).Error + return orgs, err +} + +func (r *Repository) Count(ctx context.Context) (int, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&Organization{}).Count(&total).Error + return int(total), err +} + +func (r *Repository) Update(ctx context.Context, org *Organization) error { + return r.db.WithContext(ctx).Model(org).Update("name", org.Name).Error +} + +func (r *Repository) Delete(ctx context.Context, id string) (bool, error) { + result := r.db.WithContext(ctx).Delete(&Organization{}, "id = ?", id) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} diff --git a/internal/org/service.go b/internal/org/service.go new file mode 100644 index 0000000..3e63b7d --- /dev/null +++ b/internal/org/service.go @@ -0,0 +1,118 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strings" + + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/db" +) + +var ( + ErrNotFound = errors.New("organization not found") + ErrSlugExists = errors.New("slug already taken") +) + +type Service struct { + repo OrgRepository +} + +func NewService(repo OrgRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Create(ctx context.Context, req CreateOrgRequest) (*Organization, error) { + req.Slug = strings.ToLower(strings.TrimSpace(req.Slug)) + + org := &Organization{ + Name: req.Name, + Slug: req.Slug, + } + + if err := s.repo.Create(ctx, org); err != nil { + if isUniqueViolation(err) { + return nil, ErrSlugExists + } + return nil, fmt.Errorf("failed to create organization: %w", err) + } + + return org, nil +} + +func (s *Service) GetByID(ctx context.Context, id string) (*Organization, error) { + org, err := s.repo.FindByID(ctx, id) + if err != nil { + if errors.Is(err, db.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to find organization: %w", err) + } + return org, nil +} + +func (s *Service) List(ctx context.Context, limit, offset int) (*OrgListResponse, error) { + if limit <= 0 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + + total, err := s.repo.Count(ctx) + if err != nil { + return nil, fmt.Errorf("failed to count organizations: %w", err) + } + + orgs, err := s.repo.FindAll(ctx, limit, offset) + if err != nil { + return nil, fmt.Errorf("failed to list organizations: %w", err) + } + if orgs == nil { + orgs = []Organization{} + } + return &OrgListResponse{ + Organizations: orgs, + Total: total, + Limit: limit, + Offset: offset, + }, nil +} + +func (s *Service) Update( + ctx context.Context, + id string, + req UpdateOrgRequest, +) (*Organization, error) { + org, err := s.repo.FindByID(ctx, id) + if err != nil { + if errors.Is(err, db.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to find organization: %w", err) + } + + org.Name = req.Name + + if err := s.repo.Update(ctx, org); err != nil { + return nil, fmt.Errorf("failed to update organization: %w", err) + } + + return org, nil +} + +func (s *Service) Delete(ctx context.Context, id string) error { + found, err := s.repo.Delete(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete organization: %w", err) + } + if !found { + return ErrNotFound + } + return nil +} + +func isUniqueViolation(err error) bool { + return err != nil && + (strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505")) +} diff --git a/migrations/00001_init.sql b/migrations/00001_init.sql new file mode 100644 index 0000000..428ae67 --- /dev/null +++ b/migrations/00001_init.sql @@ -0,0 +1,22 @@ +-- +goose Up + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); + +-- +goose Down +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS users; diff --git a/migrations/00002_create_organizations.sql b/migrations/00002_create_organizations.sql new file mode 100644 index 0000000..1826931 --- /dev/null +++ b/migrations/00002_create_organizations.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- +goose Down +DROP TABLE IF EXISTS organizations;