package main import ( "context" "errors" "log" "net/http" "os" "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.UserRepo, permissionService, ) noteService := services.NewNoteService( db.NoteRepo, db.CategoryRepo, db.MembershipRepo, nil, // NoteRevisionRepository db.SpaceRepo, permissionService, passwordHasher, ) categoryService := services.NewCategoryService( db.CategoryRepo, db.MembershipRepo, db.NoteRepo, 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) 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") // 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 (frontend) for all other routes // This must be after all API route handlers to allow API routes to take precedence router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // List of static file extensions to serve directly staticExts := map[string]bool{ ".js": true, ".css": true, ".svg": true, ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, ".woff": true, ".woff2": true, ".ttf": true, ".eot": true, } filePath := "./public" + r.URL.Path if r.URL.Path == "/" { filePath = "./public/index.html" } // Check if it's a static file (has an extension in staticExts) isStatic := false for ext := range staticExts { if len(r.URL.Path) > len(ext) { if r.URL.Path[len(r.URL.Path)-len(ext):] == ext { isStatic = true break } } } // If it doesn't look like a static file, serve index.html (SPA routing) if !isStatic { filePath = "./public/index.html" } http.ServeFile(w, r, filePath) }) // 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 }