first commit
This commit is contained in:
433
backend/cmd/server/main.go
Normal file
433
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/noteapp/backend/internal/application/services"
|
||||
"github.com/noteapp/backend/internal/domain/entities"
|
||||
"github.com/noteapp/backend/internal/domain/repositories"
|
||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
||||
"github.com/noteapp/backend/internal/infrastructure/database"
|
||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
||||
"github.com/noteapp/backend/internal/interfaces/handlers"
|
||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
||||
"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"
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
// 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)
|
||||
|
||||
// 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.SpaceRepo,
|
||||
db.MembershipRepo,
|
||||
db.NoteRepo,
|
||||
db.CategoryRepo,
|
||||
db.FeatureFlagRepo,
|
||||
permissionService,
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
// 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)
|
||||
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)
|
||||
|
||||
// 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")
|
||||
|
||||
// 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}/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("/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.CreateProvider).Methods("POST")
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user