Compare commits

..

2 Commits

Author SHA1 Message Date
fabio b3741f86c8 Add signup and email templates for user registration and password reset
- Created a new HTML file for the signup page at `backend/spa/signup/index.html`.
- Added HTML and plain text templates for password reset emails at `backend/templates/mailTemplates/password_reset.html.tmpl` and `backend/templates/mailTemplates/password_reset.txt.tmpl`.
- Added HTML and plain text templates for registration confirmation emails at `backend/templates/mailTemplates/registration.html.tmpl` and `backend/templates/mailTemplates/registration.txt.tmpl`.
2026-04-05 20:46:35 +02:00
fabio 36fca2af6c Refactor authentication module: Introduce AuthController and endpoints, implement login, registration, password reset, and token management functionalities. Update routes and services to utilize new structures and improve code organization. Enhance user management with detailed error handling and session management. Update API response types and ensure consistent naming conventions across the application. 2026-04-05 17:09:01 +02:00
135 changed files with 808 additions and 984 deletions

View File

@ -1,3 +1,4 @@
# go-quasar-partial-ssr # go-quasar-partial-ssr
bakend in GO frontend quasar framework con generazione delle pagine statiche per la parte public bakend in GO frontend quasar framework con generazione delle pagine statiche per la parte public

View File

