Files
notely/backend/cmd/server/main.go
domrichardson 6774c401bf
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 49s
feat: updated identity providers in admin panel
2026-03-25 15:17:48 +00:00

451 lines
15 KiB
Go

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.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)
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)
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")
// 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.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
}