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`.
This commit is contained in:
fabio 2026-04-05 20:46:35 +02:00
parent 36fca2af6c
commit b3741f86c8
136 changed files with 532 additions and 711 deletions

View File

@ -2,10 +2,3 @@
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
internal
auth
model
controller
service
endpoint

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
// //
// Apr 05, 2026 17:08:11 UTC // Apr 05, 2026 20:12:24 UTC
// //
export interface ApiRestResponse { export interface ApiRestResponse {
@ -281,11 +281,178 @@ 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 model // package systemUtils
// //
export interface RefreshRequest { // Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string
refresh_token: string; // internal/systemUtils/routes.go Line: 37
export const metrics = async (): Promise<{
data: string;
error: Nullable<string>;
}> => {
return (await api.GET("/metrics")) as {
data: string;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem
// internal/systemUtils/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=/health; name=health; method=GET; response=string
// internal/systemUtils/routes.go Line: 34
export const health = async (): Promise<{
data: string;
error: Nullable<string>;
}> => {
return (await api.GET("/health")) as {
data: string;
error: Nullable<string>;
};
};
export interface MailDebugItem {
name: string;
content: string;
}
//
// package admin
//
// 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>;
};
};
// Typescript: TSEndpoint= path=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
// internal/admin/routes.go Line: 16
export const blockUser = async (
data: BlockUserRequest,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.PUT("/admin/users/:uuid/block", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
export interface BlockUserRequest {
action: string;
}
export interface ListUsersRequest {
page: number;
pageSize: number;
}
//
// 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 (
data: RefreshRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/refresh", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
// internal/auth/routes.go Line: 26
export const me = async (): Promise<{
data: UserShort;
error: Nullable<string>;
}> => {
return (await api.GET("/auth/me")) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/auth/routes.go Line: 29
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=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
// internal/auth/routes.go Line: 32
export const forgotPassword = async (
data: ForgotPasswordRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/forgot", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// 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 { export interface TokenPair {
@ -302,27 +469,60 @@ export interface LoginRequest {
password: string; password: string;
} }
export interface ResetPasswordRequest { export interface RefreshRequest {
token: string; refresh_token: string;
password: string;
} }
// //
// package controllers // package user
// //
export interface BlockUserRequest { // Typescript: TSEndpoint= path=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
action: string; // internal/user/routes.go Line: 18
}
export interface ListUsersRequest { export const updateUser = async (
page: number; data: UpdateUserRequest,
pageSize: number; ): Promise<{ data: UserProfile; error: Nullable<string> }> => {
} return (await api.PUT("/users/:uuid", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
export interface SimpleResponse { // Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
message: string; // 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 { export interface UpdateUserRequest {
name: string; name: string;
@ -337,115 +537,16 @@ export interface UpdateUserRequest {
} }
// //
// package routes // package responses
// //
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=string export interface SimpleResponse {
// internal/http/routes/system_routes.go Line: 37 message: string;
export const metrics = async (): Promise<{ }
data: string;
error: Nullable<string>;
}> => {
return (await api.GET("/metrics")) as {
data: string;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/maildebug; name=mailDebug; method=GET; response=routes.[]MailDebugItem //
// internal/http/routes/system_routes.go Line: 48 // package routes
export const mailDebug = async (): Promise<{ //
data: MailDebugItem[];
error: Nullable<string>;
}> => {
return (await api.GET("/maildebug")) as {
data: MailDebugItem[];
error: Nullable<string>;
};
};
// 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=/users/:uuid; name=updateUser; method=PUT; request=controllers.UpdateUserRequest; response=models.UserProfile
// internal/http/routes/user_routes.go Line: 19
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=/admin/users; name=listUsers; method=POST; request=controllers.ListUsersRequest; response=models.[]UserShort
// internal/http/routes/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>;
};
};
// 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> }> => {
return (await api.PUT("/admin/users/:uuid/block", data)) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
// internal/http/routes/user_routes.go Line: 16
export const createUser = async (
data: UserCreateInput,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.POST("/users", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
// internal/http/routes/user_routes.go Line: 22
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=/health; name=health; method=GET; response=string
// internal/http/routes/system_routes.go Line: 34
export const health = async (): Promise<{
data: string;
error: Nullable<string>;
}> => {
return (await api.GET("/health")) as {
data: string;
error: Nullable<string>;
};
};
export interface FormRequest { export interface FormRequest {
req: string; req: string;
@ -456,99 +557,6 @@ export interface FormResponse {
test: string; test: string;
} }
export interface MailDebugItem {
name: string;
content: string;
}
//
// package endpoint
//
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=model.ForgotPasswordRequest; response=controllers.SimpleResponse
// internal/auth/endpoint/routes.go Line: 34
export const forgotPassword = async (
data: ForgotPasswordRequest,
): Promise<{ data: SimpleResponse; error: Nullable<string> }> => {
return (await api.POST("/auth/password/forgot", data)) as {
data: SimpleResponse;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=model.ResetPasswordRequest; response=controllers.SimpleResponse
// internal/auth/endpoint/routes.go Line: 37
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/endpoint/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>;
};
};
// Typescript: TSEndpoint= path=/auth/login; name=login; method=POST; request=model.LoginRequest; response=model.TokenPair
// internal/auth/endpoint/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/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
// internal/auth/endpoint/routes.go Line: 25
export const refresh = async (
data: RefreshRequest,
): Promise<{ data: TokenPair; error: Nullable<string> }> => {
return (await api.POST("/auth/refresh", data)) as {
data: TokenPair;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
// internal/auth/endpoint/routes.go Line: 28
export const me = async (): Promise<{
data: UserShort;
error: Nullable<string>;
}> => {
return (await api.GET("/auth/me")) as {
data: UserShort;
error: Nullable<string>;
};
};
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/auth/endpoint/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>;
};
};
// //
// package models // package models
// //
@ -565,17 +573,6 @@ export interface UserCreateInput {
preferences: Nullable<UserPreferencesShort>; preferences: Nullable<UserPreferencesShort>;
} }
export interface UserDetailsShort {
title: string;
firstName: string;
lastName: string;
address: string;
city: string;
zipCode: string;
country: string;
phone: string;
}
export interface UserPreferencesShort { export interface UserPreferencesShort {
useIdle: boolean; useIdle: boolean;
idleTimeout: number; idleTimeout: number;
@ -587,6 +584,17 @@ export interface UserPreferencesShort {
language: string; language: string;
} }
export interface UserDetailsShort {
title: string;
firstName: string;
lastName: string;
address: string;
city: string;
zipCode: string;
country: string;
phone: string;
}
export interface UserShort { export interface UserShort {
email: string; email: string;
name: string; name: string;
@ -598,14 +606,14 @@ export interface UserShort {
avatar: Nullable<string>; avatar: Nullable<string>;
} }
export type UserRoles = string[];
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
export type UserTypes = string[]; export type UserTypes = string[];
export type UsersShort = UserShort[]; export type UsersShort = UserShort[];
export type UserRoles = string[];
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
export const EnumUserStatus = { export const EnumUserStatus = {
UserStatusPending: "pending", UserStatusPending: "pending",
UserStatusActive: "active", UserStatusActive: "active",

View File

@ -11,14 +11,13 @@ import (
"syscall" "syscall"
"time" "time"
authmodel "server/internal/auth/model" "server/internal/auth"
authservice "server/internal/auth/service" "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"
@ -55,7 +54,7 @@ func main() {
log.Fatalf("init db: %v", err) log.Fatalf("init db: %v", err)
} }
authService, err := authservice.New(authmodel.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,
@ -84,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,
@ -139,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 controller package auth
import ( import (
"crypto/rand" "crypto/rand"
@ -6,26 +6,27 @@ 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"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
authmodel "server/internal/auth/model"
authservice "server/internal/auth/service"
"server/internal/http/controllers"
"server/internal/mail"
"server/internal/models"
) )
type AuthController struct { type AuthController struct {
authService *authservice.Service authService *AuthService
mailService *mail.Service mailService *mail.Service
} }
func New(authService *authservice.Service, mailService *mail.Service) *AuthController { func New(authService *AuthService, mailService *mail.Service) *AuthController {
return &AuthController{ return &AuthController{
authService: authService, authService: authService,
mailService: mailService, mailService: mailService,
@ -34,15 +35,15 @@ func New(authService *authservice.Service, mailService *mail.Service) *AuthContr
// 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 authmodel.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 := controllers.ValidateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := controllers.DBFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -54,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 := authservice.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")
} }
@ -62,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")
} }
@ -76,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: controllers.HashToken(tokens.AccessToken), AccessTokenHash: tokens.HashToken(token.AccessToken),
RefreshTokenHash: controllers.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"),
@ -87,14 +88,14 @@ 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.Response().Header.Set("Auth-Token", tokens.AccessToken) c.Response().Header.Set("Auth-Token", token.AccessToken)
return c.JSON(controllers.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.
func (ac *AuthController) Refresh(c fiber.Ctx) error { func (ac *AuthController) Refresh(c fiber.Ctx) error {
var req authmodel.RefreshRequest var req RefreshRequest
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")
} }
@ -106,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(controllers.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 := authservice.ClaimsFromCtx(c)
if !ok {
return fiber.NewError(fiber.StatusUnauthorized, "missing claims")
}
db, err := controllers.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(controllers.Success(models.ToUserShort(&user)))
} }
// Register creates a new user with optional roles/types/preferences. // Register creates a new user with optional roles/types/preferences.
@ -138,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 := controllers.ValidateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := controllers.DBFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -155,7 +133,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
} }
now := time.Now().UTC() now := time.Now().UTC()
hashedPassword, err := authservice.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")
} }
@ -183,7 +161,7 @@ func (ac *AuthController) Register(c fiber.Ctx) error {
}(), }(),
Avatar: req.Avatar, Avatar: req.Avatar,
UUID: uuid.NewString(), UUID: uuid.NewString(),
Details: controllers.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
@ -220,19 +198,19 @@ 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(controllers.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 {
var req authmodel.ForgotPasswordRequest var req ForgotPasswordRequest
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 := controllers.ValidateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := controllers.DBFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
@ -240,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(controllers.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(controllers.Success(fiber.Map{"sent": true})) return c.JSON(responses.Success(fiber.Map{"sent": true}))
} }
resetToken, err := generateSecureToken() resetToken, err := generateSecureToken()
@ -257,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: controllers.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,
@ -288,30 +266,30 @@ 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(controllers.Success(controllers.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 {
var req authmodel.ResetPasswordRequest var req ResetPasswordRequest
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 := controllers.ValidateStruct(&req); err != nil { if err := validation.ValidateStruct(&req); err != nil {
return err return err
} }
db, err := controllers.DBFromCtx(c) db, err := helpers.DBFromCtx(c)
if err != nil { if err != nil {
return err return err
} }
hashedPassword, err := authservice.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 := controllers.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 {
@ -351,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(controllers.Success(controllers.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 {
@ -371,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 := controllers.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 := controllers.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) {
@ -390,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(controllers.Success(controllers.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

@ -1,4 +1,4 @@
package model package auth
import ( import (
"time" "time"
@ -24,24 +24,3 @@ type TokenPair struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
} }
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,4 +1,4 @@
package service package auth
import ( import (
"errors" "errors"

View File

@ -1,4 +1,4 @@
package model package auth
// Typescript: interface // Typescript: interface
type LoginRequest struct { type LoginRequest struct {

View File

@ -1,18 +1,16 @@
package endpoint package auth
import ( import (
"time" "time"
authcontroller "server/internal/auth/controller"
authservice "server/internal/auth/service"
"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 Register(app *fiber.App, authService *authservice.Service, mailService *mail.Service) { func Register(app *fiber.App, authService *AuthService, mailService *mail.Service) {
authController := authcontroller.New(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,
@ -25,9 +23,6 @@ func Register(app *fiber.App, authService *authservice.Service, mailService *mai
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.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)

View File

@ -1,4 +1,4 @@
package service package auth
import ( import (
"errors" "errors"
@ -6,12 +6,10 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
authmodel "server/internal/auth/model"
) )
type Service struct { type AuthService struct {
cfg authmodel.Config cfg Config
secret []byte secret []byte
accessExpiry time.Duration accessExpiry time.Duration
refreshExpiry time.Duration refreshExpiry time.Duration
@ -22,7 +20,7 @@ const (
tokenTypeRefresh = "refresh" tokenTypeRefresh = "refresh"
) )
func New(cfg authmodel.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")
} }
@ -33,7 +31,7 @@ func New(cfg authmodel.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,
@ -41,32 +39,32 @@ func New(cfg authmodel.Config) (*Service, error) {
}, nil }, nil
} }
func (s *Service) GenerateTokenPair(username string) (authmodel.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 authmodel.TokenPair{}, err return TokenPair{}, err
} }
refresh, err := s.generateToken(username, tokenTypeRefresh, s.refreshExpiry) refresh, err := s.generateToken(username, tokenTypeRefresh, s.refreshExpiry)
if err != nil { if err != nil {
return authmodel.TokenPair{}, err return TokenPair{}, err
} }
return authmodel.TokenPair{ return TokenPair{
AccessToken: access, AccessToken: access,
RefreshToken: refresh, RefreshToken: refresh,
}, nil }, nil
} }
func (s *Service) AccessExpiry() time.Duration { func (s *AuthService) AccessExpiry() time.Duration {
return s.accessExpiry return s.accessExpiry
} }
func (s *Service) RefreshExpiry() time.Duration { func (s *AuthService) 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 == "" {
@ -86,18 +84,18 @@ func (s *Service) Middleware() fiber.Handler {
} }
} }
func (s *Service) Refresh(refreshToken string) (authmodel.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 authmodel.TokenPair{}, err return TokenPair{}, err
} }
if claims.TokenType != tokenTypeRefresh { if claims.TokenType != tokenTypeRefresh {
return authmodel.TokenPair{}, errors.New("refresh token required") return TokenPair{}, errors.New("refresh token required")
} }
return s.GenerateTokenPair(claims.Username) return s.GenerateTokenPair(claims.Username)
} }
func (s *Service) ValidateAccessToken(tokenString string) (*authmodel.Claims, error) { func (s *AuthService) 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
@ -108,17 +106,17 @@ func (s *Service) ValidateAccessToken(tokenString string) (*authmodel.Claims, er
return claims, nil return claims, nil
} }
func ClaimsFromCtx(c fiber.Ctx) (*authmodel.Claims, bool) { func ClaimsFromCtx(c fiber.Ctx) (*Claims, bool) {
val := c.Locals("authClaims") val := c.Locals("authClaims")
if val == nil { if val == nil {
return nil, false return nil, false
} }
claims, ok := val.(*authmodel.Claims) claims, ok := val.(*Claims)
return claims, ok return claims, ok
} }
func (s *Service) parseToken(tokenString string) (*authmodel.Claims, error) { func (s *AuthService) parseToken(tokenString string) (*Claims, error) {
claims := &authmodel.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 {
return nil, fiber.ErrUnauthorized return nil, fiber.ErrUnauthorized
@ -135,8 +133,8 @@ func (s *Service) parseToken(tokenString string) (*authmodel.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 := authmodel.Claims{ claims := Claims{
Username: username, Username: username,
TokenType: tokenType, TokenType: tokenType,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{

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.

View File

@ -1,223 +0,0 @@
package controllers
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
authservice "server/internal/auth/service"
"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 := authservice.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 := authservice.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 *authservice.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,6 +0,0 @@
package controllers
// Typescript: interface
type SimpleResponse struct {
Message string `json:"message"`
}

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,27 +0,0 @@
package routes
import (
authendpoint "server/internal/auth/endpoint"
authservice "server/internal/auth/service"
"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 *authservice.Service, mailService *mail.Service) {
registerSystemRoutes(app)
authendpoint.Register(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{

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

@ -9,7 +9,7 @@ import (
"github.com/brianvoe/gofakeit/v6" "github.com/brianvoe/gofakeit/v6"
"gorm.io/gorm" "gorm.io/gorm"
authservice "server/internal/auth/service" "server/internal/auth"
"server/internal/models" "server/internal/models"
) )
@ -38,7 +38,7 @@ func SeedUsers(db *gorm.DB, n int) ([]models.User, []Credential, error) {
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("generate password: %w", err) return nil, nil, fmt.Errorf("generate password: %w", err)
} }
passwordHash, err := authservice.HashPassword(pw) passwordHash, err := auth.HashPassword(pw)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("hash seed password: %w", err) return nil, nil, fmt.Errorf("hash seed password: %w", err)
} }

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"

View File

@ -1,4 +1,4 @@
package controllers package user
import ( import (
"errors" "errors"
@ -9,8 +9,11 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
authservice "server/internal/auth/service" "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
} }
@ -63,7 +66,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to check user") return fiber.NewError(fiber.StatusInternalServerError, "failed to check user")
} }
hashedPassword, err := authservice.HashPassword(req.Password) hashedPassword, err := auth.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")
} }
@ -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 (
authservice "server/internal/auth/service" "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 *authservice.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 *authservice.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"

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

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