From f2753e14956fc9bf5bdcada76522941773285c95 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Thu, 17 Jul 2025 22:37:07 +0300 Subject: [PATCH] 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,}) +} -- 2.49.1