refactor: updated swagger;

feat: helper function in errors for checking postgres error types;
feat: sql query method for finding users by their email;
feat: registration begin/complete with checking existing username/email;
refactor: error handling in controller
This commit is contained in:
2025-07-03 04:33:25 +03:00
parent d08db300fc
commit 0a51727af8
12 changed files with 348 additions and 31 deletions

View File

@@ -11,7 +11,7 @@ const docTemplate = `{
"title": "{{.Title}}",
"contact": {},
"license": {
"name": "GPL 3.0"
"name": "GPL-3.0"
},
"version": "{{.Version}}"
},
@@ -139,7 +139,11 @@ const docTemplate = `{
}
}
],
"responses": {}
"responses": {
"200": {
"description": "desc"
}
}
}
},
"/auth/registrationComplete": {
@@ -154,7 +158,25 @@ const docTemplate = `{
"Auth"
],
"summary": "Confirm with code, finish creating the account",
"responses": {}
"parameters": [
{
"description": "desc",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteRequest"
}
}
],
"responses": {
"200": {
"description": "desc",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
}
}
}
},
"/profile": {
@@ -305,13 +327,16 @@ const docTemplate = `{
],
"properties": {
"password": {
"type": "string"
"type": "string",
"maxLength": 100
},
"totp": {
"type": "string"
},
"username": {
"type": "string"
"type": "string",
"maxLength": 20,
"minLength": 3
}
}
},
@@ -346,6 +371,46 @@ const docTemplate = `{
"minLength": 3
}
}
},
"models.RegistrationCompleteRequest": {
"type": "object",
"required": [
"name",
"username",
"verification_code"
],
"properties": {
"avatar_url": {
"type": "string",
"maxLength": 255
},
"birthday": {
"type": "string"
},
"name": {
"type": "string",
"maxLength": 75
},
"username": {
"type": "string",
"maxLength": 20,
"minLength": 3
},
"verification_code": {
"type": "string"
}
}
},
"models.RegistrationCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

@@ -8,7 +8,7 @@
"title": "Easywish client API",
"contact": {},
"license": {
"name": "GPL 3.0"
"name": "GPL-3.0"
},
"version": "1.0"
},
@@ -135,7 +135,11 @@
}
}
],
"responses": {}
"responses": {
"200": {
"description": "desc"
}
}
}
},
"/auth/registrationComplete": {
@@ -150,7 +154,25 @@
"Auth"
],
"summary": "Confirm with code, finish creating the account",
"responses": {}
"parameters": [
{
"description": "desc",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteRequest"
}
}
],
"responses": {
"200": {
"description": "desc",
"schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse"
}
}
}
}
},
"/profile": {
@@ -301,13 +323,16 @@
],
"properties": {
"password": {
"type": "string"
"type": "string",
"maxLength": 100
},
"totp": {
"type": "string"
},
"username": {
"type": "string"
"type": "string",
"maxLength": 20,
"minLength": 3
}
}
},
@@ -342,6 +367,46 @@
"minLength": 3
}
}
},
"models.RegistrationCompleteRequest": {
"type": "object",
"required": [
"name",
"username",
"verification_code"
],
"properties": {
"avatar_url": {
"type": "string",
"maxLength": 255
},
"birthday": {
"type": "string"
},
"name": {
"type": "string",
"maxLength": 75
},
"username": {
"type": "string",
"maxLength": 20,
"minLength": 3
},
"verification_code": {
"type": "string"
}
}
},
"models.RegistrationCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
}
},
"securityDefinitions": {

View File

@@ -8,10 +8,13 @@ definitions:
models.LoginRequest:
properties:
password:
maxLength: 100
type: string
totp:
type: string
username:
maxLength: 20
minLength: 3
type: string
required:
- password
@@ -39,11 +42,39 @@ definitions:
- password
- username
type: object
models.RegistrationCompleteRequest:
properties:
avatar_url:
maxLength: 255
type: string
birthday:
type: string
name:
maxLength: 75
type: string
username:
maxLength: 20
minLength: 3
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.
license:
name: GPL 3.0
name: GPL-3.0
title: Easywish client API
version: "1.0"
paths:
@@ -124,7 +155,9 @@ paths:
$ref: '#/definitions/models.RegistrationBeginRequest'
produces:
- application/json
responses: {}
responses:
"200":
description: desc
summary: Register an account
tags:
- Auth
@@ -132,9 +165,20 @@ paths:
post:
consumes:
- application/json
parameters:
- description: desc
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RegistrationCompleteRequest'
produces:
- application/json
responses: {}
responses:
"200":
description: desc
schema:
$ref: '#/definitions/models.RegistrationCompleteResponse'
summary: Confirm with code, finish creating the account
tags:
- Auth

View File

@@ -33,6 +33,7 @@ require (
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect

View File

@@ -48,6 +48,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=

View File

@@ -18,11 +18,13 @@
package controllers
import (
errs "easywish/internal/errors"
"easywish/internal/middleware"
"easywish/internal/models"
"easywish/internal/services"
"easywish/internal/utils"
"easywish/internal/utils/enums"
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -96,6 +98,8 @@ func (a *authControllerImpl) Refresh(c *gin.Context) {
// @Accept json
// @Produce json
// @Param request body models.RegistrationBeginRequest true "desc"
// @Success 200 "Account is created and awaiting verification"
// @Success 409 "Username or email is already taken"
// @Router /auth/registrationBegin [post]
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
@@ -108,11 +112,15 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
_, err := a.authService.RegistrationBegin(request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, err.Error())
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
c.Status(http.StatusConflict)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusAccepted)
c.Status(http.StatusOK)
return
}
@@ -121,16 +129,37 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationCompleteRequest true "desc"
// @Success 200 {object} models.RegistrationCompleteResponse "desc"
// @Router /auth/registrationComplete [post]
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
c.Status(http.StatusNotImplemented)
request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
response, err := a.authService.RegistrationComplete(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)
}
func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin)
group.POST("/registrationComplete", a.RegistrationComplete)
group.POST("/login", a.Login)
group.POST("/refresh", a.Refresh)
group.POST("/passwordResetBegin", a.PasswordResetBegin)
group.POST("/passwordResetComplete", a.PasswordResetComplete)
group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete)
group.POST("/login", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.Login)
group.POST("/refresh", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.UserRole), a.Refresh)
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetBegin)
group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete)
}

View File

@@ -531,6 +531,25 @@ func (q *Queries) GetUserBansByUsername(ctx context.Context, username string) ([
return items, nil
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT users.id, users.username, users.verified, users.registration_date, users.deleted FROM users
JOIN login_informations linfo ON linfo.user_id = users.id
WHERE linfo.email = $1::text
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.Verified,
&i.RegistrationDate,
&i.Deleted,
)
return i, err
}
const getUserByLoginCredentials = `-- name: GetUserByLoginCredentials :one
SELECT
users.id,

