607 lines
22 KiB
Go
607 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/database"
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/handlers"
|
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
|
"github.com/gorilla/mux"
|
|
"github.com/joho/godotenv"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
)
|
|
|
|
func main() {
|
|
// Load environment variables
|
|
_ = godotenv.Load()
|
|
|
|
// Configuration
|
|
mongoURL := os.Getenv("MONGODB_URI")
|
|
if mongoURL == "" {
|
|
mongoURL = "mongodb://localhost:27017"
|
|
}
|
|
|
|
jwtSecret := os.Getenv("JWT_SECRET")
|
|
if jwtSecret == "" {
|
|
jwtSecret = "your-secret-key-change-in-production"
|
|
}
|
|
|
|
encryptionKey := os.Getenv("ENCRYPTION_KEY")
|
|
if encryptionKey == "" {
|
|
encryptionKey = "00000000000000000000000000000000" // 32 bytes
|
|
}
|
|
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
redisAddr := os.Getenv("REDIS_ADDR")
|
|
if redisAddr == "" {
|
|
redisAddr = "localhost:6379"
|
|
}
|
|
|
|
redisUser := os.Getenv("REDIS_USER")
|
|
redisPassword := os.Getenv("REDIS_PASSWORD")
|
|
redisDB := 0
|
|
if redisDBText := os.Getenv("REDIS_DB"); redisDBText != "" {
|
|
parsedDB, err := strconv.Atoi(redisDBText)
|
|
if err != nil {
|
|
log.Fatalf("invalid REDIS_DB value: %v", err)
|
|
}
|
|
redisDB = parsedDB
|
|
}
|
|
|
|
sessionTTL := 7 * 24 * time.Hour
|
|
if sessionTTLText := os.Getenv("SESSION_TTL_HOURS"); sessionTTLText != "" {
|
|
hours, err := strconv.Atoi(sessionTTLText)
|
|
if err != nil || hours <= 0 {
|
|
log.Fatalf("invalid SESSION_TTL_HOURS value: %q", sessionTTLText)
|
|
}
|
|
sessionTTL = time.Duration(hours) * time.Hour
|
|
}
|
|
|
|
// Connect to database
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
db, err := database.NewDatabase(ctx, mongoURL)
|
|
if err != nil {
|
|
log.Fatalf("Failed to connect to database: %v", err)
|
|
}
|
|
defer db.Close(context.Background())
|
|
|
|
redisClient := redis.NewClient(&redis.Options{
|
|
Addr: redisAddr,
|
|
Username: redisUser,
|
|
Password: redisPassword,
|
|
DB: redisDB,
|
|
})
|
|
|
|
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
|
log.Fatalf("failed to connect to redis: %v", err)
|
|
}
|
|
defer func() {
|
|
_ = redisClient.Close()
|
|
}()
|
|
|
|
// Initialize security components
|
|
passwordHasher := security.NewPasswordHasher()
|
|
encryptor, err := security.NewEncryptor(encryptionKey)
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize encryptor: %v", err)
|
|
}
|
|
|
|
// Initialize JWT manager
|
|
jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour)
|
|
sessionManager := auth.NewSessionManager(redisClient, sessionTTL)
|
|
|
|
// Initialize services
|
|
permissionService := services.NewPermissionService(
|
|
db.UserRepo,
|
|
db.GroupRepo,
|
|
db.MembershipRepo,
|
|
db.SpaceRepo,
|
|
)
|
|
|
|
authService := services.NewAuthService(
|
|
db.UserRepo,
|
|
db.GroupRepo,
|
|
db.ProviderRepo,
|
|
db.LinkRepo,
|
|
db.RecoveryRepo,
|
|
db.FeatureFlagRepo,
|
|
permissionService,
|
|
jwtManager,
|
|
passwordHasher,
|
|
encryptor,
|
|
)
|
|
|
|
spaceService := services.NewSpaceService(
|
|
db.SpaceRepo,
|
|
db.MembershipRepo,
|
|
db.NoteRepo,
|
|
db.CategoryRepo,
|
|
db.TaskListRepo,
|
|
db.UserRepo,
|
|
permissionService,
|
|
)
|
|
|
|
noteService := services.NewNoteService(
|
|
db.NoteRepo,
|
|
db.CategoryRepo,
|
|
db.MembershipRepo,
|
|
nil, // NoteRevisionRepository
|
|
db.SpaceRepo,
|
|
permissionService,
|
|
passwordHasher,
|
|
)
|
|
|
|
categoryService := services.NewCategoryService(
|
|
db.CategoryRepo,
|
|
db.TaskListRepo,
|
|
db.MembershipRepo,
|
|
db.NoteRepo,
|
|
permissionService,
|
|
)
|
|
|
|
taskService := services.NewTaskService(
|
|
db.TaskRepo,
|
|
db.TaskListRepo,
|
|
db.TaskStatusRepo,
|
|
db.NoteRepo,
|
|
db.CategoryRepo,
|
|
db.MembershipRepo,
|
|
permissionService,
|
|
)
|
|
|
|
adminService := services.NewAdminService(
|
|
db.UserRepo,
|
|
db.GroupRepo,
|
|
db.ProviderRepo,
|
|
db.LinkRepo,
|
|
db.SpaceRepo,
|
|
db.MembershipRepo,
|
|
db.NoteRepo,
|
|
db.CategoryRepo,
|
|
db.FeatureFlagRepo,
|
|
permissionService,
|
|
encryptor,
|
|
)
|
|
|
|
if err := permissionService.EnsureAdminGroup(context.Background()); err != nil {
|
|
log.Fatalf("failed to initialize admin group: %v", err)
|
|
}
|
|
|
|
if err := ensureDefaultAdminUser(
|
|
context.Background(),
|
|
db.UserRepo,
|
|
db.GroupRepo,
|
|
permissionService,
|
|
passwordHasher,
|
|
); err != nil {
|
|
log.Fatalf("failed to initialize default admin user: %v", err)
|
|
}
|
|
|
|
// Initialize handlers
|
|
authHandler := handlers.NewAuthHandler(authService, sessionManager)
|
|
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
|
noteHandler := handlers.NewNoteHandler(noteService)
|
|
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
|
taskHandler := handlers.NewTaskHandler(taskService)
|
|
adminHandler := handlers.NewAdminHandler(adminService)
|
|
publicHandler := handlers.NewPublicHandler(spaceService, noteService)
|
|
settingsHandler := handlers.NewSettingsHandler(authService)
|
|
fileService := services.NewFileService(db.FeatureFlagRepo, db.MembershipRepo, encryptor)
|
|
fileHandler := handlers.NewFileHandler(fileService)
|
|
|
|
// Create router
|
|
router := mux.NewRouter()
|
|
router.PathPrefix("/").Methods(http.MethodOptions).HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
// Middleware
|
|
authMiddleware := middleware.NewAuthMiddleware(jwtManager, sessionManager)
|
|
router.Use(middleware.LoggingMiddleware)
|
|
router.Use(middleware.CORSMiddleware)
|
|
router.Use(middleware.SecurityHeaders)
|
|
|
|
// Public endpoints
|
|
router.HandleFunc("/health", authHandler.Health).Methods("GET")
|
|
router.HandleFunc("/api/v1/auth/register", authHandler.Register).Methods("POST")
|
|
router.HandleFunc("/api/v1/auth/login", authHandler.Login).Methods("POST")
|
|
router.HandleFunc("/api/v1/auth/refresh", authHandler.RefreshToken).Methods("POST")
|
|
router.HandleFunc("/api/v1/auth/logout", authHandler.Logout).Methods("POST")
|
|
router.HandleFunc("/api/v1/auth/providers", authHandler.ListProviders).Methods("GET")
|
|
router.HandleFunc("/api/v1/auth/providers/{providerId}/start", authHandler.StartProviderLogin).Methods("GET")
|
|
router.HandleFunc("/api/v1/auth/providers/{providerId}/callback", authHandler.CompleteProviderLogin).Methods("GET")
|
|
router.HandleFunc("/api/v1/settings/feature-flags", settingsHandler.GetFeatureFlags).Methods("GET")
|
|
|
|
// Public read-only endpoints (no auth required)
|
|
public := router.PathPrefix("/api/v1/public").Subrouter()
|
|
public.HandleFunc("/spaces", publicHandler.ListPublicSpaces).Methods("GET")
|
|
public.HandleFunc("/spaces/{spaceId}", publicHandler.GetPublicSpace).Methods("GET")
|
|
public.HandleFunc("/spaces/{spaceId}/notes", publicHandler.GetPublicNotes).Methods("GET")
|
|
public.HandleFunc("/spaces/{spaceId}/notes/{noteId}", publicHandler.GetPublicNote).Methods("GET")
|
|
public.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", publicHandler.UnlockPublicNote).Methods("POST")
|
|
|
|
// Protected endpoints
|
|
api := router.PathPrefix("/api/v1").Subrouter()
|
|
api.Use(authMiddleware.Middleware)
|
|
api.HandleFunc("/auth/me", authHandler.Me).Methods("GET")
|
|
|
|
// Space endpoints
|
|
api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET")
|
|
api.HandleFunc("/spaces", spaceHandler.CreateSpace).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}", spaceHandler.GetSpace).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}", spaceHandler.UpdateSpace).Methods("PUT")
|
|
api.HandleFunc("/spaces/{spaceId}", spaceHandler.DeleteSpace).Methods("DELETE")
|
|
api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.AddMember).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.GetSpaceMembers).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/members/{userId}", spaceHandler.RemoveMember).Methods("DELETE")
|
|
api.HandleFunc("/spaces/{spaceId}/available-users", spaceHandler.GetAvailableUsers).Methods("GET")
|
|
|
|
// Note endpoints
|
|
api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.GetNotesBySpace).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.CreateNote).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/notes/search", noteHandler.SearchNotes).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.GetNote).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", noteHandler.UnlockNote).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.UpdateNote).Methods("PUT")
|
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.DeleteNote).Methods("DELETE")
|
|
|
|
// Category endpoints
|
|
api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.GetCategoryTree).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.CreateCategory).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.UpdateCategory).Methods("PUT")
|
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE")
|
|
api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH")
|
|
|
|
// Task endpoints
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.ListTaskLists).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists", taskHandler.CreateTaskList).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.UpdateTaskList).Methods("PUT")
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}", taskHandler.DeleteTaskList).Methods("DELETE")
|
|
|
|
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.ListTasks).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks", taskHandler.CreateTask).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks/search", taskHandler.SearchTasks).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.GetTask).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.UpdateTask).Methods("PUT")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}", taskHandler.DeleteTask).Methods("DELETE")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/transition", taskHandler.TransitionTaskStatus).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes", taskHandler.LinkTaskNote).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/tasks/{taskId}/notes/{noteId}", taskHandler.UnlinkTaskNote).Methods("DELETE")
|
|
api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/tasks", taskHandler.ListTasksByNote).Methods("GET")
|
|
|
|
// Task status endpoints (scoped to task list)
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.ListStatuses).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses", taskHandler.CreateStatus).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/reorder", taskHandler.ReorderStatuses).Methods("PUT")
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.UpdateStatus).Methods("PUT")
|
|
api.HandleFunc("/spaces/{spaceId}/task-lists/{taskListId}/statuses/{statusId}", taskHandler.DeleteStatus).Methods("DELETE")
|
|
|
|
// File explorer endpoints (space-scoped)
|
|
api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET")
|
|
api.HandleFunc("/spaces/{spaceId}/files/upload", fileHandler.UploadFile).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.CreateFolder).Methods("POST")
|
|
api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.DeleteFile).Methods("DELETE")
|
|
api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.DeleteFolder).Methods("DELETE")
|
|
|
|
// Admin endpoints
|
|
admin := router.PathPrefix("/api/v1/admin").Subrouter()
|
|
admin.Use(authMiddleware.Middleware)
|
|
admin.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
userIDHex, err := middleware.GetUserIDFromContext(r.Context())
|
|
if err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, err := bson.ObjectIDFromHex(userIDHex)
|
|
if err != nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
allowed, err := permissionService.UserHasPermission(r.Context(), userID, "admin.access")
|
|
if err != nil {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
if !allowed {
|
|
allowed, err = permissionService.UserHasPermission(r.Context(), userID, "*")
|
|
if err != nil || !allowed {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET")
|
|
admin.HandleFunc("/users/{userId}", adminHandler.DeleteUser).Methods("DELETE")
|
|
admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT")
|
|
admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET")
|
|
admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST")
|
|
admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT")
|
|
admin.HandleFunc("/groups/{groupId}", adminHandler.DeleteGroup).Methods("DELETE")
|
|
admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET")
|
|
admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT")
|
|
admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE")
|
|
admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.AddSpaceMember).Methods("POST")
|
|
admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.ListSpaceMembers).Methods("GET")
|
|
admin.HandleFunc("/spaces/{spaceId}/members/{userId}", adminHandler.RemoveSpaceMember).Methods("DELETE")
|
|
admin.HandleFunc("/spaces/{spaceId}/visibility", adminHandler.SetSpaceVisibility).Methods("PUT")
|
|
admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET")
|
|
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
|
// manage identity providers — admin-only
|
|
admin.HandleFunc("/auth/providers", authHandler.ListProvidersForAdmin).Methods("GET")
|
|
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
|
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
|
|
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
|
|
|
|
// Serve static files (NextJS frontend) for all other routes.
|
|
// Must come after all API route handlers.
|
|
router.PathPrefix("/").HandlerFunc(serveNextJS)
|
|
|
|
// Start server
|
|
server := &http.Server{
|
|
Addr: ":" + port,
|
|
Handler: router,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
log.Printf("Server starting on port %s\n", port)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("Server error: %v", err)
|
|
}
|
|
}
|
|
|
|
func ensureDefaultAdminUser(
|
|
ctx context.Context,
|
|
userRepo repositories.UserRepository,
|
|
groupRepo repositories.GroupRepository,
|
|
permissionService *services.PermissionService,
|
|
passwordHasher *security.PasswordHasher,
|
|
) error {
|
|
adminEmail := strings.ToLower(strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_EMAIL")))
|
|
adminUsername := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_USERNAME"))
|
|
adminPassword := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_PASSWORD"))
|
|
adminFirstName := "System"
|
|
adminLastName := "Admin"
|
|
|
|
if adminEmail == "" && adminUsername == "" && adminPassword == "" {
|
|
log.Println("default admin bootstrap skipped (DEFAULT_ADMIN_* env vars not set)")
|
|
return nil
|
|
}
|
|
|
|
if adminEmail == "" || adminUsername == "" || adminPassword == "" {
|
|
return errors.New("DEFAULT_ADMIN_EMAIL, DEFAULT_ADMIN_USERNAME and DEFAULT_ADMIN_PASSWORD must all be set")
|
|
}
|
|
|
|
if len(adminPassword) < 8 {
|
|
return errors.New("DEFAULT_ADMIN_PASSWORD must be at least 8 characters")
|
|
}
|
|
|
|
adminGroup, err := groupRepo.GetGroupByName(ctx, "Admin")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
user, err := userRepo.GetUserByEmail(ctx, adminEmail)
|
|
if err != nil {
|
|
if err.Error() != "user not found" {
|
|
return err
|
|
}
|
|
|
|
if existingUsernameUser, usernameErr := userRepo.GetUserByUsername(ctx, adminUsername); usernameErr == nil && existingUsernameUser != nil {
|
|
return errors.New("DEFAULT_ADMIN_USERNAME already belongs to another user")
|
|
} else if usernameErr != nil && usernameErr.Error() != "user not found" {
|
|
return usernameErr
|
|
}
|
|
|
|
hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword)
|
|
if hashErr != nil {
|
|
return hashErr
|
|
}
|
|
|
|
user = &entities.User{
|
|
Email: adminEmail,
|
|
Username: adminUsername,
|
|
PasswordHash: hashedPassword,
|
|
FirstName: adminFirstName,
|
|
LastName: adminLastName,
|
|
GroupIDs: []bson.ObjectID{adminGroup.ID},
|
|
IsActive: true,
|
|
EmailVerified: true,
|
|
}
|
|
|
|
if createErr := userRepo.CreateUser(ctx, user); createErr != nil {
|
|
return createErr
|
|
}
|
|
|
|
if permissionService != nil {
|
|
if permErr := permissionService.UpdateUserEffectivePermissions(ctx, user); permErr != nil {
|
|
return permErr
|
|
}
|
|
}
|
|
|
|
log.Printf("default admin user created: %s", adminEmail)
|
|
return nil
|
|
}
|
|
|
|
modified := false
|
|
if !user.IsActive {
|
|
user.IsActive = true
|
|
modified = true
|
|
}
|
|
|
|
hasAdminGroup := false
|
|
for _, groupID := range user.GroupIDs {
|
|
if groupID == adminGroup.ID {
|
|
hasAdminGroup = true
|
|
break
|
|
}
|
|
}
|
|
if !hasAdminGroup {
|
|
user.GroupIDs = append(user.GroupIDs, adminGroup.ID)
|
|
modified = true
|
|
}
|
|
|
|
passwordMatches, verifyErr := passwordHasher.VerifyPassword(adminPassword, user.PasswordHash)
|
|
if verifyErr != nil {
|
|
return verifyErr
|
|
}
|
|
if !passwordMatches {
|
|
hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword)
|
|
if hashErr != nil {
|
|
return hashErr
|
|
}
|
|
user.PasswordHash = hashedPassword
|
|
modified = true
|
|
}
|
|
|
|
if !modified {
|
|
log.Printf("default admin user already initialized: %s", adminEmail)
|
|
return nil
|
|
}
|
|
|
|
if permissionService != nil {
|
|
if err := permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := userRepo.UpdateUser(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
log.Printf("default admin user synchronized from environment: %s", adminEmail)
|
|
return nil
|
|
}
|
|
|
|
// serveNextJS serves the NextJS static export from ./public.
|
|
// It handles /_next/ assets directly, tries {path}/index.html for page routes,
|
|
// and falls back to dynamic route pattern matching (NextJS [param] folders).
|
|
func serveNextJS(w http.ResponseWriter, r *http.Request) {
|
|
const publicDir = "./public"
|
|
urlPath := r.URL.Path
|
|
|
|
// ── /_next/ and other static assets ─────────────────────────────────────
|
|
if strings.HasPrefix(urlPath, "/_next/") {
|
|
http.ServeFile(w, r, filepath.Join(publicDir, filepath.FromSlash(urlPath)))
|
|
return
|
|
}
|
|
|
|
staticExts := []string{
|
|
".js", ".css", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico",
|
|
".woff", ".woff2", ".ttf", ".eot", ".json", ".map", ".txt", ".xml",
|
|
}
|
|
for _, ext := range staticExts {
|
|
if strings.HasSuffix(urlPath, ext) {
|
|
http.ServeFile(w, r, filepath.Join(publicDir, filepath.FromSlash(urlPath)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// ── HTML page resolution ─────────────────────────────────────────────────
|
|
// NextJS static export with trailingSlash:true produces {path}/index.html
|
|
cleanPath := strings.TrimRight(urlPath, "/")
|
|
if cleanPath == "" {
|
|
cleanPath = "/"
|
|
}
|
|
|
|
// 1. Try exact {path}/index.html
|
|
candidate := filepath.Join(publicDir, filepath.FromSlash(cleanPath), "index.html")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
http.ServeFile(w, r, candidate)
|
|
return
|
|
}
|
|
|
|
// 2. Try {path}.html
|
|
candidate = filepath.Join(publicDir, filepath.FromSlash(cleanPath)+".html")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
http.ServeFile(w, r, candidate)
|
|
return
|
|
}
|
|
|
|
// 3. Walk path segments and try NextJS dynamic [param] folders
|
|
segments := strings.Split(strings.Trim(cleanPath, "/"), "/")
|
|
if result := findNextJSPage(publicDir, segments); result != "" {
|
|
http.ServeFile(w, r, result)
|
|
return
|
|
}
|
|
|
|
// 4. Fallback: root index.html
|
|
http.ServeFile(w, r, filepath.Join(publicDir, "index.html"))
|
|
}
|
|
|
|
// findNextJSPage recursively searches the public directory for an HTML file
|
|
// that matches the given URL segments, replacing unknown segments with any
|
|
// NextJS dynamic directory ([param] named folders).
|
|
func findNextJSPage(publicDir string, segments []string) string {
|
|
return searchNextJSSegments(publicDir, segments, 0, publicDir)
|
|
}
|
|
|
|
func searchNextJSSegments(publicDir string, segments []string, idx int, currentDir string) string {
|
|
if idx == len(segments) {
|
|
candidate := filepath.Join(currentDir, "index.html")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
return ""
|
|
}
|
|
|
|
seg := segments[idx]
|
|
|
|
// Try the exact segment directory
|
|
exactDir := filepath.Join(currentDir, seg)
|
|
if result := searchNextJSSegments(publicDir, segments, idx+1, exactDir); result != "" {
|
|
return result
|
|
}
|
|
|
|
// Try NextJS dynamic segment directories.
|
|
// Matches both [param] (source convention) and __param__ (static export convention).
|
|
entries, err := os.ReadDir(currentDir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
isBracket := len(name) > 2 && name[0] == '[' && name[len(name)-1] == ']'
|
|
isPlaceholder := len(name) >= 4 && strings.HasPrefix(name, "__") && strings.HasSuffix(name, "__")
|
|
if isBracket || isPlaceholder {
|
|
dynamicDir := filepath.Join(currentDir, name)
|
|
if result := searchNextJSSegments(publicDir, segments, idx+1, dynamicDir); result != "" {
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|