@ -4,7 +4,7 @@
// //
// This file was generated by github.com/millevolte/ts-rpc // This file was generated by github.com/millevolte/ts-rpc
// //
// Mar 17, 2026 18:16:42 UTC // Apr 05, 2026 20:12:24 UTC
// //
export interface ApiRestResponse { export interface ApiRestResponse {
@ -281,58 +281,11 @@ export type Nullable<T> = T | null;
export type Record<K extends string | number | symbol, T> = { [P in K]: T }; export type Record<K extends string | number | symbol, T> = { [P in K]: T };
// //
// package routes // package systemUtils
// //
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
// internal/http/routes/user_routes.go Line: 13
export const getUser = async (
uuid: string,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.GET(`/users/${uuid}`)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem
// internal/http/routes/system_routes.go Line: 48
export const mailDebug = async (): Promise<{
data: MailDebugItem[];
error: Nullable<string>;
}> => {
return (await api.GET("/maildebug")) as {
data: MailDebugItem[];
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=controllers.LoginRequest; response=auth.TokenPair
// internal/http/routes/auth_routes.go Line: 22
export const login = async (
data: LoginRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/login", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/http/routes/auth_routes.go Line: 31
export const register = async (
data: UserCreateInput,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.POST("/auth/register", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string // Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
// internal/http/routes/system_routes.go Line: 37 // internal/systemUtils/routes.go Line: 37
export const metrics = async (): Promise<{ export const metrics = async (): Promise<{
data: string; data: string;
error: Nullable<string>; error: Nullable<string>;
@ -343,32 +296,20 @@ export const metrics = async (): Promise<{
}; };
}; };
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile // Typescript: TSEndpoint= path=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem
// internal/http/routes/user_routes.go Line: 19 // internal/systemUtils/routes.go Line: 48
export const mailDebug = async (): Promise<{
export const updateUser = async ( data: MailDebugItem[];
data: UpdateUserRequest,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.PUT("/users/:uuid", data)) as {
data: UserProfile;
error: Nullable<string>; error: Nullable<string>;
}; }> => {
}; return (await api.GET("/maildebug")) as {
data: MailDebugItem[];
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
// internal/http/routes/auth_routes.go Line: 40
export const validToken = async (
data: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/valid", data)) as {
data: SimpleResponse;
error: Nullable<string>; error: Nullable<string>;
}; };
}; };
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string // Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
// internal/http/routes/system_routes.go Line: 34 // internal/systemUtils/routes.go Line: 34
export const health = async (): Promise<{ export const health = async (): Promise<{
data: string; data: string;
error: Nullable<string>; error: Nullable<string>;
@ -379,44 +320,54 @@ export const health = async (): Promise<{
}; };
}; };
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse export interface MailDebugItem {
// internal/http/routes/user_routes.go Line: 22 name: string;
content: string;
}
export const deleteUser = async ( //
uuid: string, // package admin
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => { //
return (await api.DELETE(`/users/${uuid}`)) as {
data: SimpleResponse; // Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
// internal/admin/routes.go Line: 12
export const listUsers = async (
data: ListUsersRequest,
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
return (await api.POST("/admin/users", data)) as {
data: UserShort[];
error: Nullable<string>; error: Nullable<string>;
}; };
}; };
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=controllers.ResetPasswordRequest; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
// internal/http/routes/auth_routes.go Line: 37 // internal/admin/routes.go Line: 16
export const resetPassword = async ( export const blockUser = async (
data: ResetPasswordRequest, data: BlockUserRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => { ): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.POST("/auth/password/reset", data)) as { return (await api.PUT("/admin/users/:uuid/block", data)) as {
data: SimpleResponse; data: UserShort;
error: Nullable<string>; error: Nullable<string>;
}; };
}; };
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile export interface BlockUserRequest {
// internal/http/routes/user_routes.go Line: 16 action: string;
}
export const createUser = async ( export interface ListUsersRequest {
data: UserCreateInput, page: number;
): Promise<{ data: UserProfile; error: Nullable<string> }> => { pageSize: number;
return (await api.POST("/users", data)) as { }
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=controllers.RefreshRequest; response=auth.TokenPair //
// internal/http/routes/auth_routes.go Line: 25 // package auth
//
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
// internal/auth/routes.go Line: 23
export const refresh = async ( export const refresh = async (
data: RefreshRequest, data: RefreshRequest,
@ -428,7 +379,7 @@ export const refresh = async (
}; };
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort // Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
// internal/http/routes/auth_routes.go Line: 28 // internal/auth/routes.go Line: 26
export const me = async (): Promise<{ export const me = async (): Promise<{
data: UserShort; data: UserShort;
error: Nullable<string>; error: Nullable<string>;
@ -439,32 +390,20 @@ export const me = async (): Promise<{
}; };
}; };
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=controllers.ListUsersRequest; response=models.[]UserShort // Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/http/routes/admin_routes.go Line: 12 // internal/auth/routes.go Line: 29
export const listUsers = async ( export const register = async (
data: ListUsersRequest, data: UserCreateInput,
): Promise<{ data: UserShort[]; error: Nullable<string> }> => {
return (await api.POST("/admin/users", data)) as {
data: UserShort[];
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=controllers.BlockUserRequest; response=models.UserShort
// internal/http/routes/admin_routes.go Line: 15
export const blockUser = async (
data: BlockUserRequest,
): Promise<{ data: UserShort; error: Nullable<string> }> => { ): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.PUT("/admin/users/:uuid/block", data)) as { return (await api.POST("/auth/register", data)) as {
data: UserShort; data: UserShort;
error: Nullable<string>; error: Nullable<string>;
}; };
}; };
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=controllers.ForgotPasswordRequest; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
// internal/http/routes/auth_routes.go Line: 34 // internal/auth/routes.go Line: 32
export const forgotPassword = async ( export const forgotPassword = async (
data: ForgotPasswordRequest, data: ForgotPasswordRequest,
@ -475,6 +414,140 @@ export const forgotPassword = async (
}; };
}; };
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 35
export const resetPassword = async (
data: ResetPasswordRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/reset", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 38
export const validToken = async (
data: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/valid", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
// internal/auth/routes.go Line: 20
export const login = async (
data: LoginRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/login", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface TokenPair {
access_token: string;
refresh_token: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RefreshRequest {
refresh_token: string;
}
//
// package user
//
// Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
// internal/user/routes.go Line: 18
export const updateUser = async (
data: UpdateUserRequest,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.PUT("/users/:uuid", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
// internal/user/routes.go Line: 21
export const deleteUser = async (
uuid: string,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.DELETE(`/users/${uuid}`)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
// internal/user/routes.go Line: 12
export const getUser = async (
uuid: string,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.GET(`/users/${uuid}`)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
// internal/user/routes.go Line: 15
export const createUser = async (
data: UserCreateInput,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.POST("/users", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
export interface UpdateUserRequest {
name: string;
email: string;
password: string;
roles: models.UserRoles;
status: models.UserStatus;
types: models.UserTypes;
avatar: Nullable<string>;
details: Nullable<models.UserDetailsShort>;
preferences: Nullable<models.UserPreferencesShort>;
}
//
// package responses
//
export interface SimpleResponse {
message: string;
}
//
// package routes
//
export interface FormRequest { export interface FormRequest {
req: string; req: string;
count: number; count: number;
@ -484,11 +557,6 @@ export interface FormResponse {
test: string; test: string;
} }
export interface MailDebugItem {
name: string;
content: string;
}
// //
// package models // package models
// //
@ -505,6 +573,17 @@ export interface UserCreateInput {
preferences: Nullable<UserPreferencesShort>; preferences: Nullable<UserPreferencesShort>;
} }
export interface UserPreferencesShort {
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
idlePin: string;
useDirectLogin: boolean;
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
}
export interface UserDetailsShort { export interface UserDetailsShort {
title: string; title: string;
firstName: string; firstName: string;
@ -527,16 +606,7 @@ export interface UserShort {
avatar: Nullable<string>; avatar: Nullable<string>;
} }
export interface UserPreferencesShort { export type UserTypes = string[];
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
idlePin: string;
useDirectLogin: boolean;
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
}
export type UsersShort = UserShort[]; export type UsersShort = UserShort[];
@ -544,66 +614,8 @@ export type UserRoles = string[];
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus]; export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
export type UserTypes = string[];
export const EnumUserStatus = { export const EnumUserStatus = {
UserStatusPending: "pending", UserStatusPending: "pending",
UserStatusActive: "active", UserStatusActive: "active",
UserStatusDisabled: "disabled", UserStatusDisabled: "disabled",
} as const; } as const;
//
// package controllers
//
export interface ResetPasswordRequest {
token: string;
password: string;
}
export interface RefreshRequest {
refresh_token: string;
}
export interface SimpleResponse {
message: string;
}
export interface UpdateUserRequest {
name: string;
email: string;
password: string;
roles: models.UserRoles;
status: models.UserStatus;
types: models.UserTypes;
avatar: Nullable<string>;
details: Nullable<models.UserDetailsShort>;
preferences: Nullable<models.UserPreferencesShort>;
}
export interface BlockUserRequest {
action: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ListUsersRequest {
page: number;
pageSize: number;
}
export interface LoginRequest {
username: string;
password: string;
}
//
// package auth
//
export interface TokenPair {
access_token: string;
refresh_token: string;
}

View File

@ -12,12 +12,12 @@ import (
"time" "time"
"server/internal/auth" "server/internal/auth"
"server/internal/authorization"
"server/internal/config" "server/internal/config"
"server/internal/db" "server/internal/db"
"server/internal/http/controllers" "server/internal/routes"
"server/internal/http/routes"
"server/internal/mail" "server/internal/mail"
"server/internal/roles"
"server/internal/seed" "server/internal/seed"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
@ -54,7 +54,7 @@ func main() {
log.Fatalf("init db: %v", err) log.Fatalf("init db: %v", err)
} }
authService, err := auth.New(auth.Config{ authService, err := auth.NewAuthService(auth.Config{
Secret: cfg.Auth.Secret, Secret: cfg.Auth.Secret,
Issuer: cfg.Auth.Issuer, Issuer: cfg.Auth.Issuer,
AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute, AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute,
@ -83,19 +83,6 @@ func main() {
log.Fatalf("setup mail: %v", err) log.Fatalf("setup mail: %v", err)
} }
roleConfigPath := cfg.RolesConfigPath
if envRoleConfig := os.Getenv("ROLES_CONFIG_PATH"); envRoleConfig != "" {
roleConfigPath = envRoleConfig
}
if roleConfigPath == "" {
roleConfigPath = envOrDefault("ROLES_CONFIG_PATH", "configs/roles.json")
}
roleResolver, err := controllers.LoadRoleConfig(roleConfigPath)
if err != nil {
log.Fatalf("load role config: %v", err)
}
roles.CheckUserRoleConsistency(dbConn, roleResolver)
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
AppName: cfg.AppName, AppName: cfg.AppName,
ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second, ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second,
@ -138,7 +125,8 @@ func main() {
return c.Next() return c.Next()
}) })
app.Use(controllers.RequireEndpointPermission(roleResolver, authService)) app.Use(authorization.RequireEndpointPermission(authService, dbConn))
routes.Register(app, authService, mailService) routes.Register(app, authService, mailService)
port := envOrDefault("PORT", "3000") port := envOrDefault("PORT", "3000")

View File

@ -14,7 +14,8 @@
"mode": "file", "mode": "file",
"from": "noreply@example.local", "from": "noreply@example.local",
"debug_dir": "data/mail-debug", "debug_dir": "data/mail-debug",
"templates_dir": "internal/http/templates", "templates_dir": "templates",
"mail_templates_dir": "templates/mailTemplates",
"frontend_base_url": "http://localhost:9000", "frontend_base_url": "http://localhost:9000",
"reset_password_path": "/#reset-password", "reset_password_path": "/#reset-password",
"smtp": { "smtp": {

Binary file not shown.

View File

@ -1,4 +1,4 @@
package controllers package admin
import ( import (
"errors" "errors"
@ -7,7 +7,10 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"gorm.io/gorm" "gorm.io/gorm"
"server/internal/helpers"
"server/internal/models" "server/internal/models"
"server/internal/responses"
"server/internal/validation"
) )
type AdminController struct{} type AdminController struct{}
@ -33,7 +36,7 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
if req.Page <= 0 { if req.Page <= 0 {
@ -43,7 +46,7 @@ func (ac *AdminController) ListUsers(c fiber.Ctx) error {
req.PageSize = 20 req.PageSize = 20
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -79,11 +82,11 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -115,5 +118,5 @@ func (ac *AdminController) BlockUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status") return fiber.NewError(fiber.StatusInternalServerError, "failed to update user status")
} }
return c.JSON(success(models.ToUserShort(&user))) return c.JSON(responses.Success(models.ToUserShort(&user)))
} }

View File

@ -0,0 +1,19 @@
package admin
import (
"server/internal/authorization"
"github.com/gofiber/fiber/v3"
)
func RegisterAdminRoutes(app *fiber.App) {
adminController := NewAdminController()
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=admin.ListUsersRequest; response=models.[]UserShort
app.Post("/admin/users", adminController.ListUsers)
authorization.RegisterEndpoint("POST/admin/users", int(authorization.AdminPermission))
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
app.Put("/admin/users/:uuid/block", adminController.BlockUser)
authorization.RegisterEndpoint("PUT/admin/users/:uuid/block", int(authorization.AdminPermission))
}

View File

@ -1,4 +1,4 @@
package controllers package auth
import ( import (
"crypto/rand" "crypto/rand"
@ -6,69 +6,44 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"server/internal/helpers"
"server/internal/mail"
"server/internal/models"
"server/internal/responses"
"server/internal/tokens"
"server/internal/validation"
"strings" "strings"
"time" "time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"gorm.io/gorm"
"server/internal/auth"
"server/internal/mail"
"server/internal/models"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
) )
type AuthController struct { type AuthController struct {
authService *auth.Service authService *AuthService
mailService *mail.Service mailService *mail.Service
} }
// Typescript: interface func New(authService *AuthService, mailService *mail.Service) *AuthController {
type SimpleResponse struct {
Message string `json:"message"`
}
func NewAuthController(authService *auth.Service, mailService *mail.Service) *AuthController {
return &AuthController{ return &AuthController{
authService: authService, authService: authService,
mailService: mailService, mailService: mailService,
} }
} }
// Typescript: interface
type LoginRequest struct {
Username string `json:"username" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
// Typescript: interface
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Typescript: interface
type ForgotPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
}
// Typescript: interface
type ResetPasswordRequest struct {
Token string `json:"token" validate:"required,min=20,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
// Login authenticates a user and issues an access/refresh token pair. // Login authenticates a user and issues an access/refresh token pair.
func (ac *AuthController) Login(c fiber.Ctx) error { func (ac *AuthController) Login(c fiber.Ctx) error {
var req LoginRequest var req LoginRequest
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -80,7 +55,7 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
} }
return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user") return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch user")
} }
match, err := auth.VerifyPassword(user.Password, req.Password) match, err := VerifyPassword(user.Password, req.Password)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials") return fiber.NewError(fiber.StatusInternalServerError, "failed to verify credentials")
} }
@ -88,7 +63,7 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials") return fiber.NewError(fiber.StatusUnauthorized, "invalid credentials")
} }
tokens, err := ac.authService.GenerateTokenPair(user.Email) token, err := ac.authService.GenerateTokenPair(user.Email)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token") return fiber.NewError(fiber.StatusInternalServerError, "failed to issue token")
} }
@ -102,8 +77,8 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
session := models.Session{ session := models.Session{
UserID: &userID, UserID: &userID,
Username: user.Email, Username: user.Email,
AccessTokenHash: hashToken(tokens.AccessToken), AccessTokenHash: tokens.HashToken(token.AccessToken),
RefreshTokenHash: hashToken(tokens.RefreshToken), RefreshTokenHash: tokens.HashToken(token.RefreshToken),
ExpiresAt: now.Add(ac.authService.RefreshExpiry()), ExpiresAt: now.Add(ac.authService.RefreshExpiry()),
IPAddress: c.IP(), IPAddress: c.IP(),
UserAgent: c.Get("User-Agent"), UserAgent: c.Get("User-Agent"),
@ -113,10 +88,9 @@ func (ac *AuthController) Login(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to record session") return fiber.NewError(fiber.StatusInternalServerError, "failed to record session")
} }
//c.Set("Auth-Token", tokens.AccessToken) c.Response().Header.Set("Auth-Token", token.AccessToken)
c.Response().Header.Set("Auth-Token", tokens.AccessToken)
return c.JSON(success(tokens)) return c.JSON(responses.Success(token))
} }
// Refresh renews an access/refresh token pair using a valid refresh token. // Refresh renews an access/refresh token pair using a valid refresh token.
@ -133,30 +107,7 @@ func (ac *AuthController) Refresh(c fiber.Ctx) error {
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error()) return fiber.NewError(fiber.StatusUnauthorized, err.Error())
} }
return c.JSON(success(tokens)) return c.JSON(responses.Success(tokens))
}
// Me returns the authenticated user's profile (short format).
func (ac *AuthController) Me(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return c.JSON(success(models.ToUserShort(&user)))
} }
// Register creates a new user with optional roles/types/preferences. // Register creates a new user with optional roles/types/preferences.
@ -165,11 +116,11 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -182,7 +133,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
} }
now := time.Now().UTC() now := time.Now().UTC()
hashedPassword, err := auth.HashPassword(req.Password) hashedPassword, err := HashPassword(req.Password)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
} }
@ -210,7 +161,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
}(), }(),
Avatar: req.Avatar, Avatar: req.Avatar,
UUID: uuid.NewString(), UUID: uuid.NewString(),
Details: toUserDetails(req.Details), Details: helpers.ToUserDetails(req.Details),
Preferences: func() *models.UserPreferences { Preferences: func() *models.UserPreferences {
if req.Preferences == nil { if req.Preferences == nil {
return nil return nil
@ -247,7 +198,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email") return fiber.NewError(fiber.StatusInternalServerError, "failed to send registration email")
} }
return c.Status(fiber.StatusCreated).JSON(success(models.ToUserShort(&user))) return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserShort(&user)))
} }
func (ac *AuthController) ForgotPassword(c fiber.Ctx) error { func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
@ -255,11 +206,11 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -267,13 +218,13 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
var user models.User var user models.User
if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil { if err := db.Where("email = ?", req.Email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return c.JSON(success(fiber.Map{"sent": true})) return c.JSON(responses.Success(fiber.Map{"sent": true}))
} }
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user") return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
} }
if user.Status == models.UserStatusDisabled { if user.Status == models.UserStatusDisabled {
return c.JSON(success(fiber.Map{"sent": true})) return c.JSON(responses.Success(fiber.Map{"sent": true}))
} }
resetToken, err := generateSecureToken() resetToken, err := generateSecureToken()
@ -284,7 +235,7 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
now := time.Now().UTC() now := time.Now().UTC()
record := models.PasswordResetToken{ record := models.PasswordResetToken{
UserID: user.ID, UserID: user.ID,
TokenHash: hashToken(resetToken), TokenHash: tokens.HashToken(resetToken),
ExpiresAt: now.Add(30 * time.Minute), ExpiresAt: now.Add(30 * time.Minute),
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
@ -315,7 +266,7 @@ func (ac *AuthController) ForgotPassword(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email") return fiber.NewError(fiber.StatusInternalServerError, "failed to send reset email")
} }
return c.JSON(success(SimpleResponse{Message: "password reset email sent"})) return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset email sent"}))
} }
func (ac *AuthController) ResetPassword(c fiber.Ctx) error { func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
@ -323,22 +274,22 @@ func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
hashedPassword, err := auth.HashPassword(req.Password) hashedPassword, err := HashPassword(req.Password)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password") return fiber.NewError(fiber.StatusInternalServerError, "failed to secure password")
} }
now := time.Now().UTC() now := time.Now().UTC()
tokenHash := hashToken(req.Token) tokenHash := tokens.HashToken(req.Token)
if err := db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
var resetToken models.PasswordResetToken var resetToken models.PasswordResetToken
if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil { if err := tx.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
@ -378,7 +329,7 @@ func (ac *AuthController) ResetPassword(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password") return fiber.NewError(fiber.StatusInternalServerError, "failed to reset password")
} }
return c.JSON(success(SimpleResponse{Message: "password reset successful"})) return c.JSON(responses.Success(responses.SimpleResponse{Message: "password reset successful"}))
} }
func (ac *AuthController) ValidToken(c fiber.Ctx) error { func (ac *AuthController) ValidToken(c fiber.Ctx) error {
@ -387,7 +338,6 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "token is required") return fiber.NewError(fiber.StatusBadRequest, "token is required")
} }
// Accept both plain text token payload and JSON string payload.
token := raw token := raw
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") { if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
if err := json.Unmarshal([]byte(raw), &token); err != nil { if err := json.Unmarshal([]byte(raw), &token); err != nil {
@ -399,13 +349,13 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "token is required") return fiber.NewError(fiber.StatusBadRequest, "token is required")
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
now := time.Now().UTC() now := time.Now().UTC()
tokenHash := hashToken(token) tokenHash := tokens.HashToken(token)
var resetToken models.PasswordResetToken var resetToken models.PasswordResetToken
if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil { if err := db.Where("token_hash = ?", tokenHash).First(&resetToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -418,7 +368,7 @@ func (ac *AuthController) ValidToken(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token") return fiber.NewError(fiber.StatusBadRequest, "invalid or expired reset token")
} }
return c.JSON(success(SimpleResponse{Message: "valid reset token"})) return c.JSON(responses.Success(responses.SimpleResponse{Message: "valid reset token"}))
} }
func generateSecureToken() (string, error) { func generateSecureToken() (string, error) {

View File

@ -0,0 +1,26 @@
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type Config struct {
Secret string
Issuer string
AccessTokenExpiry time.Duration
RefreshTokenExpiry time.Duration
}
type Claims struct {
Username string `json:"username"`
TokenType string `json:"type"`
jwt.RegisteredClaims
}
// Typescript: interface
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}

View File

@ -0,0 +1,23 @@
package auth
// Typescript: interface
type LoginRequest struct {
Username string `json:"username" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
// Typescript: interface
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
// Typescript: interface
type ForgotPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
}
// Typescript: interface
type ResetPasswordRequest struct {
Token string `json:"token" validate:"required,min=20,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
}

View File

@ -1,21 +0,0 @@
package auth
type Permission int
type Role struct {
Name string
Permissions Permission
}
const (
AdminPermission Permission = 0xff - (1<<iota - 1)
ManagerPermission
UserPermission
GuestPermission
)
var Roles = []Role{
{"admin", AdminPermission},
{"manager", ManagerPermission},
{"user", UserPermission},
{"guest", GuestPermission},
}

View File

@ -1,40 +1,35 @@
package routes package auth
import ( import (
"time" "time"
"server/internal/auth"
"server/internal/http/controllers"
"server/internal/mail" "server/internal/mail"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/limiter" "github.com/gofiber/fiber/v3/middleware/limiter"
) )
func registerAuthRoutes(app *fiber.App, authService *auth.Service, mailService *mail.Service) { func Register(app *fiber.App, authService *AuthService, mailService *mail.Service) {
authController := controllers.NewAuthController(authService, mailService) authController := New(authService, mailService)
authRateLimiter := limiter.New(limiter.Config{ authRateLimiter := limiter.New(limiter.Config{
Max: 10, Max: 10,
Expiration: time.Minute, Expiration: time.Minute,
LimiterMiddleware: limiter.SlidingWindow{}, LimiterMiddleware: limiter.SlidingWindow{},
}) })
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=controllers.LoginRequest; response=auth.TokenPair // Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
app.Post("/auth/login", authRateLimiter, authController.Login) app.Post("/auth/login", authRateLimiter, authController.Login)
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=controllers.RefreshRequest; response=auth.TokenPair // Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
app.Post("/auth/refresh", authService.Middleware(), authController.Refresh) app.Post("/auth/refresh", authService.Middleware(), authController.Refresh)
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
app.Get("/auth/me", authService.Middleware(), authController.Me)
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort // Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
app.Post("/auth/register", authRateLimiter, authController.Register) app.Post("/auth/register", authRateLimiter, authController.Register)
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=controllers.ForgotPasswordRequest; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
app.Post("/auth/password/forgot", authRateLimiter, authController.ForgotPassword) app.Post("/auth/password/forgot", authRateLimiter, authController.ForgotPassword)
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=controllers.ResetPasswordRequest; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
app.Post("/auth/password/reset", authRateLimiter, authController.ResetPassword) app.Post("/auth/password/reset", authRateLimiter, authController.ResetPassword)
// Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/auth/password/valid; name=validToken; method=POST; request=string; response=controllers.SimpleResponse

View File

@ -2,45 +2,25 @@ package auth
import ( import (
"errors" "errors"
"strings"
"time" "time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
type Config struct { type AuthService struct {
Secret string
Issuer string
AccessTokenExpiry time.Duration
RefreshTokenExpiry time.Duration
}
type Service struct {
cfg Config cfg Config
secret []byte secret []byte
accessExpiry time.Duration accessExpiry time.Duration
refreshExpiry time.Duration refreshExpiry time.Duration
} }
type Claims struct {
Username string `json:"username"`
TokenType string `json:"type"`
jwt.RegisteredClaims
}
// Typescript: interface
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
const ( const (
tokenTypeAccess = "access" tokenTypeAccess = "access"
tokenTypeRefresh = "refresh" tokenTypeRefresh = "refresh"
) )
func New(cfg Config) (*Service, error) { func NewAuthService(cfg Config) (*AuthService, error) {
if cfg.Secret == "" { if cfg.Secret == "" {
return nil, errors.New("jwt secret is required") return nil, errors.New("jwt secret is required")
} }
@ -51,7 +31,7 @@ func New(cfg Config) (*Service, error) {
return nil, errors.New("refresh token expiry must be positive") return nil, errors.New("refresh token expiry must be positive")
} }
return &Service{ return &AuthService{
cfg: cfg, cfg: cfg,
secret: []byte(cfg.Secret), secret: []byte(cfg.Secret),
accessExpiry: cfg.AccessTokenExpiry, accessExpiry: cfg.AccessTokenExpiry,
@ -59,7 +39,7 @@ func New(cfg Config) (*Service, error) {
}, nil }, nil
} }
func (s *Service) GenerateTokenPair(username string) (TokenPair, error) { func (s *AuthService) GenerateTokenPair(username string) (TokenPair, error) {
access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry) access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry)
if err != nil { if err != nil {
return TokenPair{}, err return TokenPair{}, err
@ -76,17 +56,15 @@ func (s *Service) GenerateTokenPair(username string) (TokenPair, error) {
}, nil }, nil
} }
// AccessExpiry returns the configured access token lifetime. func (s *AuthService) AccessExpiry() time.Duration {
func (s *Service) AccessExpiry() time.Duration {
return s.accessExpiry return s.accessExpiry
} }
// RefreshExpiry returns the configured refresh token lifetime. func (s *AuthService) RefreshExpiry() time.Duration {
func (s *Service) RefreshExpiry() time.Duration {
return s.refreshExpiry return s.refreshExpiry
} }
func (s *Service) Middleware() fiber.Handler { func (s *AuthService) Middleware() fiber.Handler {
return func(c fiber.Ctx) error { return func(c fiber.Ctx) error {
tokenString := c.Get("Auth-Token") tokenString := c.Get("Auth-Token")
if tokenString == "" { if tokenString == "" {
@ -106,7 +84,7 @@ func (s *Service) Middleware() fiber.Handler {
} }
} }
func (s *Service) Refresh(refreshToken string) (TokenPair, error) { func (s *AuthService) Refresh(refreshToken string) (TokenPair, error) {
claims, err := s.parseToken(refreshToken) claims, err := s.parseToken(refreshToken)
if err != nil { if err != nil {
return TokenPair{}, err return TokenPair{}, err
@ -117,8 +95,7 @@ func (s *Service) Refresh(refreshToken string) (TokenPair, error) {
return s.GenerateTokenPair(claims.Username) return s.GenerateTokenPair(claims.Username)
} }
// ValidateAccessToken parses and validates an access token string, ensuring type=access. func (s *AuthService) ValidateAccessToken(tokenString string) (*Claims, error) {
func (s *Service) ValidateAccessToken(tokenString string) (*Claims, error) {
claims, err := s.parseToken(tokenString) claims, err := s.parseToken(tokenString)
if err != nil { if err != nil {
return nil, err return nil, err
@ -129,7 +106,16 @@ func (s *Service) ValidateAccessToken(tokenString string) (*Claims, error) {
return claims, nil return claims, nil
} }
func (s *Service) parseToken(tokenString string) (*Claims, error) { func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
val := c.Locals("authClaims")
if val == nil {
return nil, false
}
claims, ok := val.(*Claims)
return claims, ok
}
func (s *AuthService) parseToken(tokenString string) (*Claims, error) {
claims := &Claims{} claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
@ -147,7 +133,7 @@ func (s *Service) parseToken(tokenString string) (*Claims, error) {
return claims, nil return claims, nil
} }
func (s *Service) generateToken(username, tokenType string, expiry time.Duration) (string, error) { func (s *AuthService) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
claims := Claims{ claims := Claims{
Username: username, Username: username,
TokenType: tokenType, TokenType: tokenType,
@ -161,27 +147,3 @@ func (s *Service) generateToken(username, tokenType string, expiry time.Duration
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secret) return token.SignedString(s.secret)
} }
func bearerToken(header string) (string, error) {
if header == "" {
return "", errors.New("missing Auth-Token header")
}
if !strings.HasPrefix(header, "Bearer ") {
return "", errors.New("invalid Authorization header format")
}
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if token == "" {
return "", errors.New("empty bearer token")
}
return token, nil
}
func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
val := c.Locals("authClaims")
if val == nil {
return nil, false
}
claims, ok := val.(*Claims)
return claims, ok
}

View File

@ -0,0 +1,85 @@
package authorization
import (
"errors"
"fmt"
"server/internal/auth"
"server/internal/models"
"strings"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
var Endpoints map[string]int
type Permission int
type Role struct {
Name string
Permissions Permission
}
const (
SuperAdminPermission Permission = 0b1111111111111111
AdminPermission Permission = 0b0111111111111111
ManagerPermission Permission = 0b0010111111111111
ContentCreatorPermission Permission = 0b0001111111111111
UserPermission Permission = 0b0000000000000011
GuestPermission Permission = 0b0000000000000001
)
var Roles = []Role{
{"superadmin", SuperAdminPermission},
{"admin", AdminPermission},
{"manager", ManagerPermission},
{"content_creator", ContentCreatorPermission},
{"user", UserPermission},
{"guest", GuestPermission},
}
func init() {
Endpoints = make(map[string]int)
}
func RegisterEndpoint(key string, permission int) {
Endpoints[key] = permission
}
// RequireEndpointPermission enforces permission mapping defined in role config.
// If the endpoint is not configured, or mapped to "*", it allows the request.
func RequireEndpointPermission(authService *auth.AuthService, dbConn *gorm.DB) fiber.Handler {
return func(c fiber.Ctx) error {
fmt.Printf("Checking permissions for %s%s\n", strings.TrimSpace(c.Method()), strings.TrimSpace(c.Path()))
perm := Endpoints[strings.TrimSpace(c.Method())+strings.TrimSpace(c.Path())]
if perm == 0 {
return c.Next()
}
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
}
claims, err := authService.ValidateAccessToken(tokenString)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
c.Locals("authClaims", claims)
var user models.User
if err := dbConn.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
}
// user need to have at least one role that satisfies the permission requirement
if user.Roles == nil {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}

View File

@ -29,6 +29,7 @@ type MailConfig struct {
From string `json:"from"` From string `json:"from"`
DebugDir string `json:"debug_dir"` DebugDir string `json:"debug_dir"`
TemplatesDir string `json:"templates_dir"` TemplatesDir string `json:"templates_dir"`
MailTemplatesDir string `json:"mail_templates_dir"`
FrontendBaseURL string `json:"frontend_base_url"` FrontendBaseURL string `json:"frontend_base_url"`
ResetPasswordPath string `json:"reset_password_path"` ResetPasswordPath string `json:"reset_password_path"`
SMTP SMTPMailConfig `json:"smtp"` SMTP SMTPMailConfig `json:"smtp"`
@ -62,8 +63,8 @@ func LoadConfig(path string) (ServerConfig, error) {
if cfg.Mail.Mode == "" { if cfg.Mail.Mode == "" {
cfg.Mail.Mode = "file" cfg.Mail.Mode = "file"
} }
if cfg.Mail.TemplatesDir == "" { if cfg.Mail.MailTemplatesDir == "" {
cfg.Mail.TemplatesDir = "internal/http/templates" cfg.Mail.MailTemplatesDir = "templates/mailTemplates"
} }
if cfg.Mail.ResetPasswordPath == "" { if cfg.Mail.ResetPasswordPath == "" {
cfg.Mail.ResetPasswordPath = "/#reset-password" cfg.Mail.ResetPasswordPath = "/#reset-password"

View File

@ -1,10 +1,10 @@
package controllers package helpers
import ( import (
"server/internal/models"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"gorm.io/gorm" "gorm.io/gorm"
"server/internal/models"
) )
// dbFromCtx extracts *gorm.DB from Fiber context. // dbFromCtx extracts *gorm.DB from Fiber context.
@ -17,6 +17,10 @@ func dbFromCtx(c fiber.Ctx) (*gorm.DB, error) {
return db, nil return db, nil
} }
func DBFromCtx(c fiber.Ctx) (*gorm.DB, error) {
return dbFromCtx(c)
}
func toUserDetails(d *models.UserDetailsShort) *models.UserDetails { func toUserDetails(d *models.UserDetailsShort) *models.UserDetails {
if d == nil { if d == nil {
return nil return nil
@ -33,6 +37,10 @@ func toUserDetails(d *models.UserDetailsShort) *models.UserDetails {
} }
} }
func ToUserDetails(d *models.UserDetailsShort) *models.UserDetails {
return toUserDetails(d)
}
func toUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences { func toUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
if p == nil { if p == nil {
return nil return nil
@ -48,3 +56,7 @@ func toUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
Language: p.Language, Language: p.Language,
} }
} }
func ToUserPreferences(p *models.UserPreferencesShort) *models.UserPreferences {
return toUserPreferences(p)
}

View File

@ -1,223 +0,0 @@
package controllers
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
"server/internal/auth"
"server/internal/models"
)
type RoleConfig struct {
Roles map[string][]string `json:"roles"`
Permissions map[string][]string `json:"permissions"`
Endpoints map[string]string `json:"endpoints"`
}
type RoleResolver struct {
roleClosure map[string]map[string]struct{}
permMap map[string]map[string]struct{}
endpointPerm map[string]string
}
func LoadRoleConfig(path string) (*RoleResolver, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read role config: %w", err)
}
var cfg RoleConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse role config: %w", err)
}
res := &RoleResolver{
roleClosure: make(map[string]map[string]struct{}),
permMap: make(map[string]map[string]struct{}),
endpointPerm: make(map[string]string),
}
for role := range cfg.Roles {
res.roleClosure[role] = make(map[string]struct{})
}
// Compute role closure (role implies itself).
var dfs func(string, map[string]struct{})
dfs = func(role string, seen map[string]struct{}) {
if _, ok := seen[role]; ok {
return
}
seen[role] = struct{}{}
if implied, ok := cfg.Roles[role]; ok {
for _, r := range implied {
dfs(r, seen)
}
}
}
for role := range cfg.Roles {
set := make(map[string]struct{})
set[role] = struct{}{}
dfs(role, set)
res.roleClosure[role] = set
}
// Build permission map including inherited permissions.
for role := range cfg.Roles {
res.permMap[role] = make(map[string]struct{})
}
for role := range cfg.Roles {
closure := res.roleClosure[role]
for implied := range closure {
for _, p := range cfg.Permissions[implied] {
res.permMap[role][p] = struct{}{}
}
}
}
// Normalise endpoints to "METHOD /path".
for key, perm := range cfg.Endpoints {
parts := strings.SplitN(key, " ", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid endpoint key %q", key)
}
method := strings.TrimSpace(strings.ToUpper(parts[0]))
path := strings.TrimSpace(parts[1])
res.endpointPerm[method+" "+path] = perm
}
return res, nil
}
func (r *RoleResolver) HasRole(userRoles []string, required string) bool {
for _, ur := range userRoles {
if closure, ok := r.roleClosure[ur]; ok {
if _, present := closure[required]; present {
return true
}
}
}
return false
}
func (r *RoleResolver) HasPermission(userRoles []string, perm string) bool {
for _, ur := range userRoles {
if perms, ok := r.permMap[ur]; ok {
if _, present := perms[perm]; present {
return true
}
}
}
return false
}
func (r *RoleResolver) PermissionForEndpoint(method, path string) (string, bool) {
key := strings.ToUpper(method) + " " + path
perm, ok := r.endpointPerm[key]
return perm, ok
}
func (r *RoleResolver) RoleDefined(role string) bool {
_, ok := r.roleClosure[role]
return ok
}
// RequireRole ensures the authenticated user has the specified role (with inheritance).
func RequireRole(resolver *RoleResolver, role string) fiber.Handler {
return func(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
}
if !resolver.HasRole(user.Roles, role) {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}
// RequirePermission ensures the authenticated user has the given permission.
func RequirePermission(resolver *RoleResolver, perm string) fiber.Handler {
return func(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
}
if !resolver.HasPermission(user.Roles, perm) {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}
// RequireEndpointPermission enforces permission mapping defined in role config.
// If the endpoint is not configured, or mapped to "*", it allows the request.
func RequireEndpointPermission(resolver *RoleResolver, authService *auth.Service) fiber.Handler {
return func(c fiber.Ctx) error {
perm, ok := resolver.PermissionForEndpoint(c.Method(), c.Path())
if !ok || perm == "*" {
return c.Next()
}
tokenString := c.Get("Auth-Token")
if tokenString == "" {
return fiber.NewError(fiber.StatusUnauthorized, "missing token header")
}
claims, err := authService.ValidateAccessToken(tokenString)
if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, err.Error())
}
c.Locals("authClaims", claims)
db, err := dbFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.Select("roles").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusUnauthorized, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user roles")
}
if !resolver.HasPermission(user.Roles, perm) {
return fiber.NewError(fiber.StatusForbidden, "insufficient permissions")
}
return c.Next()
}
}

View File

@ -1,17 +0,0 @@
package routes
import (
"server/internal/http/controllers"
"github.com/gofiber/fiber/v3"
)
func registerAdminRoutes(app *fiber.App) {
adminController := controllers.NewAdminController()
// Typescript: TSEndpoint= path=/admin/users; name=listUsers; method=POST; request=controllers.ListUsersRequest; response=models.[]UserShort
app.Post("/admin/users", adminController.ListUsers)
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=controllers.BlockUserRequest; response=models.UserShort
app.Put("/admin/users/:uuid/block", adminController.BlockUser)
}

View File

@ -1,26 +0,0 @@
package routes
import (
"server/internal/auth"
"server/internal/mail"
"github.com/gofiber/fiber/v3"
)
// Typescript: interface
type FormRequest struct {
Req string `json:"req"`
Count int `json:"count"`
}
// Typescript: interface
type FormResponse struct {
Test string `json:"test"`
}
func Register(app *fiber.App, authService *auth.Service, mailService *mail.Service) {
registerSystemRoutes(app)
registerAuthRoutes(app, authService, mailService)
registerUserRoutes(app, authService)
registerAdminRoutes(app)
}

View File

@ -1,7 +1,12 @@
package controllers package responses
import "github.com/gofiber/fiber/v3" import "github.com/gofiber/fiber/v3"
// Typescript: interface
type SimpleResponse struct {
Message string `json:"message"`
}
// success wraps a payload in the standard API envelope. // success wraps a payload in the standard API envelope.
func success(data any) fiber.Map { func success(data any) fiber.Map {
return fiber.Map{ return fiber.Map{
@ -9,3 +14,7 @@ func success(data any) fiber.Map {
"error": nil, "error": nil,
} }
} }
func Success(data any) fiber.Map {
return success(data)
}

View File

@ -1,26 +1,9 @@
package roles package roles
import ( import (
"log"
"gorm.io/gorm" "gorm.io/gorm"
"server/internal/http/controllers"
"server/internal/models"
) )
func CheckUserRoleConsistency(db *gorm.DB, resolver *controllers.RoleResolver) { func CheckUserRoleConsistency(db *gorm.DB) {
var list []models.User
if err := db.Select("email", "roles").Find(&list).Error; err != nil {
log.Printf("warning: cannot verify user roles: %v", err)
return
}
for _, u := range list {
for _, r := range u.Roles {
if !resolver.RoleDefined(r) {
log.Printf("inconsistency: user %s has undefined role %q", u.Email, r)
}
}
}
} }

View File

@ -0,0 +1,29 @@
package routes
import (
"server/internal/admin"
"server/internal/auth"
"server/internal/mail"
"server/internal/systemUtils"
"server/internal/user"
"github.com/gofiber/fiber/v3"
)
// Typescript: interface
type FormRequest struct {
Req string `json:"req"`
Count int `json:"count"`
}
// Typescript: interface
type FormResponse struct {
Test string `json:"test"`
}
func Register(app *fiber.App, authService *auth.AuthService, mailService *mail.Service) {
systemUtils.RegisterSystemRoutes(app)
auth.Register(app, authService, mailService)
user.RegisterUserRoutes(app, authService)
admin.RegisterAdminRoutes(app)
}

View File

@ -1,4 +1,4 @@
package routes package systemUtils
import ( import (
"fmt" "fmt"
@ -14,7 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
const spaDistPath = "internal/http/static/spa" const spaDistPath = "http/static/spa"
// Typescript: interface // Typescript: interface
type MailDebugItem struct { type MailDebugItem struct {
@ -30,7 +30,7 @@ func healthHandler(c fiber.Ctx) error {
}) })
} }
func registerSystemRoutes(app *fiber.App) { func RegisterSystemRoutes(app *fiber.App) {
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string // Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
app.Get("/health", healthHandler) app.Get("/health", healthHandler)

View File

@ -1,4 +1,4 @@
package controllers package tokens
import ( import (
"crypto/sha256" "crypto/sha256"
@ -9,3 +9,7 @@ func hashToken(token string) string {
sum := sha256.Sum256([]byte(token)) sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
func HashToken(token string) string {
return hashToken(token)
}

View File

@ -1,4 +1,4 @@
package controllers package user
import ( import (
"errors" "errors"
@ -10,7 +10,10 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"server/internal/auth" "server/internal/auth"
"server/internal/helpers"
"server/internal/models" "server/internal/models"
"server/internal/responses"
"server/internal/validation"
) )
type UserController struct{} type UserController struct{}
@ -38,7 +41,7 @@ func (uc *UserController) GetUser(c fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
return c.JSON(success(models.ToUserProfile(user))) return c.JSON(responses.Success(models.ToUserProfile(user)))
} }
// CreateUser creates a user together with optional details and preferences. // CreateUser creates a user together with optional details and preferences.
@ -47,11 +50,11 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -93,8 +96,8 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
}(), }(),
Avatar: req.Avatar, Avatar: req.Avatar,
UUID: uuid.NewString(), UUID: uuid.NewString(),
Details: toUserDetails(req.Details), Details: helpers.ToUserDetails(req.Details),
Preferences: toUserPreferences(req.Preferences), Preferences: helpers.ToUserPreferences(req.Preferences),
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@ -107,7 +110,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user") return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
} }
return c.Status(fiber.StatusCreated).JSON(success(models.ToUserProfile(&user))) return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserProfile(&user)))
} }
// UpdateUser replaces user fields and synchronizes details/preferences. // UpdateUser replaces user fields and synchronizes details/preferences.
@ -116,11 +119,11 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload") return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
} }
if err := validateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -173,12 +176,12 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user") return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
} }
return c.JSON(success(models.ToUserProfile(user))) return c.JSON(responses.Success(models.ToUserProfile(user)))
} }
// DeleteUser removes a user and linked details/preferences through cascading delete rules. // DeleteUser removes a user and linked details/preferences through cascading delete rules.
func (uc *UserController) DeleteUser(c fiber.Ctx) error { func (uc *UserController) DeleteUser(c fiber.Ctx) error {
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -200,7 +203,7 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user") return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user")
} }
return c.JSON(success(SimpleResponse{Message: "user deleted"})) return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
} }
func loadUserByID(c fiber.Ctx) (*models.User, error) { func loadUserByID(c fiber.Ctx) (*models.User, error) {
@ -209,7 +212,7 @@ func loadUserByID(c fiber.Ctx) (*models.User, error) {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -231,7 +234,7 @@ func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid") return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
} }
db, err := dbFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -304,3 +307,26 @@ func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesS
} }
return tx.Save(&preferences).Error return tx.Save(&preferences).Error
} }
// Me returns the authenticated user's profile (short format).
func (uc *UserController) Me(c fiber.Ctx) error {
claims, ok := auth.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := helpers.DBFromCtx(c)
if err != nil {
return err
}
var user models.User
if err := db.Preload("Details").Preload("Preferences").Where("email = ?", claims.Username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "user not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "failed to load user")
}
return c.JSON(responses.Success(models.ToUserShort(&user)))
}