View File

@@ -24,6 +24,7 @@ import (
var (
ErrUnauthorized = errors.New("User is not authorized")
ErrUsernameTaken = errors.New("Provided username is already in use")
ErrEmailTaken = errors.New("Provided email is already in use")
ErrUserNotFound = errors.New("User was not found")
ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code")
ErrInvalidToken = errors.New("Token is invalid or expired")

View File

@@ -0,0 +1,34 @@
// 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 <https://www.gnu.org/licenses/>.
package errors
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
)
func IsPgErr(err error, code string) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == code {
return true
}
}
return false
}

View File

@@ -24,6 +24,7 @@ import (
"easywish/internal/utils"
"easywish/internal/utils/enums"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
@@ -55,11 +56,40 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
var err error
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil {
if errs.IsPgErr(err, pgerrcode.UniqueViolation) {
a.log.Warn(
"Attempted registration for a taken username",
zap.String("username", request.Username),
zap.Error(err))
return false, errs.ErrUsernameTaken
}
a.log.Error("Failed to add user to database", zap.Error(err))
return false, errs.ErrServerError
}
a.log.Info("Registraion of a new user", zap.String("username", user.Username), zap.Int64("id", user.ID))
if _, err := db.TXQueries.GetUserByEmail(db.CTX, *request.Email); err == nil {
a.log.Warn(
"Attempted registration for a taken email",
zap.String("email", *request.Email))
return false, errs.ErrEmailTaken
} else if !errs.IsPgErr(err, pgerrcode.NoData) {
a.log.Error(
"Failed to check if email is not taken",
zap.String("email", *request.Email),
zap.Error(err))
return false, errs.ErrServerError
} else {
a.log.Debug("Verified that email is not taken", zap.String("email", *request.Email))
}
a.log.Info(
"Registraion of a new user",
zap.String("username", user.Username),
zap.Int64("id", user.ID))
if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{
UserID: user.ID,
@@ -109,11 +139,19 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username)
if err != nil {
if errs.IsPgErr(err, pgerrcode.NoData) {
a.log.Warn(
"Could not find user attempting to complete registration with given username",
zap.String("username", request.Username),
zap.Error(err))
return nil, errs.ErrUserNotFound
}
a.log.Error(
"Failed to find user attempting to complete registration",
zap.String("username", request.Username),
"Failed to get user",
zap.String("username", request.Username),
zap.Error(err))
return nil, errs.ErrUserNotFound
return nil, errs.ErrServerError
}
confirmationCode, err = db.TXQueries.GetConfirmationCodeByCode(db.CTX, database.GetConfirmationCodeByCodeParams{
@@ -123,11 +161,20 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
})
if err != nil {
a.log.Warn(
"User supplied wrong confirmation code for completing registration",
zap.String("username", user.Username),
if errs.IsPgErr(err, pgerrcode.NoData) {
a.log.Warn(
"User supplied unexistent confirmation code for completing registration",
zap.String("username", user.Username),
zap.String("code", request.VerificationCode),
zap.Error(err))
return nil, errs.ErrForbidden
}
a.log.Error(
"Failed to acquire specified registration code",
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrForbidden
return nil, errs.ErrServerError
}
err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
@@ -140,8 +187,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
"Failed to update the user's registration code used state",
zap.String("username", user.Username),
zap.Int64("confirmation_code_id", confirmationCode.ID),
zap.Error(err),
)
zap.Error(err))
return nil, errs.ErrServerError
}
@@ -179,6 +225,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
return nil, errs.ErrServerError
}
// TODO: session info
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
UserID: user.ID,
Name: utils.NewPointer("First device"),
@@ -206,6 +253,10 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
helper.Commit()
a.log.Info(
"User verified registration",
zap.String("username", request.Username))
response := models.RegistrationCompleteResponse{Tokens: models.Tokens{
AccessToken: accessToken,
RefreshToken: refreshToken,

View File

@@ -19,6 +19,7 @@ services:
POSTGRES_URL: ${POSTGRES_URL}
REDIS_URL: ${REDIS_URL}
MINIO_URL: ${MINIO_URL}
ENVIRONMENT: ${ENVIRONMENT}
ports:
- "8080:8080"
networks:

View File

@@ -34,6 +34,11 @@ WHERE id = $1;
SELECT * FROM users
WHERE username = $1;
;-- name: GetUserByEmail :one
SELECT users.* FROM users
JOIN login_informations linfo ON linfo.user_id = users.id
WHERE linfo.email = @email::text;
;-- name: GetUserByLoginCredentials :one
SELECT
users.id,