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..eb5e1dd 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -413,7 +413,7 @@ const docTemplate = `{ "200": { "description": "Says whether it's healthy or not", "schema": { - "$ref": "#/definitions/controllers.HealthStatus" + "$ref": "#/definitions/models.HealthStatusResponse" } } } @@ -421,14 +421,6 @@ const docTemplate = `{ } }, "definitions": { - "controllers.HealthStatus": { - "type": "object", - "properties": { - "healthy": { - "type": "boolean" - } - } - }, "models.ChangePasswordRequest": { "type": "object", "required": [ @@ -447,6 +439,14 @@ const docTemplate = `{ } } }, + "models.HealthStatusResponse": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + } + } + }, "models.LoginRequest": { "type": "object", "required": [ @@ -530,7 +530,8 @@ const docTemplate = `{ ], "properties": { "refresh_token": { - "type": "string" + "type": "string", + "maxLength": 2000 } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index af5b4dd..4f37e72 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -409,7 +409,7 @@ "200": { "description": "Says whether it's healthy or not", "schema": { - "$ref": "#/definitions/controllers.HealthStatus" + "$ref": "#/definitions/models.HealthStatusResponse" } } } @@ -417,14 +417,6 @@ } }, "definitions": { - "controllers.HealthStatus": { - "type": "object", - "properties": { - "healthy": { - "type": "boolean" - } - } - }, "models.ChangePasswordRequest": { "type": "object", "required": [ @@ -443,6 +435,14 @@ } } }, + "models.HealthStatusResponse": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + } + } + }, "models.LoginRequest": { "type": "object", "required": [ @@ -526,7 +526,8 @@ ], "properties": { "refresh_token": { - "type": "string" + "type": "string", + "maxLength": 2000 } } }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 4454738..39f35c1 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,10 +1,5 @@ basePath: /api/ definitions: - controllers.HealthStatus: - properties: - healthy: - type: boolean - type: object models.ChangePasswordRequest: properties: old_password: @@ -17,6 +12,11 @@ definitions: - old_password - password type: object + models.HealthStatusResponse: + properties: + healthy: + type: boolean + type: object models.LoginRequest: properties: password: @@ -71,6 +71,7 @@ definitions: models.RefreshRequest: properties: refresh_token: + maxLength: 2000 type: string required: - refresh_token @@ -372,7 +373,7 @@ paths: "200": description: Says whether it's healthy or not schema: - $ref: '#/definitions/controllers.HealthStatus' + $ref: '#/definitions/models.HealthStatusResponse' summary: Get health status tags: - Service diff --git a/backend/internal/controllers/auth.go b/backend/internal/controllers/auth.go index 8e82813..0c264fc 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,73 +30,91 @@ 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 +type AuthController struct { auth services.AuthService + log *zap.Logger } -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 +func NewAuthController(log *zap.Logger, auth services.AuthService) Controller { + ctrl := &AuthController{auth: auth, log: log} + + return &controllerImpl{ + Path: "/auth", + Middleware: []gin.HandlerFunc{}, + Methods: []ControllerMethod{ + { + HttpMethod: POST, + Path: "/registrationBegin", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.registrationBeginHandler, + }, + { + HttpMethod: POST, + Path: "/registrationComplete", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.registrationCompleteHandler, + }, + { + HttpMethod: POST, + Path: "/login", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.loginHandler, + }, + { + HttpMethod: POST, + Path: "/refresh", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.refreshHandler, + }, + { + HttpMethod: POST, + Path: "/passwordResetBegin", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.passwordResetBeginHandler, + }, + { + HttpMethod: POST, + Path: "/passwordResetComplete", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.passwordResetCompleteHandler, + }, + { + HttpMethod: POST, + Path: "/changePassword", + Authorization: enums.UserRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.changePasswordHandler, + }, + }, } +} - response, err := a.auth.Login(request.User, request.Body) - +// @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 { - 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) + _, err = ctrl.auth.RegistrationBegin(request.Body) if err != nil { - if errors.Is(err, errs.ErrTooManyRequests) { + 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) @@ -105,26 +122,53 @@ func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) { 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 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) +// @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 := a.auth.PasswordResetComplete(request.Body) + + response, err := ctrl.auth.Login(request.User, request.Body) if err != nil { if errors.Is(err, errs.ErrForbidden) { c.Status(http.StatusForbidden) @@ -133,28 +177,25 @@ func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) { } 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) +// @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 := a.auth.Refresh(request.Body) + + response, err := ctrl.auth.Refresh(request.Body) if err != nil { if utils.ErrorIsOneOf( err, @@ -167,36 +208,87 @@ func (a *authControllerImpl) Refresh(c *gin.Context) { ) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) } else { - c.JSON(http.StatusInternalServerError, err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": 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) { +// @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) +} - request, ok := utils.GetRequest[models.RegistrationBeginRequest](c) - if !ok { - c.Status(http.StatusBadRequest) +// @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 } - _, err := a.auth.RegistrationBegin(request.Body) - + response, err := ctrl.auth.PasswordResetComplete(request.Body) if err != nil { - if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { - c.Status(http.StatusConflict) + 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) } @@ -204,76 +296,4 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { } 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) } diff --git a/backend/internal/controllers/controller.go b/backend/internal/controllers/controller.go new file mode 100644 index 0000000..e9d3a85 --- /dev/null +++ b/backend/internal/controllers/controller.go @@ -0,0 +1,112 @@ +// 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/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 + Middleware []gin.HandlerFunc + Methods []ControllerMethod +} + +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(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)..., + )} +} + +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 + } + + // TODO: Think hard on a singleton for better performance + 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"}) + panic("No client_info found in gin context. Does the handler use AuthMiddleware?") + } + cinfo := cinfoFromCtx.(dto.ClientInfo) + + return &dto.Request[ModelT]{ + Body: body, + User: cinfo, + }, nil +} diff --git a/backend/internal/controllers/profile.go b/backend/internal/controllers/profile.go deleted file mode 100644 index 5a4fbd8..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/service.go b/backend/internal/controllers/service.go index d0012a4..38b959a 100644 --- a/backend/internal/controllers/service.go +++ b/backend/internal/controllers/service.go @@ -1,56 +1,60 @@ // 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 +type ServiceController struct {} + +func NewServiceController() Controller { + + ctrl := &ServiceController{} + + return &controllerImpl{ + Path: "/service", + Middleware: []gin.HandlerFunc{}, + Methods: []ControllerMethod{ + + { + HttpMethod: GET, + Path: "/health", + Authorization: enums.GuestRole, + Middleware: []gin.HandlerFunc{}, + Function: ctrl.healthHandler, + }, + + }, + } } -type serviceControllerImpl struct{} - -func NewServiceController() ServiceController { - return &serviceControllerImpl{} -} - -// 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"` +// @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,}) } 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/controllers/router.go b/backend/internal/errors/controller.go similarity index 83% rename from backend/internal/controllers/router.go rename to backend/internal/errors/controller.go index 20731b5..4489316 100644 --- a/backend/internal/controllers/router.go +++ b/backend/internal/errors/controller.go @@ -1,26 +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 controllers +package errors -import ( - "github.com/gin-gonic/gin" +import "errors" + +var ( + ErrClientInfoNotProvided = errors.New("No client info provded") ) - -type Router interface { - RegisterRoutes(group *gin.RouterGroup) -} 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/controllers/account.go b/backend/internal/models/service.go similarity index 69% rename from backend/internal/controllers/account.go rename to backend/internal/models/service.go index 3f2e1d3..373b050 100644 --- a/backend/internal/controllers/account.go +++ b/backend/internal/models/service.go @@ -1,34 +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 controllers +package models -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) +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")