View File

@ -1,14 +1,14 @@
package routes package user
import ( import (
"server/internal/auth" "server/internal/auth"
"server/internal/http/controllers" "server/internal/authorization"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
func registerUserRoutes(app *fiber.App, authService *auth.Service) { func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) {
userController := controllers.NewUserController() userController := NewUserController()
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile // Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser) app.Get("/users/:uuid", authService.Middleware(), userController.GetUser)
@ -21,4 +21,8 @@ func registerUserRoutes(app *fiber.App, authService *auth.Service) {
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse // Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser) app.Delete("/users/:uuid", authService.Middleware(), userController.DeleteUser)
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
app.Get("/auth/me", authService.Middleware(), userController.Me)
authorization.RegisterEndpoint("GET/auth/me", int(authorization.UserPermission))
} }

View File

@ -1,4 +1,4 @@
package controllers package validation
import ( import (
"fmt" "fmt"
@ -26,3 +26,7 @@ func validateStruct(payload any) error {
} }
return nil return nil
} }
func ValidateStruct(payload any) error {
return validateStruct(payload)
}

View File

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

View File

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

Before

Width:  |  Height:  |  Size: 448 KiB

After

Width:  |  Height:  |  Size: 448 KiB

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Some files were not shown because too many files have changed in this diff Show More