From 7298ab662f2df865e2680c9b332c1e5ee1fddd4f Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Thu, 17 Jul 2025 17:20:48 +0300 Subject: [PATCH 1/4] experiment: prototyping new ASP.NET-like controllers; feat: ControllerMethod struct for storing data about an individual API endpoint; feat: controllerImpl struct for setting up a controller; feat: GetRequest method for parsing and validating a request with automatic abortion on binding/validation errors --- backend/internal/controllers/controller.go | 141 +++++++++++++++++++++ backend/internal/errors/controller.go | 24 ++++ 2 files changed, 165 insertions(+) create mode 100644 backend/internal/controllers/controller.go create mode 100644 backend/internal/errors/controller.go diff --git a/backend/internal/controllers/controller.go b/backend/internal/controllers/controller.go new file mode 100644 index 0000000..9686bec --- /dev/null +++ b/backend/internal/controllers/controller.go @@ -0,0 +1,141 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package controllers + +import ( + "easywish/internal/dto" + errs "easywish/internal/errors" + "easywish/internal/middleware" + "easywish/internal/services" + "easywish/internal/utils/enums" + "easywish/internal/validation" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "go.uber.org/zap" +) + +var ( + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" +) + +type ControllerMethod struct { + HttpMethod string + Path string + Authorization enums.Role + Middleware []gin.HandlerFunc + Function func (c *gin.Context) +} + +type controllerImpl struct { + Path string + Authorization enums.Role + Middleware []gin.HandlerFunc + Methods []ControllerMethod +} + +func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) *gin.RouterGroup { + ctrlGroup := group.Group(ctrl.Path) + ctrlGroup.Use(middleware.AuthMiddleware(log, auth)) + ctrlGroup.Use(gin.HandlerFunc(func(c *gin.Context) { + ip := c.ClientIP() + userAgent := c.Request.UserAgent() + sessionInfoFromCtx, ok := c.Get("session_info"); if !ok { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid or missing session data"}) + return + } + + sessionInfo := sessionInfoFromCtx.(dto.SessionInfo) + + if sessionInfo.Role < ctrl.Authorization { + c.AbortWithStatusJSON( + http.StatusForbidden, + gin.H{"error": "Insufficient authorization for this controller"}) + return + } + + c.Set("client_info", dto.ClientInfo{ + SessionInfo: sessionInfo, + IP: ip, + UserAgent: userAgent, + }) + + c.Next() + })) + ctrlGroup.Use(ctrl.Middleware...) + + for _, method := range ctrl.Methods { + ctrlGroup.Handle( + method.HttpMethod, + method.Path, + append( + method.Middleware, + gin.HandlerFunc(func(c *gin.Context) { + clientInfo, _ := c.Get("client_info") + if clientInfo.(dto.ClientInfo).Role < method.Authorization { + c.AbortWithStatusJSON( + http.StatusForbidden, + gin.H{"error": "Insufficient authorization for this method"}) + return + } + }), + method.Function)..., + )} + return ctrlGroup +} + +type Controller interface { + Setup() +} + +func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { + + var body ModelT + if err := c.ShouldBindJSON(&body); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return nil, err + } + + validate := validation.NewValidator() + + if err := validate.Struct(body); err != nil { + errorList := err.(validator.ValidationErrors) + c.AbortWithStatusJSON( + http.StatusBadRequest, + gin.H{"error": errorList}) + return nil, err + } + + cinfoFromCtx, ok := c.Get("client_info"); if !ok { + c.AbortWithStatusJSON( + http.StatusInternalServerError, + gin.H{"error": "Client info was not found"}) + return nil, errs.ErrClientInfoNotProvided + } + cinfo := cinfoFromCtx.(*dto.ClientInfo) + + return &dto.Request[ModelT]{ + Body: body, + User: *cinfo, + }, nil +} diff --git a/backend/internal/errors/controller.go b/backend/internal/errors/controller.go new file mode 100644 index 0000000..4489316 --- /dev/null +++ b/backend/internal/errors/controller.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package errors + +import "errors" + +var ( + ErrClientInfoNotProvided = errors.New("No client info provded") +) From f9d7439defb8ec9c3fc703b145aef60d9c597b5f Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Thu, 17 Jul 2025 17:52:12 +0300 Subject: [PATCH 2/4] fix: Setup interface mismatch; refactor: GetRequest now panics on missing client_info since it is only supposed to be used on handlers behind AuthMiddleware --- backend/internal/controllers/controller.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/controllers/controller.go b/backend/internal/controllers/controller.go index 9686bec..15e9195 100644 --- a/backend/internal/controllers/controller.go +++ b/backend/internal/controllers/controller.go @@ -19,7 +19,6 @@ package controllers import ( "easywish/internal/dto" - errs "easywish/internal/errors" "easywish/internal/middleware" "easywish/internal/services" "easywish/internal/utils/enums" @@ -105,7 +104,7 @@ func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth } type Controller interface { - Setup() + Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) } func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { @@ -116,6 +115,7 @@ func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { return nil, err } + // TODO: Think hard on a singleton for better performance validate := validation.NewValidator() if err := validate.Struct(body); err != nil { @@ -130,7 +130,7 @@ func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { c.AbortWithStatusJSON( http.StatusInternalServerError, gin.H{"error": "Client info was not found"}) - return nil, errs.ErrClientInfoNotProvided + panic("No client_info found in gin context. Does the handler use AuthMiddleware?") } cinfo := cinfoFromCtx.(*dto.ClientInfo) From d6e2d02bffe56eac1bc49454702dcf92215d86d2 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Thu, 17 Jul 2025 21:42:47 +0300 Subject: [PATCH 3/4] refactor: transitioned auth controller to use the new controller structure; feat: setup DI for controllers; refactor: marked old utils and routes package parts as deprecated --- backend/cmd/main.go | 18 +- backend/docs/docs.go | 452 ------------------- backend/docs/swagger.json | 452 ------------------- backend/docs/swagger.yaml | 292 ------------ backend/internal/controllers/account.go | 12 +- backend/internal/controllers/auth.go | 500 +++++++++++---------- backend/internal/controllers/controller.go | 39 +- backend/internal/controllers/profile.go | 62 +-- backend/internal/controllers/service.go | 60 +-- backend/internal/controllers/setup.go | 58 ++- backend/internal/middleware/request.go | 3 + backend/internal/models/service.go | 22 + backend/internal/routes/router.go | 42 +- backend/internal/routes/setup.go | 9 +- backend/internal/utils/request.go | 1 + 15 files changed, 437 insertions(+), 1585 deletions(-) create mode 100644 backend/internal/models/service.go diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 612a022..45fd6ac 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -15,17 +15,17 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -// @title Easywish client API -// @version 1.0 -// @description Easy and feature-rich wishlist. -// @license.name GPL-3.0 +// @title Easywish client API +// @version 1.0 +// @description Easy and feature-rich wishlist. +// @license.name GPL-3.0 -// @BasePath /api/ -// @Schemes http +// @BasePath /api/ +// @Schemes http // @securityDefinitions.apikey JWT -// @in header -// @name Authorization +// @in header +// @name Authorization package main @@ -46,7 +46,6 @@ import ( "easywish/internal/database" "easywish/internal/logger" redisclient "easywish/internal/redisClient" - "easywish/internal/routes" "easywish/internal/services" "easywish/internal/validation" @@ -74,7 +73,6 @@ func main() { validation.Module, controllers.Module, - routes.Module, fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine, syncLogger *logger.SyncLogger) { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e1df226..9da7489 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -38,257 +38,6 @@ const docTemplate = `{ "responses": {} } }, - "/auth/changePassword": { - "post": { - "security": [ - { - "JWT": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Set new password using the old password", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.ChangePasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "Password successfully changed" - }, - "403": { - "description": "Invalid old password" - } - } - } - }, - "/auth/login": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Acquire tokens via login credentials (and 2FA code if needed)", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.LoginRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.LoginResponse" - } - }, - "403": { - "description": "Invalid login credentials" - } - } - } - }, - "/auth/passwordResetBegin": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Request password reset email", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.PasswordResetBeginRequest" - } - } - ], - "responses": { - "200": { - "description": "Reset code sent to the email if it is attached to an account" - }, - "429": { - "description": "Too many recent requests for this email" - } - } - } - }, - "/auth/passwordResetComplete": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Complete password reset via email code", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.PasswordResetCompleteRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.PasswordResetCompleteResponse" - } - }, - "403": { - "description": "Wrong verification code or username" - } - } - } - }, - "/auth/refresh": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Receive new tokens via refresh token", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RefreshRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.RefreshResponse" - } - }, - "401": { - "description": "Invalid refresh token" - } - } - } - }, - "/auth/registrationBegin": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Register an account", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RegistrationBeginRequest" - } - } - ], - "responses": { - "200": { - "description": "Account is created and awaiting verification" - }, - "409": { - "description": "Username or email is already taken" - }, - "429": { - "description": "Too many recent registration attempts for this email" - } - } - } - }, - "/auth/registrationComplete": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Confirm with code, finish creating the account", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RegistrationCompleteRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.RegistrationCompleteResponse" - } - }, - "403": { - "description": "Invalid email or verification code" - } - } - } - }, "/profile": { "patch": { "security": [ @@ -395,207 +144,6 @@ const docTemplate = `{ ], "responses": {} } - }, - "/service/health": { - "get": { - "description": "Used internally for checking service health", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Service" - ], - "summary": "Get health status", - "responses": { - "200": { - "description": "Says whether it's healthy or not", - "schema": { - "$ref": "#/definitions/controllers.HealthStatus" - } - } - } - } - } - }, - "definitions": { - "controllers.HealthStatus": { - "type": "object", - "properties": { - "healthy": { - "type": "boolean" - } - } - }, - "models.ChangePasswordRequest": { - "type": "object", - "required": [ - "old_password", - "password" - ], - "properties": { - "old_password": { - "type": "string" - }, - "password": { - "type": "string" - }, - "totp": { - "type": "string" - } - } - }, - "models.LoginRequest": { - "type": "object", - "required": [ - "password", - "username" - ], - "properties": { - "password": { - "type": "string", - "maxLength": 100 - }, - "totp": { - "type": "string" - }, - "username": { - "type": "string", - "maxLength": 20, - "minLength": 3 - } - } - }, - "models.LoginResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.PasswordResetBeginRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - } - } - }, - "models.PasswordResetCompleteRequest": { - "type": "object", - "required": [ - "email", - "password", - "verification_code" - ], - "properties": { - "email": { - "type": "string" - }, - "log_out_sessions": { - "type": "boolean" - }, - "password": { - "type": "string" - }, - "verification_code": { - "type": "string" - } - } - }, - "models.PasswordResetCompleteResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.RefreshRequest": { - "type": "object", - "required": [ - "refresh_token" - ], - "properties": { - "refresh_token": { - "type": "string" - } - } - }, - "models.RefreshResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.RegistrationBeginRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "models.RegistrationCompleteRequest": { - "type": "object", - "required": [ - "name", - "username", - "verification_code" - ], - "properties": { - "birthday": { - "type": "string" - }, - "name": { - "type": "string" - }, - "username": { - "type": "string" - }, - "verification_code": { - "type": "string" - } - } - }, - "models.RegistrationCompleteResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index af5b4dd..c2c6d2a 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -34,257 +34,6 @@ "responses": {} } }, - "/auth/changePassword": { - "post": { - "security": [ - { - "JWT": [] - } - ], - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Set new password using the old password", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.ChangePasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "Password successfully changed" - }, - "403": { - "description": "Invalid old password" - } - } - } - }, - "/auth/login": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Acquire tokens via login credentials (and 2FA code if needed)", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.LoginRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.LoginResponse" - } - }, - "403": { - "description": "Invalid login credentials" - } - } - } - }, - "/auth/passwordResetBegin": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Request password reset email", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.PasswordResetBeginRequest" - } - } - ], - "responses": { - "200": { - "description": "Reset code sent to the email if it is attached to an account" - }, - "429": { - "description": "Too many recent requests for this email" - } - } - } - }, - "/auth/passwordResetComplete": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Complete password reset via email code", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.PasswordResetCompleteRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.PasswordResetCompleteResponse" - } - }, - "403": { - "description": "Wrong verification code or username" - } - } - } - }, - "/auth/refresh": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Receive new tokens via refresh token", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RefreshRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.RefreshResponse" - } - }, - "401": { - "description": "Invalid refresh token" - } - } - } - }, - "/auth/registrationBegin": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Register an account", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RegistrationBeginRequest" - } - } - ], - "responses": { - "200": { - "description": "Account is created and awaiting verification" - }, - "409": { - "description": "Username or email is already taken" - }, - "429": { - "description": "Too many recent registration attempts for this email" - } - } - } - }, - "/auth/registrationComplete": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Auth" - ], - "summary": "Confirm with code, finish creating the account", - "parameters": [ - { - "description": " ", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/models.RegistrationCompleteRequest" - } - } - ], - "responses": { - "200": { - "description": " ", - "schema": { - "$ref": "#/definitions/models.RegistrationCompleteResponse" - } - }, - "403": { - "description": "Invalid email or verification code" - } - } - } - }, "/profile": { "patch": { "security": [ @@ -391,207 +140,6 @@ ], "responses": {} } - }, - "/service/health": { - "get": { - "description": "Used internally for checking service health", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Service" - ], - "summary": "Get health status", - "responses": { - "200": { - "description": "Says whether it's healthy or not", - "schema": { - "$ref": "#/definitions/controllers.HealthStatus" - } - } - } - } - } - }, - "definitions": { - "controllers.HealthStatus": { - "type": "object", - "properties": { - "healthy": { - "type": "boolean" - } - } - }, - "models.ChangePasswordRequest": { - "type": "object", - "required": [ - "old_password", - "password" - ], - "properties": { - "old_password": { - "type": "string" - }, - "password": { - "type": "string" - }, - "totp": { - "type": "string" - } - } - }, - "models.LoginRequest": { - "type": "object", - "required": [ - "password", - "username" - ], - "properties": { - "password": { - "type": "string", - "maxLength": 100 - }, - "totp": { - "type": "string" - }, - "username": { - "type": "string", - "maxLength": 20, - "minLength": 3 - } - } - }, - "models.LoginResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.PasswordResetBeginRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - } - } - }, - "models.PasswordResetCompleteRequest": { - "type": "object", - "required": [ - "email", - "password", - "verification_code" - ], - "properties": { - "email": { - "type": "string" - }, - "log_out_sessions": { - "type": "boolean" - }, - "password": { - "type": "string" - }, - "verification_code": { - "type": "string" - } - } - }, - "models.PasswordResetCompleteResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.RefreshRequest": { - "type": "object", - "required": [ - "refresh_token" - ], - "properties": { - "refresh_token": { - "type": "string" - } - } - }, - "models.RefreshResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } - }, - "models.RegistrationBeginRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "models.RegistrationCompleteRequest": { - "type": "object", - "required": [ - "name", - "username", - "verification_code" - ], - "properties": { - "birthday": { - "type": "string" - }, - "name": { - "type": "string" - }, - "username": { - "type": "string" - }, - "verification_code": { - "type": "string" - } - } - }, - "models.RegistrationCompleteResponse": { - "type": "object", - "properties": { - "access_token": { - "type": "string" - }, - "refresh_token": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 4454738..ba28971 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,122 +1,4 @@ basePath: /api/ -definitions: - controllers.HealthStatus: - properties: - healthy: - type: boolean - type: object - models.ChangePasswordRequest: - properties: - old_password: - type: string - password: - type: string - totp: - type: string - required: - - old_password - - password - type: object - models.LoginRequest: - properties: - password: - maxLength: 100 - type: string - totp: - type: string - username: - maxLength: 20 - minLength: 3 - type: string - required: - - password - - username - type: object - models.LoginResponse: - properties: - access_token: - type: string - refresh_token: - type: string - type: object - models.PasswordResetBeginRequest: - properties: - email: - type: string - required: - - email - type: object - models.PasswordResetCompleteRequest: - properties: - email: - type: string - log_out_sessions: - type: boolean - password: - type: string - verification_code: - type: string - required: - - email - - password - - verification_code - type: object - models.PasswordResetCompleteResponse: - properties: - access_token: - type: string - refresh_token: - type: string - type: object - models.RefreshRequest: - properties: - refresh_token: - type: string - required: - - refresh_token - type: object - models.RefreshResponse: - properties: - access_token: - type: string - refresh_token: - type: string - type: object - models.RegistrationBeginRequest: - properties: - email: - type: string - password: - type: string - username: - type: string - required: - - email - - password - - username - type: object - models.RegistrationCompleteRequest: - properties: - birthday: - type: string - name: - type: string - username: - type: string - verification_code: - type: string - required: - - name - - username - - verification_code - type: object - models.RegistrationCompleteResponse: - properties: - access_token: - type: string - refresh_token: - type: string - type: object info: contact: {} description: Easy and feature-rich wishlist. @@ -137,165 +19,6 @@ paths: summary: Change account password tags: - Account - /auth/changePassword: - post: - consumes: - - application/json - parameters: - - description: ' ' - in: body - name: request - required: true - schema: - $ref: '#/definitions/models.ChangePasswordRequest' - produces: - - application/json - responses: - "200": - description: Password successfully changed - "403": - description: Invalid old password - security: - - JWT: [] - summary: Set new password using the old password - tags: - - Auth - /auth/login: - post: - consumes: - - application/json - parameters: - - description: ' ' - in: body - name: request - required: true - schema: - $ref: '#/definitions/models.LoginRequest' - produces: - - application/json - responses: - "200": - description: ' ' - schema: - $ref: '#/definitions/models.LoginResponse' - "403": - description: Invalid login credentials - summary: Acquire tokens via login credentials (and 2FA code if needed) - tags: - - Auth - /auth/passwordResetBegin: - post: - consumes: - - application/json - parameters: - - description: ' ' - in: body - name: request - required: true - schema: - $ref: '#/definitions/models.PasswordResetBeginRequest' - produces: - - application/json - responses: - "200": - description: Reset code sent to the email if it is attached to an account - "429": - description: Too many recent requests for this email - summary: Request password reset email - tags: - - Auth - /auth/passwordResetComplete: - post: - consumes: - - application/json - parameters: - - description: ' ' - in: body - name: request - required: true - schema: - $ref: '#/definitions/models.PasswordResetCompleteRequest' - produces: - - application/json - responses: - "200": - description: ' ' - schema: - $ref: '#/definitions/models.PasswordResetCompleteResponse' - "403": - description: Wrong verification code or username - summary: Complete password reset via email code - tags: - - Auth - /auth/refresh: - post: - consumes: - - application/json - parameters: - - description: ' ' - in: body - name: request - required: true - schema: - $ref: '#/definitions/models.RefreshRequest' - produces: - - application/json - responses: - "200": - description: ' ' - schema: - $ref: '#/definitions/models.RefreshResponse' - "401": - description: Invalid refresh token - summary: Receive new tokens via refresh token - tags: - - Auth - /auth/registrationBegin: - post: - consumes: - - application/json - parameters: - - description: ' ' - in: body - name: request - required: true - schema: - $ref: '#/definitions/models.RegistrationBeginRequest' - produces: - - application/json - responses: - "200": - description: Account is created and awaiting verification - "409": - description: Username or email is already taken - "429": - description: Too many recent registration attempts for this email - summary: Register an account - tags: - - Auth - /auth/registrationComplete: - post: - consumes: - - application/json - parameters: - - description: ' ' - in: body - name: request - required: true - schema: - $ref: '#/definitions/models.RegistrationCompleteRequest' - produces: - - application/json - responses: - "200": - description: ' ' - schema: - $ref: '#/definitions/models.RegistrationCompleteResponse' - "403": - description: Invalid email or verification code - summary: Confirm with code, finish creating the account - tags: - - Auth /profile: patch: consumes: @@ -361,21 +84,6 @@ paths: summary: Update profile privacy settings tags: - Profile - /service/health: - get: - consumes: - - application/json - description: Used internally for checking service health - produces: - - application/json - responses: - "200": - description: Says whether it's healthy or not - schema: - $ref: '#/definitions/controllers.HealthStatus' - summary: Get health status - tags: - - Service schemes: - http securityDefinitions: diff --git a/backend/internal/controllers/account.go b/backend/internal/controllers/account.go index 3f2e1d3..22e3862 100644 --- a/backend/internal/controllers/account.go +++ b/backend/internal/controllers/account.go @@ -23,12 +23,12 @@ import ( "github.com/gin-gonic/gin" ) -// @Summary Change account password -// @Tags Account -// @Accept json -// @Produce json -// @Security JWT -// @Router /account/changePassword [put] +// @Summary Change account password +// @Tags Account +// @Accept json +// @Produce json +// @Security JWT +// @Router /account/changePassword [put] func ChangePassword(c *gin.Context) { c.Status(http.StatusNotImplemented) } diff --git a/backend/internal/controllers/auth.go b/backend/internal/controllers/auth.go index 8e82813..d21cc37 100644 --- a/backend/internal/controllers/auth.go +++ b/backend/internal/controllers/auth.go @@ -19,7 +19,6 @@ package controllers import ( errs "easywish/internal/errors" - "easywish/internal/middleware" "easywish/internal/models" "easywish/internal/services" "easywish/internal/utils" @@ -31,249 +30,258 @@ import ( "go.uber.org/zap" ) -type AuthController interface { - RegistrationBegin(c *gin.Context) - RegistrationComplete(c *gin.Context) - Login(c *gin.Context) - Refresh(c *gin.Context) - PasswordResetBegin(c *gin.Context) - PasswordResetComplete(c *gin.Context) - ChangePassword(c *gin.Context) - Router -} - -type authControllerImpl struct { - log *zap.Logger - auth services.AuthService -} - -func NewAuthController(_log *zap.Logger, _auth services.AuthService) AuthController { - return &authControllerImpl{log: _log, auth: _auth} -} - -// @Summary Acquire tokens via login credentials (and 2FA code if needed) -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body models.LoginRequest true " " -// @Success 200 {object} models.LoginResponse " " -// @Failure 403 "Invalid login credentials" -// @Router /auth/login [post] -func (a *authControllerImpl) Login(c *gin.Context) { - request, ok := utils.GetRequest[models.LoginRequest](c) - if !ok { - c.Status(http.StatusBadRequest) - return - } - - response, err := a.auth.Login(request.User, request.Body) - - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) -} - -// @Summary Request password reset email -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body models.PasswordResetBeginRequest true " " -// @Router /auth/passwordResetBegin [post] -// @Success 200 "Reset code sent to the email if it is attached to an account" -// @Failure 429 "Too many recent requests for this email" -func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) { - request, ok := utils.GetRequest[models.PasswordResetBeginRequest](c) - if !ok { - c.Status(http.StatusBadRequest) - return - } - - response, err := a.auth.PasswordResetBegin(request.Body) - if err != nil { - if errors.Is(err, errs.ErrTooManyRequests) { - c.Status(http.StatusTooManyRequests) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) -} - -// @Summary Complete password reset via email code -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body models.PasswordResetCompleteRequest true " " -// @Router /auth/passwordResetComplete [post] -// @Success 200 {object} models.PasswordResetCompleteResponse " " -// @Success 403 "Wrong verification code or username" -func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) { - - request, ok := utils.GetRequest[models.PasswordResetCompleteRequest](c) - if !ok { - c.Status(http.StatusBadRequest) - return - } - - response, err := a.auth.PasswordResetComplete(request.Body) - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) -} - - -// @Summary Receive new tokens via refresh token -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body models.RefreshRequest true " " -// @Router /auth/refresh [post] -// @Success 200 {object} models.RefreshResponse " " -// @Failure 401 "Invalid refresh token" -func (a *authControllerImpl) Refresh(c *gin.Context) { - - request, ok := utils.GetRequest[models.RefreshRequest](c) - if !ok { - c.Status(http.StatusBadRequest) - return - } - - response, err := a.auth.Refresh(request.Body) - if err != nil { - if utils.ErrorIsOneOf( - err, - errs.ErrTokenExpired, - errs.ErrTokenInvalid, - errs.ErrInvalidToken, - errs.ErrWrongTokenType, - errs.ErrSessionNotFound, - errs.ErrSessionTerminated, - ) { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) - } else { - c.JSON(http.StatusInternalServerError, err.Error()) - } - return - } - - c.JSON(http.StatusOK, response) -} - -// @Summary Register an account -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body models.RegistrationBeginRequest true " " -// @Success 200 "Account is created and awaiting verification" -// @Failure 409 "Username or email is already taken" -// @Failure 429 "Too many recent registration attempts for this email" -// @Router /auth/registrationBegin [post] -func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { - - request, ok := utils.GetRequest[models.RegistrationBeginRequest](c) - if !ok { - c.Status(http.StatusBadRequest) - return - } - - _, err := a.auth.RegistrationBegin(request.Body) - - if err != nil { - if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { - c.Status(http.StatusConflict) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.Status(http.StatusOK) - return -} - -// @Summary Confirm with code, finish creating the account -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body models.RegistrationCompleteRequest true " " -// @Success 200 {object} models.RegistrationCompleteResponse " " -// @Failure 403 "Invalid email or verification code" -// @Router /auth/registrationComplete [post] -func (a *authControllerImpl) RegistrationComplete(c *gin.Context) { - request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c) - if !ok { - c.Status(http.StatusBadRequest) - return - } - - response, err := a.auth.RegistrationComplete(request.User, request.Body) - - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else if errors.Is(err, errs.ErrUnauthorized) { - c.Status(http.StatusUnauthorized) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) -} - -// @Summary Set new password using the old password -// @Tags Auth -// @Accept json -// @Produce json -// @Security JWT -// @Param request body models.ChangePasswordRequest true " " -// @Success 200 "Password successfully changed" -// @Failure 403 "Invalid old password" -// @Router /auth/changePassword [post] -func (a *authControllerImpl) ChangePassword(c *gin.Context) { - request, ok := utils.GetRequest[models.ChangePasswordRequest](c) - if !ok { - c.Status(http.StatusBadRequest) - return - } - - response, err := a.auth.ChangePassword(request.Body, request.User) - - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) -} - -func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) { - group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin) - group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete) - group.POST("/login", middleware.RequestMiddleware[models.LoginRequest](enums.GuestRole), a.Login) - group.POST("/refresh", middleware.RequestMiddleware[models.RefreshRequest](enums.GuestRole), a.Refresh) - group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.PasswordResetBeginRequest](enums.GuestRole), a.PasswordResetBegin) - group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.PasswordResetCompleteRequest](enums.GuestRole), a.PasswordResetComplete) - group.POST("/changePassword", middleware.RequestMiddleware[models.ChangePasswordRequest](enums.UserRole), a.ChangePassword) +func NewAuthController(log *zap.Logger, auth services.AuthService) Controller { + return &controllerImpl{ + Path: "/auth", + Middleware: []gin.HandlerFunc{}, + Methods: []ControllerMethod{ + + // @Summary Register an account + // @Tags Auth + // @Accept json + // @Produce json + // @Param request body models.RegistrationBeginRequest true " " + // @Success 200 "Account is created and awaiting verification" + // @Failure 409 "Username or email is already taken" + // @Failure 429 "Too many recent registration attempts for this email" + // @Router /auth/registrationBegin [post] + { + HttpMethod: POST, + Path: "/registrationBegin", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { + + request, err := GetRequest[models.RegistrationBeginRequest](c); if err != nil { + return + } + + _, err = auth.RegistrationBegin(request.Body); if err != nil { + if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { + c.Status(http.StatusConflict) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.Status(http.StatusOK) + return + }, + }, + + // @Summary Confirm with code, finish creating the account + // @Tags Auth + // @Accept json + // @Produce json + // @Param request body models.RegistrationCompleteRequest true " " + // @Success 200 {object} models.RegistrationCompleteResponse " " + // @Failure 403 "Invalid email or verification code" + // @Router /auth/registrationComplete [post] + { + HttpMethod: POST, + Path: "/registrationComplete", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { + + request, err := GetRequest[models.RegistrationCompleteRequest](c); if err != nil { + return + } + + response, err := auth.RegistrationComplete(request.User, request.Body) + + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else if errors.Is(err, errs.ErrUnauthorized) { + c.Status(http.StatusUnauthorized) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) + }, + }, + + // @Summary Acquire tokens via login credentials (and 2FA code if needed) + // @Tags Auth + // @Accept json + // @Produce json + // @Param request body models.LoginRequest true " " + // @Success 200 {object} models.LoginResponse " " + // @Failure 403 "Invalid login credentials" + // @Router /auth/login [post] + { + HttpMethod: POST, + Path: "/login", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { + request, err := GetRequest[models.LoginRequest](c); if err != nil { + return + } + + response, err := auth.Login(request.User, request.Body) + + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) + }, + }, + + // @Summary Receive new tokens via refresh token + // @Tags Auth + // @Accept json + // @Produce json + // @Param request body models.RefreshRequest true " " + // @Router /auth/refresh [post] + // @Success 200 {object} models.RefreshResponse " " + // @Failure 401 "Invalid refresh token" + { + HttpMethod: POST, + Path: "/refresh", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { + + request, err := GetRequest[models.RefreshRequest](c); if err != nil { + return + } + + response, err := auth.Refresh(request.Body) + if err != nil { + if utils.ErrorIsOneOf( + err, + errs.ErrTokenExpired, + errs.ErrTokenInvalid, + errs.ErrInvalidToken, + errs.ErrWrongTokenType, + errs.ErrSessionNotFound, + errs.ErrSessionTerminated, + ) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + } else { + c.JSON(http.StatusInternalServerError, err.Error()) + } + return + } + + c.JSON(http.StatusOK, response) + }, + }, + + // @Summary Request password reset email + // @Tags Auth + // @Accept json + // @Produce json + // @Param request body models.PasswordResetBeginRequest true " " + // @Router /auth/passwordResetBegin [post] + // @Success 200 "Reset code sent to the email if it is attached to an account" + // @Failure 429 "Too many recent requests for this email" + { + HttpMethod: POST, + Path: "/passwordResetBegin", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { + + request, err := GetRequest[models.PasswordResetBeginRequest](c); if err != nil { + return + } + + response, err := auth.PasswordResetBegin(request.Body) + if err != nil { + if errors.Is(err, errs.ErrTooManyRequests) { + c.Status(http.StatusTooManyRequests) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) + }, + }, + + + // @Summary Complete password reset via email code + // @Tags Auth + // @Accept json + // @Produce json + // @Param request body models.PasswordResetCompleteRequest true " " + // @Router /auth/passwordResetComplete [post] + // @Success 200 {object} models.PasswordResetCompleteResponse " " + // @Success 403 "Wrong verification code or username" + { + HttpMethod: POST, + Path: "/passwordResetComplete", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { + + request, err := GetRequest[models.PasswordResetCompleteRequest](c); if err != nil { + return + } + + response, err := auth.PasswordResetComplete(request.Body) + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) + }, + }, + + // @Summary Set new password using the old password + // @Tags Auth + // @Accept json + // @Produce json + // @Security JWT + // @Param request body models.ChangePasswordRequest true " " + // @Success 200 "Password successfully changed" + // @Failure 403 "Invalid old password" + // @Router /auth/changePassword [post] + { + HttpMethod: POST, + Path: "/changePassword", + Authorization: enums.UserRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { + + request, err := GetRequest[models.ChangePasswordRequest](c); if err != nil { + return + } + + response, err := auth.ChangePassword(request.Body, request.User) + + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) + }, + }, + + }, + } } diff --git a/backend/internal/controllers/controller.go b/backend/internal/controllers/controller.go index 15e9195..63de177 100644 --- a/backend/internal/controllers/controller.go +++ b/backend/internal/controllers/controller.go @@ -19,7 +19,6 @@ package controllers import ( "easywish/internal/dto" - "easywish/internal/middleware" "easywish/internal/services" "easywish/internal/utils/enums" "easywish/internal/validation" @@ -48,39 +47,16 @@ type ControllerMethod struct { type controllerImpl struct { Path string - Authorization enums.Role Middleware []gin.HandlerFunc Methods []ControllerMethod } -func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) *gin.RouterGroup { +type Controller interface { + Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) +} + +func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) { ctrlGroup := group.Group(ctrl.Path) - ctrlGroup.Use(middleware.AuthMiddleware(log, auth)) - ctrlGroup.Use(gin.HandlerFunc(func(c *gin.Context) { - ip := c.ClientIP() - userAgent := c.Request.UserAgent() - sessionInfoFromCtx, ok := c.Get("session_info"); if !ok { - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid or missing session data"}) - return - } - - sessionInfo := sessionInfoFromCtx.(dto.SessionInfo) - - if sessionInfo.Role < ctrl.Authorization { - c.AbortWithStatusJSON( - http.StatusForbidden, - gin.H{"error": "Insufficient authorization for this controller"}) - return - } - - c.Set("client_info", dto.ClientInfo{ - SessionInfo: sessionInfo, - IP: ip, - UserAgent: userAgent, - }) - - c.Next() - })) ctrlGroup.Use(ctrl.Middleware...) for _, method := range ctrl.Methods { @@ -100,11 +76,6 @@ func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth }), method.Function)..., )} - return ctrlGroup -} - -type Controller interface { - Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) } func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { diff --git a/backend/internal/controllers/profile.go b/backend/internal/controllers/profile.go index 5a4fbd8..9d3d1cc 100644 --- a/backend/internal/controllers/profile.go +++ b/backend/internal/controllers/profile.go @@ -39,53 +39,53 @@ func NewProfileController() ProfileController { return &profileControllerImpl{} } -// @Summary Get someone's profile details -// @Tags Profile -// @Accept json -// @Produce json -// @Param username path string true "Username" -// @Security JWT -// @Router /profile/{username} [get] +// @Summary Get someone's profile details +// @Tags Profile +// @Accept json +// @Produce json +// @Param username path string true "Username" +// @Security JWT +// @Router /profile/{username} [get] func (p *profileControllerImpl) GetProfile(c *gin.Context) { c.Status(http.StatusNotImplemented) } -// @Summary Get own profile when authorized -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile/me [get] +// @Summary Get own profile when authorized +// @Tags Profile +// @Accept json +// @Produce json +// @Security JWT +// @Router /profile/me [get] func (p *profileControllerImpl) GetOwnProfile(c *gin.Context) { c.Status(http.StatusNotImplemented) } -// @Summary Update profile -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile [patch] +// @Summary Update profile +// @Tags Profile +// @Accept json +// @Produce json +// @Security JWT +// @Router /profile [patch] func (p *profileControllerImpl) UpdateProfile(c *gin.Context) { c.Status(http.StatusNotImplemented) } -// @Summary Get profile privacy settings -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile/privacy [get] +// @Summary Get profile privacy settings +// @Tags Profile +// @Accept json +// @Produce json +// @Security JWT +// @Router /profile/privacy [get] func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) { c.Status(http.StatusNotImplemented) } -// @Summary Update profile privacy settings -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile/privacy [patch] +// @Summary Update profile privacy settings +// @Tags Profile +// @Accept json +// @Produce json +// @Security JWT +// @Router /profile/privacy [patch] func (p *profileControllerImpl) UpdatePrivacySettings(c *gin.Context) { c.Status(http.StatusNotImplemented) } diff --git a/backend/internal/controllers/service.go b/backend/internal/controllers/service.go index d0012a4..c19be38 100644 --- a/backend/internal/controllers/service.go +++ b/backend/internal/controllers/service.go @@ -1,56 +1,56 @@ // Copyright (c) 2025 Nikolai Papin -// +// // This file is part of Easywish -// +// // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. -// +// // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // the GNU General Public License for more details. -// +// // You should have received a copy of the GNU General Public License // along with this program. If not, see . package controllers import ( + "easywish/internal/models" + "easywish/internal/utils/enums" "net/http" "github.com/gin-gonic/gin" ) -type ServiceController interface { - HealthCheck(c *gin.Context) - Router -} +func NewServiceController() Controller { + return &controllerImpl{ + Path: "/service", + Middleware: []gin.HandlerFunc{}, + Methods: []ControllerMethod{ -type serviceControllerImpl struct{} + // Health godoc + // @Summary Get health status + // @Description Used internally for checking service health + // @Tags Service + // @Accept json + // @Produce json + // @Success 200 {object} HealthStatus "Says whether it's healthy or not" + // @Router /service/health [get] + { + HttpMethod: GET, + Path: "/health", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: func(c *gin.Context) { -func NewServiceController() ServiceController { - return &serviceControllerImpl{} -} + c.JSON(http.StatusOK, models.HealthStatusResponse{Healthy: true,}) -// HealthCheck implements ServiceController. -// @Summary Get health status -// @Description Used internally for checking service health -// @Tags Service -// @Accept json -// @Produce json -// @Success 200 {object} HealthStatus "Says whether it's healthy or not" -// @Router /service/health [get] -func (s *serviceControllerImpl) HealthCheck(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"healthy": true}) -} + }, + }, -// RegisterRoutes implements ServiceController. -func (s *serviceControllerImpl) RegisterRoutes(group *gin.RouterGroup) { - group.GET("/health", s.HealthCheck) -} - -type HealthStatus struct { - Healthy bool `json:"healthy"` + }, + } } diff --git a/backend/internal/controllers/setup.go b/backend/internal/controllers/setup.go index 5f4ba0a..9816a9f 100644 --- a/backend/internal/controllers/setup.go +++ b/backend/internal/controllers/setup.go @@ -1,30 +1,72 @@ // Copyright (c) 2025 Nikolai Papin -// +// // This file is part of Easywish -// +// // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. -// +// // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // the GNU General Public License for more details. -// +// // You should have received a copy of the GNU General Public License // along with this program. If not, see . package controllers import ( + "easywish/internal/dto" + "easywish/internal/middleware" + "easywish/internal/services" + "net/http" + + "github.com/gin-gonic/gin" "go.uber.org/fx" + "go.uber.org/zap" ) +type SetupControllersParams struct { + fx.In + Controllers []Controller `group:"controllers"` + Log *zap.Logger + Auth services.AuthService + Group *gin.Engine +} + +func setupControllers(p SetupControllersParams) { + + apiGroup := p.Group.Group("/api") + apiGroup.Use(middleware.AuthMiddleware(p.Log, p.Auth)) + apiGroup.Use(gin.HandlerFunc(func(c *gin.Context) { + ip := c.ClientIP() + userAgent := c.Request.UserAgent() + sessionInfoFromCtx, ok := c.Get("session_info"); if !ok { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid or missing session data"}) + return + } + + sessionInfo := sessionInfoFromCtx.(dto.SessionInfo) + + c.Set("client_info", dto.ClientInfo{ + SessionInfo: sessionInfo, + IP: ip, + UserAgent: userAgent, + }) + + c.Next() + })) + for _, ctrl := range p.Controllers { + ctrl.Setup(apiGroup, p.Log, p.Auth) + } +} + var Module = fx.Module("controllers", fx.Provide( - NewServiceController, - NewAuthController, - NewProfileController, - ), + fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)), + fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)), + ), + fx.Invoke(setupControllers), ) diff --git a/backend/internal/middleware/request.go b/backend/internal/middleware/request.go index a729644..7ebfa2a 100644 --- a/backend/internal/middleware/request.go +++ b/backend/internal/middleware/request.go @@ -30,6 +30,7 @@ import ( const requestKey = "request" +// Deprecated: no longer used, embedded into controllers.GetRequest instead func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) { var ok bool @@ -58,10 +59,12 @@ func ClientInfoFromContext(c *gin.Context) (*dto.ClientInfo, bool) { }, true } +// Deprecated: no longer used, see controllers.GetRequest func RequestFromContext[T any](c *gin.Context) dto.Request[T] { return c.Value(requestKey).(dto.Request[T]) } +// Deprecated: no longer used, see controllers.GetRequest func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc { return gin.HandlerFunc(func(c *gin.Context) { diff --git a/backend/internal/models/service.go b/backend/internal/models/service.go new file mode 100644 index 0000000..373b050 --- /dev/null +++ b/backend/internal/models/service.go @@ -0,0 +1,22 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package models + +type HealthStatusResponse struct { + Healthy bool `json:"healthy"` +} diff --git a/backend/internal/routes/router.go b/backend/internal/routes/router.go index 7a263f7..e211a14 100644 --- a/backend/internal/routes/router.go +++ b/backend/internal/routes/router.go @@ -26,6 +26,7 @@ import ( "go.uber.org/zap" ) +// Deprecated: no longer used, see controllers func NewRouter(engine *gin.Engine, log *zap.Logger, auth services.AuthService, groups []RouteGroup) *gin.Engine { apiGroup := engine.Group("/api") apiGroup.Use(middleware.AuthMiddleware(log, auth)) @@ -37,29 +38,30 @@ func NewRouter(engine *gin.Engine, log *zap.Logger, auth services.AuthService, g return engine } +// Deprecated: no longer used, see controllers type RouteGroup struct { BasePath string Middleware []gin.HandlerFunc Router controllers.Router } -func NewRouteGroups( - authController controllers.AuthController, - serviceController controllers.ServiceController, - profileController controllers.ProfileController, -) []RouteGroup { - return []RouteGroup{ - { - BasePath: "/auth", - Router: authController, - }, - { - BasePath: "/service", - Router: serviceController, - }, - { - BasePath: "/profile", - Router: profileController, - }, - } -} +// func NewRouteGroups( +// authController controllers.AuthController, +// serviceController controllers.ServiceController, +// profileController controllers.ProfileController, +// ) []RouteGroup { +// return []RouteGroup{ +// { +// BasePath: "/auth", +// Router: authController, +// }, +// { +// BasePath: "/service", +// Router: serviceController, +// }, +// { +// BasePath: "/profile", +// Router: profileController, +// }, +// } +// } diff --git a/backend/internal/routes/setup.go b/backend/internal/routes/setup.go index 5a1e8f7..03a8c37 100644 --- a/backend/internal/routes/setup.go +++ b/backend/internal/routes/setup.go @@ -21,9 +21,10 @@ import ( "go.uber.org/fx" ) +// Deprecated: no longer used, see controllers var Module = fx.Module("routes", - fx.Provide( - NewRouteGroups, - ), - fx.Invoke(NewRouter), + // fx.Provide( + // NewRouteGroups, + // ), + // fx.Invoke(NewRouter), ) diff --git a/backend/internal/utils/request.go b/backend/internal/utils/request.go index 81e3195..dcec554 100644 --- a/backend/internal/utils/request.go +++ b/backend/internal/utils/request.go @@ -22,6 +22,7 @@ import ( "github.com/gin-gonic/gin" ) +// Deprecated: use controllers.GetRequest method for the new controllers func GetRequest[T any](c *gin.Context) (*dto.Request[T], bool) { req, ok := c.Get("request") From f2753e14956fc9bf5bdcada76522941773285c95 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Thu, 17 Jul 2025 22:37:07 +0300 Subject: [PATCH 4/4] refactor: declaring controller methods externally because the big idiot swaggo does not want to work unless the comments are attached to a gin handler func; fix: swagger docs work now; chore: remove incomplete account and profile controllers; fix: correct client info type in request middleware --- backend/docs/docs.go | 453 ++++++++++++++++++++ backend/docs/swagger.json | 453 ++++++++++++++++++++ backend/docs/swagger.yaml | 293 +++++++++++++ backend/internal/controllers/account.go | 34 -- backend/internal/controllers/auth.go | 464 +++++++++++---------- backend/internal/controllers/controller.go | 4 +- backend/internal/controllers/profile.go | 94 ----- backend/internal/controllers/router.go | 26 -- backend/internal/controllers/service.go | 30 +- 9 files changed, 1456 insertions(+), 395 deletions(-) delete mode 100644 backend/internal/controllers/account.go delete mode 100644 backend/internal/controllers/profile.go delete mode 100644 backend/internal/controllers/router.go diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 9da7489..eb5e1dd 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -38,6 +38,257 @@ const docTemplate = `{ "responses": {} } }, + "/auth/changePassword": { + "post": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Set new password using the old password", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ChangePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "Password successfully changed" + }, + "403": { + "description": "Invalid old password" + } + } + } + }, + "/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Acquire tokens via login credentials (and 2FA code if needed)", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.LoginResponse" + } + }, + "403": { + "description": "Invalid login credentials" + } + } + } + }, + "/auth/passwordResetBegin": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Request password reset email", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetBeginRequest" + } + } + ], + "responses": { + "200": { + "description": "Reset code sent to the email if it is attached to an account" + }, + "429": { + "description": "Too many recent requests for this email" + } + } + } + }, + "/auth/passwordResetComplete": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Complete password reset via email code", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteResponse" + } + }, + "403": { + "description": "Wrong verification code or username" + } + } + } + }, + "/auth/refresh": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Receive new tokens via refresh token", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.RefreshResponse" + } + }, + "401": { + "description": "Invalid refresh token" + } + } + } + }, + "/auth/registrationBegin": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register an account", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RegistrationBeginRequest" + } + } + ], + "responses": { + "200": { + "description": "Account is created and awaiting verification" + }, + "409": { + "description": "Username or email is already taken" + }, + "429": { + "description": "Too many recent registration attempts for this email" + } + } + } + }, + "/auth/registrationComplete": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Confirm with code, finish creating the account", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteResponse" + } + }, + "403": { + "description": "Invalid email or verification code" + } + } + } + }, "/profile": { "patch": { "security": [ @@ -144,6 +395,208 @@ const docTemplate = `{ ], "responses": {} } + }, + "/service/health": { + "get": { + "description": "Used internally for checking service health", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Service" + ], + "summary": "Get health status", + "responses": { + "200": { + "description": "Says whether it's healthy or not", + "schema": { + "$ref": "#/definitions/models.HealthStatusResponse" + } + } + } + } + } + }, + "definitions": { + "models.ChangePasswordRequest": { + "type": "object", + "required": [ + "old_password", + "password" + ], + "properties": { + "old_password": { + "type": "string" + }, + "password": { + "type": "string" + }, + "totp": { + "type": "string" + } + } + }, + "models.HealthStatusResponse": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + } + } + }, + "models.LoginRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "password": { + "type": "string", + "maxLength": 100 + }, + "totp": { + "type": "string" + }, + "username": { + "type": "string", + "maxLength": 20, + "minLength": 3 + } + } + }, + "models.LoginResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.PasswordResetBeginRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteRequest": { + "type": "object", + "required": [ + "email", + "password", + "verification_code" + ], + "properties": { + "email": { + "type": "string" + }, + "log_out_sessions": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "maxLength": 2000 + } + } + }, + "models.RefreshResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.RegistrationBeginRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "models.RegistrationCompleteRequest": { + "type": "object", + "required": [ + "name", + "username", + "verification_code" + ], + "properties": { + "birthday": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + } + }, + "models.RegistrationCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index c2c6d2a..4f37e72 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -34,6 +34,257 @@ "responses": {} } }, + "/auth/changePassword": { + "post": { + "security": [ + { + "JWT": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Set new password using the old password", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.ChangePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "Password successfully changed" + }, + "403": { + "description": "Invalid old password" + } + } + } + }, + "/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Acquire tokens via login credentials (and 2FA code if needed)", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.LoginResponse" + } + }, + "403": { + "description": "Invalid login credentials" + } + } + } + }, + "/auth/passwordResetBegin": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Request password reset email", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetBeginRequest" + } + } + ], + "responses": { + "200": { + "description": "Reset code sent to the email if it is attached to an account" + }, + "429": { + "description": "Too many recent requests for this email" + } + } + } + }, + "/auth/passwordResetComplete": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Complete password reset via email code", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteResponse" + } + }, + "403": { + "description": "Wrong verification code or username" + } + } + } + }, + "/auth/refresh": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Receive new tokens via refresh token", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.RefreshResponse" + } + }, + "401": { + "description": "Invalid refresh token" + } + } + } + }, + "/auth/registrationBegin": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register an account", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RegistrationBeginRequest" + } + } + ], + "responses": { + "200": { + "description": "Account is created and awaiting verification" + }, + "409": { + "description": "Username or email is already taken" + }, + "429": { + "description": "Too many recent registration attempts for this email" + } + } + } + }, + "/auth/registrationComplete": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Confirm with code, finish creating the account", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteResponse" + } + }, + "403": { + "description": "Invalid email or verification code" + } + } + } + }, "/profile": { "patch": { "security": [ @@ -140,6 +391,208 @@ ], "responses": {} } + }, + "/service/health": { + "get": { + "description": "Used internally for checking service health", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Service" + ], + "summary": "Get health status", + "responses": { + "200": { + "description": "Says whether it's healthy or not", + "schema": { + "$ref": "#/definitions/models.HealthStatusResponse" + } + } + } + } + } + }, + "definitions": { + "models.ChangePasswordRequest": { + "type": "object", + "required": [ + "old_password", + "password" + ], + "properties": { + "old_password": { + "type": "string" + }, + "password": { + "type": "string" + }, + "totp": { + "type": "string" + } + } + }, + "models.HealthStatusResponse": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + } + } + }, + "models.LoginRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "password": { + "type": "string", + "maxLength": 100 + }, + "totp": { + "type": "string" + }, + "username": { + "type": "string", + "maxLength": 20, + "minLength": 3 + } + } + }, + "models.LoginResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.PasswordResetBeginRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteRequest": { + "type": "object", + "required": [ + "email", + "password", + "verification_code" + ], + "properties": { + "email": { + "type": "string" + }, + "log_out_sessions": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "maxLength": 2000 + } + } + }, + "models.RefreshResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.RegistrationBeginRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "models.RegistrationCompleteRequest": { + "type": "object", + "required": [ + "name", + "username", + "verification_code" + ], + "properties": { + "birthday": { + "type": "string" + }, + "name": { + "type": "string" + }, + "username": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + } + }, + "models.RegistrationCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index ba28971..39f35c1 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,4 +1,123 @@ basePath: /api/ +definitions: + models.ChangePasswordRequest: + properties: + old_password: + type: string + password: + type: string + totp: + type: string + required: + - old_password + - password + type: object + models.HealthStatusResponse: + properties: + healthy: + type: boolean + type: object + models.LoginRequest: + properties: + password: + maxLength: 100 + type: string + totp: + type: string + username: + maxLength: 20 + minLength: 3 + type: string + required: + - password + - username + type: object + models.LoginResponse: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + models.PasswordResetBeginRequest: + properties: + email: + type: string + required: + - email + type: object + models.PasswordResetCompleteRequest: + properties: + email: + type: string + log_out_sessions: + type: boolean + password: + type: string + verification_code: + type: string + required: + - email + - password + - verification_code + type: object + models.PasswordResetCompleteResponse: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + models.RefreshRequest: + properties: + refresh_token: + maxLength: 2000 + type: string + required: + - refresh_token + type: object + models.RefreshResponse: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + models.RegistrationBeginRequest: + properties: + email: + type: string + password: + type: string + username: + type: string + required: + - email + - password + - username + type: object + models.RegistrationCompleteRequest: + properties: + birthday: + type: string + name: + type: string + username: + type: string + verification_code: + type: string + required: + - name + - username + - verification_code + type: object + models.RegistrationCompleteResponse: + properties: + access_token: + type: string + refresh_token: + type: string + type: object info: contact: {} description: Easy and feature-rich wishlist. @@ -19,6 +138,165 @@ paths: summary: Change account password tags: - Account + /auth/changePassword: + post: + consumes: + - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.ChangePasswordRequest' + produces: + - application/json + responses: + "200": + description: Password successfully changed + "403": + description: Invalid old password + security: + - JWT: [] + summary: Set new password using the old password + tags: + - Auth + /auth/login: + post: + consumes: + - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.LoginRequest' + produces: + - application/json + responses: + "200": + description: ' ' + schema: + $ref: '#/definitions/models.LoginResponse' + "403": + description: Invalid login credentials + summary: Acquire tokens via login credentials (and 2FA code if needed) + tags: + - Auth + /auth/passwordResetBegin: + post: + consumes: + - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.PasswordResetBeginRequest' + produces: + - application/json + responses: + "200": + description: Reset code sent to the email if it is attached to an account + "429": + description: Too many recent requests for this email + summary: Request password reset email + tags: + - Auth + /auth/passwordResetComplete: + post: + consumes: + - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.PasswordResetCompleteRequest' + produces: + - application/json + responses: + "200": + description: ' ' + schema: + $ref: '#/definitions/models.PasswordResetCompleteResponse' + "403": + description: Wrong verification code or username + summary: Complete password reset via email code + tags: + - Auth + /auth/refresh: + post: + consumes: + - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.RefreshRequest' + produces: + - application/json + responses: + "200": + description: ' ' + schema: + $ref: '#/definitions/models.RefreshResponse' + "401": + description: Invalid refresh token + summary: Receive new tokens via refresh token + tags: + - Auth + /auth/registrationBegin: + post: + consumes: + - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.RegistrationBeginRequest' + produces: + - application/json + responses: + "200": + description: Account is created and awaiting verification + "409": + description: Username or email is already taken + "429": + description: Too many recent registration attempts for this email + summary: Register an account + tags: + - Auth + /auth/registrationComplete: + post: + consumes: + - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.RegistrationCompleteRequest' + produces: + - application/json + responses: + "200": + description: ' ' + schema: + $ref: '#/definitions/models.RegistrationCompleteResponse' + "403": + description: Invalid email or verification code + summary: Confirm with code, finish creating the account + tags: + - Auth /profile: patch: consumes: @@ -84,6 +362,21 @@ paths: summary: Update profile privacy settings tags: - Profile + /service/health: + get: + consumes: + - application/json + description: Used internally for checking service health + produces: + - application/json + responses: + "200": + description: Says whether it's healthy or not + schema: + $ref: '#/definitions/models.HealthStatusResponse' + summary: Get health status + tags: + - Service schemes: - http securityDefinitions: diff --git a/backend/internal/controllers/account.go b/backend/internal/controllers/account.go deleted file mode 100644 index 22e3862..0000000 --- a/backend/internal/controllers/account.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2025 Nikolai Papin -// -// This file is part of Easywish -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See -// the GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -package controllers - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// @Summary Change account password -// @Tags Account -// @Accept json -// @Produce json -// @Security JWT -// @Router /account/changePassword [put] -func ChangePassword(c *gin.Context) { - c.Status(http.StatusNotImplemented) -} diff --git a/backend/internal/controllers/auth.go b/backend/internal/controllers/auth.go index d21cc37..0c264fc 100644 --- a/backend/internal/controllers/auth.go +++ b/backend/internal/controllers/auth.go @@ -30,258 +30,270 @@ import ( "go.uber.org/zap" ) +type AuthController struct { + auth services.AuthService + log *zap.Logger +} + func NewAuthController(log *zap.Logger, auth services.AuthService) Controller { + ctrl := &AuthController{auth: auth, log: log} + return &controllerImpl{ Path: "/auth", Middleware: []gin.HandlerFunc{}, Methods: []ControllerMethod{ - - // @Summary Register an account - // @Tags Auth - // @Accept json - // @Produce json - // @Param request body models.RegistrationBeginRequest true " " - // @Success 200 "Account is created and awaiting verification" - // @Failure 409 "Username or email is already taken" - // @Failure 429 "Too many recent registration attempts for this email" - // @Router /auth/registrationBegin [post] { - HttpMethod: POST, - Path: "/registrationBegin", + HttpMethod: POST, + Path: "/registrationBegin", Authorization: enums.GuestRole, - Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - - request, err := GetRequest[models.RegistrationBeginRequest](c); if err != nil { - return - } - - _, err = auth.RegistrationBegin(request.Body); if err != nil { - if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { - c.Status(http.StatusConflict) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.Status(http.StatusOK) - return - }, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.registrationBeginHandler, }, - - // @Summary Confirm with code, finish creating the account - // @Tags Auth - // @Accept json - // @Produce json - // @Param request body models.RegistrationCompleteRequest true " " - // @Success 200 {object} models.RegistrationCompleteResponse " " - // @Failure 403 "Invalid email or verification code" - // @Router /auth/registrationComplete [post] { - HttpMethod: POST, - Path: "/registrationComplete", + HttpMethod: POST, + Path: "/registrationComplete", Authorization: enums.GuestRole, - Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - - request, err := GetRequest[models.RegistrationCompleteRequest](c); if err != nil { - return - } - - response, err := auth.RegistrationComplete(request.User, request.Body) - - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else if errors.Is(err, errs.ErrUnauthorized) { - c.Status(http.StatusUnauthorized) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) - }, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.registrationCompleteHandler, }, - - // @Summary Acquire tokens via login credentials (and 2FA code if needed) - // @Tags Auth - // @Accept json - // @Produce json - // @Param request body models.LoginRequest true " " - // @Success 200 {object} models.LoginResponse " " - // @Failure 403 "Invalid login credentials" - // @Router /auth/login [post] { - HttpMethod: POST, - Path: "/login", + HttpMethod: POST, + Path: "/login", Authorization: enums.GuestRole, - Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - request, err := GetRequest[models.LoginRequest](c); if err != nil { - return - } - - response, err := auth.Login(request.User, request.Body) - - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) - }, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.loginHandler, }, - - // @Summary Receive new tokens via refresh token - // @Tags Auth - // @Accept json - // @Produce json - // @Param request body models.RefreshRequest true " " - // @Router /auth/refresh [post] - // @Success 200 {object} models.RefreshResponse " " - // @Failure 401 "Invalid refresh token" { - HttpMethod: POST, - Path: "/refresh", + HttpMethod: POST, + Path: "/refresh", Authorization: enums.GuestRole, - Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - - request, err := GetRequest[models.RefreshRequest](c); if err != nil { - return - } - - response, err := auth.Refresh(request.Body) - if err != nil { - if utils.ErrorIsOneOf( - err, - errs.ErrTokenExpired, - errs.ErrTokenInvalid, - errs.ErrInvalidToken, - errs.ErrWrongTokenType, - errs.ErrSessionNotFound, - errs.ErrSessionTerminated, - ) { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) - } else { - c.JSON(http.StatusInternalServerError, err.Error()) - } - return - } - - c.JSON(http.StatusOK, response) - }, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.refreshHandler, }, - - // @Summary Request password reset email - // @Tags Auth - // @Accept json - // @Produce json - // @Param request body models.PasswordResetBeginRequest true " " - // @Router /auth/passwordResetBegin [post] - // @Success 200 "Reset code sent to the email if it is attached to an account" - // @Failure 429 "Too many recent requests for this email" { - HttpMethod: POST, - Path: "/passwordResetBegin", + HttpMethod: POST, + Path: "/passwordResetBegin", Authorization: enums.GuestRole, - Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - - request, err := GetRequest[models.PasswordResetBeginRequest](c); if err != nil { - return - } - - response, err := auth.PasswordResetBegin(request.Body) - if err != nil { - if errors.Is(err, errs.ErrTooManyRequests) { - c.Status(http.StatusTooManyRequests) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) - }, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.passwordResetBeginHandler, }, - - - // @Summary Complete password reset via email code - // @Tags Auth - // @Accept json - // @Produce json - // @Param request body models.PasswordResetCompleteRequest true " " - // @Router /auth/passwordResetComplete [post] - // @Success 200 {object} models.PasswordResetCompleteResponse " " - // @Success 403 "Wrong verification code or username" { - HttpMethod: POST, - Path: "/passwordResetComplete", + HttpMethod: POST, + Path: "/passwordResetComplete", Authorization: enums.GuestRole, - Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - - request, err := GetRequest[models.PasswordResetCompleteRequest](c); if err != nil { - return - } - - response, err := auth.PasswordResetComplete(request.Body) - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) - }, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.passwordResetCompleteHandler, }, - - // @Summary Set new password using the old password - // @Tags Auth - // @Accept json - // @Produce json - // @Security JWT - // @Param request body models.ChangePasswordRequest true " " - // @Success 200 "Password successfully changed" - // @Failure 403 "Invalid old password" - // @Router /auth/changePassword [post] { - HttpMethod: POST, - Path: "/changePassword", + HttpMethod: POST, + Path: "/changePassword", Authorization: enums.UserRole, - Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - - request, err := GetRequest[models.ChangePasswordRequest](c); if err != nil { - return - } - - response, err := auth.ChangePassword(request.Body, request.User) - - if err != nil { - if errors.Is(err, errs.ErrForbidden) { - c.Status(http.StatusForbidden) - } else { - c.Status(http.StatusInternalServerError) - } - return - } - - c.JSON(http.StatusOK, response) - }, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.changePasswordHandler, }, - }, } } + +// @Summary Register an account +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body models.RegistrationBeginRequest true " " +// @Success 200 "Account is created and awaiting verification" +// @Failure 409 "Username or email is already taken" +// @Failure 429 "Too many recent registration attempts for this email" +// @Router /auth/registrationBegin [post] +func (ctrl *AuthController) registrationBeginHandler(c *gin.Context) { + request, err := GetRequest[models.RegistrationBeginRequest](c) + if err != nil { + return + } + + _, err = ctrl.auth.RegistrationBegin(request.Body) + if err != nil { + if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { + c.Status(http.StatusConflict) + } else if errors.Is(err, errs.ErrTooManyRequests) { + c.Status(http.StatusTooManyRequests) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.Status(http.StatusOK) +} + +// @Summary Confirm with code, finish creating the account +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body models.RegistrationCompleteRequest true " " +// @Success 200 {object} models.RegistrationCompleteResponse " " +// @Failure 403 "Invalid email or verification code" +// @Router /auth/registrationComplete [post] +func (ctrl *AuthController) registrationCompleteHandler(c *gin.Context) { + request, err := GetRequest[models.RegistrationCompleteRequest](c) + if err != nil { + return + } + + response, err := ctrl.auth.RegistrationComplete(request.User, request.Body) + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else if errors.Is(err, errs.ErrUnauthorized) { + c.Status(http.StatusUnauthorized) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// @Summary Acquire tokens via login credentials (and 2FA code if needed) +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body models.LoginRequest true " " +// @Success 200 {object} models.LoginResponse " " +// @Failure 403 "Invalid login credentials" +// @Router /auth/login [post] +func (ctrl *AuthController) loginHandler(c *gin.Context) { + request, err := GetRequest[models.LoginRequest](c) + if err != nil { + return + } + + response, err := ctrl.auth.Login(request.User, request.Body) + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// @Summary Receive new tokens via refresh token +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body models.RefreshRequest true " " +// @Router /auth/refresh [post] +// @Success 200 {object} models.RefreshResponse " " +// @Failure 401 "Invalid refresh token" +func (ctrl *AuthController) refreshHandler(c *gin.Context) { + request, err := GetRequest[models.RefreshRequest](c) + if err != nil { + return + } + + response, err := ctrl.auth.Refresh(request.Body) + if err != nil { + if utils.ErrorIsOneOf( + err, + errs.ErrTokenExpired, + errs.ErrTokenInvalid, + errs.ErrInvalidToken, + errs.ErrWrongTokenType, + errs.ErrSessionNotFound, + errs.ErrSessionTerminated, + ) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// @Summary Request password reset email +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body models.PasswordResetBeginRequest true " " +// @Router /auth/passwordResetBegin [post] +// @Success 200 "Reset code sent to the email if it is attached to an account" +// @Failure 429 "Too many recent requests for this email" +func (ctrl *AuthController) passwordResetBeginHandler(c *gin.Context) { + request, err := GetRequest[models.PasswordResetBeginRequest](c) + if err != nil { + return + } + + _, err = ctrl.auth.PasswordResetBegin(request.Body) + if err != nil { + if errors.Is(err, errs.ErrTooManyRequests) { + c.Status(http.StatusTooManyRequests) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.Status(http.StatusOK) +} + +// @Summary Complete password reset via email code +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body models.PasswordResetCompleteRequest true " " +// @Router /auth/passwordResetComplete [post] +// @Success 200 {object} models.PasswordResetCompleteResponse " " +// @Success 403 "Wrong verification code or username" +func (ctrl *AuthController) passwordResetCompleteHandler(c *gin.Context) { + request, err := GetRequest[models.PasswordResetCompleteRequest](c) + if err != nil { + return + } + + response, err := ctrl.auth.PasswordResetComplete(request.Body) + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// @Summary Set new password using the old password +// @Tags Auth +// @Accept json +// @Produce json +// @Security JWT +// @Param request body models.ChangePasswordRequest true " " +// @Success 200 "Password successfully changed" +// @Failure 403 "Invalid old password" +// @Router /auth/changePassword [post] +func (ctrl *AuthController) changePasswordHandler(c *gin.Context) { + request, err := GetRequest[models.ChangePasswordRequest](c) + if err != nil { + return + } + + _, err = ctrl.auth.ChangePassword(request.Body, request.User) + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.Status(http.StatusOK) +} diff --git a/backend/internal/controllers/controller.go b/backend/internal/controllers/controller.go index 63de177..e9d3a85 100644 --- a/backend/internal/controllers/controller.go +++ b/backend/internal/controllers/controller.go @@ -103,10 +103,10 @@ func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { gin.H{"error": "Client info was not found"}) panic("No client_info found in gin context. Does the handler use AuthMiddleware?") } - cinfo := cinfoFromCtx.(*dto.ClientInfo) + cinfo := cinfoFromCtx.(dto.ClientInfo) return &dto.Request[ModelT]{ Body: body, - User: *cinfo, + User: cinfo, }, nil } diff --git a/backend/internal/controllers/profile.go b/backend/internal/controllers/profile.go deleted file mode 100644 index 9d3d1cc..0000000 --- a/backend/internal/controllers/profile.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2025 Nikolai Papin -// -// This file is part of Easywish -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See -// the GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -package controllers - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -type ProfileController interface { - GetProfile(c *gin.Context) - GetOwnProfile(c *gin.Context) - UpdateProfile(c *gin.Context) - GetPrivacySettings(c *gin.Context) - UpdatePrivacySettings(c *gin.Context) - Router -} - -type profileControllerImpl struct { -} - -func NewProfileController() ProfileController { - return &profileControllerImpl{} -} - -// @Summary Get someone's profile details -// @Tags Profile -// @Accept json -// @Produce json -// @Param username path string true "Username" -// @Security JWT -// @Router /profile/{username} [get] -func (p *profileControllerImpl) GetProfile(c *gin.Context) { - c.Status(http.StatusNotImplemented) -} - -// @Summary Get own profile when authorized -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile/me [get] -func (p *profileControllerImpl) GetOwnProfile(c *gin.Context) { - c.Status(http.StatusNotImplemented) -} - -// @Summary Update profile -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile [patch] -func (p *profileControllerImpl) UpdateProfile(c *gin.Context) { - c.Status(http.StatusNotImplemented) -} - -// @Summary Get profile privacy settings -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile/privacy [get] -func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) { - c.Status(http.StatusNotImplemented) -} - -// @Summary Update profile privacy settings -// @Tags Profile -// @Accept json -// @Produce json -// @Security JWT -// @Router /profile/privacy [patch] -func (p *profileControllerImpl) UpdatePrivacySettings(c *gin.Context) { - c.Status(http.StatusNotImplemented) -} - -func (p *profileControllerImpl) RegisterRoutes(group *gin.RouterGroup) { -} diff --git a/backend/internal/controllers/router.go b/backend/internal/controllers/router.go deleted file mode 100644 index 20731b5..0000000 --- a/backend/internal/controllers/router.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2025 Nikolai Papin -// -// This file is part of Easywish -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See -// the GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . - -package controllers - -import ( - "github.com/gin-gonic/gin" -) - -type Router interface { - RegisterRoutes(group *gin.RouterGroup) -} diff --git a/backend/internal/controllers/service.go b/backend/internal/controllers/service.go index c19be38..38b959a 100644 --- a/backend/internal/controllers/service.go +++ b/backend/internal/controllers/service.go @@ -25,32 +25,36 @@ import ( "github.com/gin-gonic/gin" ) +type ServiceController struct {} + func NewServiceController() Controller { + + ctrl := &ServiceController{} + return &controllerImpl{ Path: "/service", Middleware: []gin.HandlerFunc{}, Methods: []ControllerMethod{ - // Health godoc - // @Summary Get health status - // @Description Used internally for checking service health - // @Tags Service - // @Accept json - // @Produce json - // @Success 200 {object} HealthStatus "Says whether it's healthy or not" - // @Router /service/health [get] { HttpMethod: GET, Path: "/health", Authorization: enums.GuestRole, Middleware: []gin.HandlerFunc{}, - Function: func(c *gin.Context) { - - c.JSON(http.StatusOK, models.HealthStatusResponse{Healthy: true,}) - - }, + Function: ctrl.healthHandler, }, }, } } + +// @Summary Get health status +// @Description Used internally for checking service health +// @Tags Service +// @Accept json +// @Produce json +// @Success 200 {object} models.HealthStatusResponse "Says whether it's healthy or not" +// @Router /service/health [get] +func (ctrl *ServiceController) healthHandler(c *gin.Context) { + c.JSON(http.StatusOK, models.HealthStatusResponse{Healthy: true,}) +}