Compare commits

..

No commits in common. "b3741f86c8b41804bd11f8de21e7c78930d4dc34" and "6920d7ae959e6a9ce642ac4ee78d46e21bbf3508" have entirely different histories.

135 changed files with 986 additions and 810 deletions

View File

@ -1,4 +1,3 @@
# go-quasar-partial-ssr
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
//
// Apr 05, 2026 20:12:24 UTC
// Mar 17, 2026 18:16:42 UTC
//
export interface ApiRestResponse {
@ -281,23 +281,22 @@ export type Nullable<T> = T | null;
export type Record<K extends string | number | symbol, T> = { [P in K]: T };
//
// package systemUtils
// package routes
//
// Typescript: TSEndpoint= path=/metrics; name=metrics; method=GET; response=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;
// 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/systemUtils/routes.go Line: 48
// internal/http/routes/system_routes.go Line: 48
export const mailDebug = async (): Promise<{
data: MailDebugItem[];
error: Nullable<string>;
@ -308,8 +307,68 @@ export const mailDebug = async (): Promise<{
};
};
// 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
// internal/http/routes/system_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=/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=/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>;
};
};
// Typescript: TSEndpoint= path=/health; name=health; method=GET; response=string
// internal/systemUtils/routes.go Line: 34
// internal/http/routes/system_routes.go Line: 34
export const health = async (): Promise<{
data: string;
error: Nullable<string>;
@ -320,54 +379,44 @@ export const health = async (): Promise<{
};
};
export interface MailDebugItem {
name: string;
content: string;
}
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
// internal/http/routes/user_routes.go Line: 22
//
// 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[];
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=/admin/users/:uuid/block; name=blockUser; method=PUT; request=admin.BlockUserRequest; response=models.UserShort
// internal/admin/routes.go Line: 16
// Typescript: TSEndpoint= path=/auth/password/reset; name=resetPassword; method=POST; request=controllers.ResetPasswordRequest; response=controllers.SimpleResponse
// internal/http/routes/auth_routes.go Line: 37
export const blockUser = async (
data: BlockUserRequest,
): Promise<{ data: UserShort; error: Nullable<string> }> => {
return (await api.PUT("/admin/users/:uuid/block", data)) as {
data: UserShort;
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>;
};
};
export interface BlockUserRequest {
action: string;
}
// Typescript: TSEndpoint= path=/users; name=createUser; method=POST; request=models.UserCreateInput; response=models.UserProfile
// internal/http/routes/user_routes.go Line: 16
export interface ListUsersRequest {
page: number;
pageSize: number;
}
export const createUser = async (
data: UserCreateInput,
): Promise<{ data: UserProfile; error: Nullable<string> }> => {
return (await api.POST("/users", data)) as {
data: UserProfile;
error: Nullable<string>;
};
};
//
// package auth
//
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=model.RefreshRequest; response=model.TokenPair
// internal/auth/routes.go Line: 23
// Typescript: TSEndpoint= path=/auth/refresh; name=refresh; method=POST; request=controllers.RefreshRequest; response=auth.TokenPair
// internal/http/routes/auth_routes.go Line: 25
export const refresh = async (
data: RefreshRequest,
@ -379,7 +428,7 @@ export const refresh = async (
};
// Typescript: TSEndpoint= path=/auth/me; name=me; method=GET; response=models.UserShort
// internal/auth/routes.go Line: 26
// internal/http/routes/auth_routes.go Line: 28
export const me = async (): Promise<{
data: UserShort;
error: Nullable<string>;
@ -390,20 +439,32 @@ export const me = async (): Promise<{
};
};
// Typescript: TSEndpoint= path=/auth/register; name=register; method=POST; request=models.UserCreateInput; response=models.UserShort
// internal/auth/routes.go Line: 29
// 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 register = async (
data: UserCreateInput,
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.POST("/auth/register", data)) as {
return (await api.PUT("/admin/users/:uuid/block", 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
// Typescript: TSEndpoint= path=/auth/password/forgot; name=forgotPassword; method=POST; request=controllers.ForgotPasswordRequest; response=controllers.SimpleResponse
// internal/http/routes/auth_routes.go Line: 34
export const forgotPassword = async (
data: ForgotPasswordRequest,
@ -414,140 +475,6 @@ 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 {
req: string;
count: number;
@ -557,6 +484,11 @@ export interface FormResponse {
test: string;
}
export interface MailDebugItem {
name: string;
content: string;
}
//
// package models
//
@ -573,17 +505,6 @@ export interface UserCreateInput {
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 {
title: string;
firstName: string;
@ -606,7 +527,16 @@ export interface UserShort {
avatar: Nullable<string>;
}
export type UserTypes = string[];
export interface UserPreferencesShort {
useIdle: boolean;
idleTimeout: number;
useIdlePassword: boolean;
idlePin: string;
useDirectLogin: boolean;
useQuadcodeLogin: boolean;
sendNoticesMail: boolean;
language: string;
}
export type UsersShort = UserShort[];
@ -614,8 +544,66 @@ export type UserRoles = string[];
export type UserStatus = (typeof EnumUserStatus)[keyof typeof EnumUserStatus];
export type UserTypes = string[];
export const EnumUserStatus = {
UserStatusPending: "pending",
UserStatusActive: "active",
UserStatusDisabled: "disabled",
} 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"
"server/internal/auth"
"server/internal/authorization"
"server/internal/config"
"server/internal/db"
"server/internal/routes"
"server/internal/http/controllers"
"server/internal/http/routes"
"server/internal/mail"
"server/internal/roles"
"server/internal/seed"
"github.com/gofiber/fiber/v3"
@ -54,7 +54,7 @@ func main() {
log.Fatalf("init db: %v", err)
}
authService, err := auth.NewAuthService(auth.Config{
authService, err := auth.New(auth.Config{
Secret: cfg.Auth.Secret,
Issuer: cfg.Auth.Issuer,
AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute,
@ -83,6 +83,19 @@ func main() {
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{
AppName: cfg.AppName,
ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second,
@ -125,8 +138,7 @@ func main() {
return c.Next()
})
app.Use(authorization.RequireEndpointPermission(authService, dbConn))
app.Use(controllers.RequireEndpointPermission(roleResolver, authService))
routes.Register(app, authService, mailService)
port := envOrDefault("PORT", "3000")

View File

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

Binary file not shown.

View File

@ -1,19 +0,0 @@
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,26 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -0,0 +1,21 @@
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

@ -2,25 +2,45 @@ package auth
import (
"errors"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/golang-jwt/jwt/v5"
)
type AuthService struct {
type Config struct {
Secret string
Issuer string
AccessTokenExpiry time.Duration
RefreshTokenExpiry time.Duration
}
type Service struct {
cfg Config
secret []byte
accessExpiry 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 (
tokenTypeAccess = "access"
tokenTypeRefresh = "refresh"
)
func NewAuthService(cfg Config) (*AuthService, error) {
func New(cfg Config) (*Service, error) {
if cfg.Secret == "" {
return nil, errors.New("jwt secret is required")
}
@ -31,7 +51,7 @@ func NewAuthService(cfg Config) (*AuthService, error) {
return nil, errors.New("refresh token expiry must be positive")
}
return &AuthService{
return &Service{
cfg: cfg,
secret: []byte(cfg.Secret),
accessExpiry: cfg.AccessTokenExpiry,
@ -39,7 +59,7 @@ func NewAuthService(cfg Config) (*AuthService, error) {
}, nil
}
func (s *AuthService) GenerateTokenPair(username string) (TokenPair, error) {
func (s *Service) GenerateTokenPair(username string) (TokenPair, error) {
access, err := s.generateToken(username, tokenTypeAccess, s.accessExpiry)
if err != nil {
return TokenPair{}, err
@ -56,15 +76,17 @@ func (s *AuthService) GenerateTokenPair(username string) (TokenPair, error) {
}, nil
}
func (s *AuthService) AccessExpiry() time.Duration {
// AccessExpiry returns the configured access token lifetime.
func (s *Service) AccessExpiry() time.Duration {
return s.accessExpiry
}
func (s *AuthService) RefreshExpiry() time.Duration {
// RefreshExpiry returns the configured refresh token lifetime.
func (s *Service) RefreshExpiry() time.Duration {
return s.refreshExpiry
}
func (s *AuthService) Middleware() fiber.Handler {
func (s *Service) Middleware() fiber.Handler {
return func(c fiber.Ctx) error {
tokenString := c.Get("Auth-Token")
if tokenString == "" {
@ -84,7 +106,7 @@ func (s *AuthService) Middleware() fiber.Handler {
}
}
func (s *AuthService) Refresh(refreshToken string) (TokenPair, error) {
func (s *Service) Refresh(refreshToken string) (TokenPair, error) {
claims, err := s.parseToken(refreshToken)
if err != nil {
return TokenPair{}, err
@ -95,7 +117,8 @@ func (s *AuthService) Refresh(refreshToken string) (TokenPair, error) {
return s.GenerateTokenPair(claims.Username)
}
func (s *AuthService) ValidateAccessToken(tokenString string) (*Claims, error) {
// ValidateAccessToken parses and validates an access token string, ensuring type=access.
func (s *Service) ValidateAccessToken(tokenString string) (*Claims, error) {
claims, err := s.parseToken(tokenString)
if err != nil {
return nil, err
@ -106,16 +129,7 @@ func (s *AuthService) ValidateAccessToken(tokenString string) (*Claims, error) {
return claims, 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
}
func (s *AuthService) parseToken(tokenString string) (*Claims, error) {
func (s *Service) parseToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
@ -133,7 +147,7 @@ func (s *AuthService) parseToken(tokenString string) (*Claims, error) {
return claims, nil
}
func (s *AuthService) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
func (s *Service) generateToken(username, tokenType string, expiry time.Duration) (string, error) {
claims := Claims{
Username: username,
TokenType: tokenType,
@ -147,3 +161,27 @@ func (s *AuthService) generateToken(username, tokenType string, expiry time.Dura
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package user
package controllers
import (
"errors"
@ -10,10 +10,7 @@ import (
"gorm.io/gorm"
"server/internal/auth"
"server/internal/helpers"
"server/internal/models"
"server/internal/responses"
"server/internal/validation"
)
type UserController struct{}
@ -41,7 +38,7 @@ func (uc *UserController) GetUser(c fiber.Ctx) error {
if err != nil {
return err
}
return c.JSON(responses.Success(models.ToUserProfile(user)))
return c.JSON(success(models.ToUserProfile(user)))
}
// CreateUser creates a user together with optional details and preferences.
@ -50,11 +47,11 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
if err := validateStruct(&req); err != nil {
return err
}
db, err := helpers.DBFromCtx(c)
db, err := dbFromCtx(c)
if err != nil {
return err
}
@ -96,8 +93,8 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
}(),
Avatar: req.Avatar,
UUID: uuid.NewString(),
Details: helpers.ToUserDetails(req.Details),
Preferences: helpers.ToUserPreferences(req.Preferences),
Details: toUserDetails(req.Details),
Preferences: toUserPreferences(req.Preferences),
CreatedAt: now,
UpdatedAt: now,
}
@ -110,7 +107,7 @@ func (uc *UserController) CreateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
}
return c.Status(fiber.StatusCreated).JSON(responses.Success(models.ToUserProfile(&user)))
return c.Status(fiber.StatusCreated).JSON(success(models.ToUserProfile(&user)))
}
// UpdateUser replaces user fields and synchronizes details/preferences.
@ -119,11 +116,11 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
if err := c.Bind().Body(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid payload")
}
if err := validation.ValidateStruct(&req); err != nil {
if err := validateStruct(&req); err != nil {
return err
}
db, err := helpers.DBFromCtx(c)
db, err := dbFromCtx(c)
if err != nil {
return err
}
@ -176,12 +173,12 @@ func (uc *UserController) UpdateUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to reload user")
}
return c.JSON(responses.Success(models.ToUserProfile(user)))
return c.JSON(success(models.ToUserProfile(user)))
}
// DeleteUser removes a user and linked details/preferences through cascading delete rules.
func (uc *UserController) DeleteUser(c fiber.Ctx) error {
db, err := helpers.DBFromCtx(c)
db, err := dbFromCtx(c)
if err != nil {
return err
}
@ -203,7 +200,7 @@ func (uc *UserController) DeleteUser(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user")
}
return c.JSON(responses.Success(responses.SimpleResponse{Message: "user deleted"}))
return c.JSON(success(SimpleResponse{Message: "user deleted"}))
}
func loadUserByID(c fiber.Ctx) (*models.User, error) {
@ -212,7 +209,7 @@ func loadUserByID(c fiber.Ctx) (*models.User, error) {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user id")
}
db, err := helpers.DBFromCtx(c)
db, err := dbFromCtx(c)
if err != nil {
return nil, err
}
@ -234,7 +231,7 @@ func loadUserByUUID(c fiber.Ctx) (*models.User, error) {
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid user uuid")
}
db, err := helpers.DBFromCtx(c)
db, err := dbFromCtx(c)
if err != nil {
return nil, err
}
@ -307,26 +304,3 @@ func syncUserPreferences(tx *gorm.DB, userID int, input *models.UserPreferencesS
}
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,4 +1,4 @@
package validation
package controllers
import (
"fmt"
@ -26,7 +26,3 @@ func validateStruct(payload any) error {
}
return nil
}
func ValidateStruct(payload any) error {
return validateStruct(payload)
}

View File

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

View File

@ -0,0 +1,26 @@
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,4 +1,4 @@
package systemUtils
package routes
import (
"fmt"
@ -14,7 +14,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const spaDistPath = "http/static/spa"
const spaDistPath = "internal/http/static/spa"
// Typescript: interface
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
app.Get("/health", healthHandler)

View File

@ -1,14 +1,14 @@
package user
package routes
import (
"server/internal/auth"
"server/internal/authorization"
"server/internal/http/controllers"
"github.com/gofiber/fiber/v3"
)
func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) {
userController := NewUserController()
func registerUserRoutes(app *fiber.App, authService *auth.Service) {
userController := controllers.NewUserController()
// Typescript: TSEndpoint= path=/users/:uuid; name=getUser; method=GET; response=models.UserProfile
app.Get("/users/:uuid", authService.Middleware(), userController.GetUser)
@ -21,8 +21,4 @@ func RegisterUserRoutes(app *fiber.App, authService *auth.AuthService) {
// Typescript: TSEndpoint= path=/users/:uuid; name=deleteUser; method=DELETE; response=controllers.SimpleResponse
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

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