package main import ( "context" "html/template" "log" "net/http" "os" "os/signal" "path/filepath" "syscall" "time" "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/joho/godotenv" "task-dashboard/internal/api" "task-dashboard/internal/auth" "task-dashboard/internal/config" "task-dashboard/internal/handlers" "task-dashboard/internal/store" ) func main() { // Load .env file (ignore error if file doesn't exist - env vars might be set directly) _ = godotenv.Load() // Load configuration cfg, err := config.Load() if err != nil { log.Fatalf("Failed to load configuration: %v", err) } // Initialize database db, err := store.New(cfg.DatabasePath) if err != nil { log.Fatalf("Failed to initialize database: %v", err) } defer db.Close() // Initialize session manager sessionManager := scs.New() sessionManager.Store = sqlite3store.New(db.DB()) sessionManager.Lifetime = 24 * time.Hour sessionManager.Cookie.Persist = true sessionManager.Cookie.Secure = !cfg.Debug sessionManager.Cookie.SameSite = http.SameSiteLaxMode // Initialize auth service authService := auth.NewService(db.DB()) // Ensure default admin user exists (use env vars for credentials) defaultUser := os.Getenv("DEFAULT_USER") defaultPass := os.Getenv("DEFAULT_PASS") if defaultUser == "" { defaultUser = "admin" } if defaultPass == "" { defaultPass = "changeme" } if err := authService.EnsureDefaultUser(defaultUser, defaultPass); err != nil { log.Printf("Warning: failed to ensure default user: %v", err) } // Parse templates for auth handlers authTemplates, err := template.ParseGlob(filepath.Join(cfg.TemplateDir, "*.html")) if err != nil { log.Printf("Warning: failed to parse auth templates: %v", err) } // Initialize auth handlers authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates) // Initialize API clients todoistClient := api.NewTodoistClient(cfg.TodoistAPIKey) trelloClient := api.NewTrelloClient(cfg.TrelloAPIKey, cfg.TrelloToken) var planToEatClient api.PlanToEatAPI if cfg.HasPlanToEat() { planToEatClient = api.NewPlanToEatClient(cfg.PlanToEatAPIKey) } var googleCalendarClient api.GoogleCalendarAPI if cfg.HasGoogleCalendar() { var err error googleCalendarClient, err = api.NewGoogleCalendarClient(context.Background(), cfg.GoogleCredentialsFile, cfg.GoogleCalendarID) if err != nil { log.Printf("Warning: failed to initialize Google Calendar client: %v", err) } else { log.Printf("Google Calendar client initialized for calendars: %s", cfg.GoogleCalendarID) } } // Initialize handlers h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, cfg) tabsHandler := handlers.NewTabsHandler(db, googleCalendarClient, cfg.TemplateDir) // Set up router r := chi.NewRouter() // Global middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(60 * time.Second)) r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally r.Use(authHandlers.Middleware().CSRFProtect) // CSRF protection // Public routes (no auth required) r.Get("/login", authHandlers.HandleLoginPage) r.Post("/login", authHandlers.HandleLogin) r.Post("/logout", authHandlers.HandleLogout) // Serve static files (public) fileServer := http.FileServer(http.Dir(cfg.StaticDir)) r.Handle("/static/*", http.StripPrefix("/static/", fileServer)) // Protected routes (auth required) r.Group(func(r chi.Router) { r.Use(authHandlers.Middleware().RequireAuth) // Dashboard r.Get("/", h.HandleDashboard) r.Post("/api/refresh", h.HandleRefresh) r.Get("/api/tasks", h.HandleGetTasks) r.Get("/api/meals", h.HandleGetMeals) r.Get("/api/boards", h.HandleGetBoards) // Tab routes for HTMX r.Get("/tabs/tasks", tabsHandler.HandleTasks) r.Get("/tabs/planning", tabsHandler.HandlePlanning) r.Get("/tabs/meals", tabsHandler.HandleMeals) r.Post("/tabs/refresh", h.HandleRefreshTab) // Trello card operations r.Post("/cards", h.HandleCreateCard) r.Post("/cards/complete", h.HandleCompleteCard) // Todoist task operations r.Post("/tasks", h.HandleCreateTask) r.Post("/tasks/complete", h.HandleCompleteTask) // Unified task completion (for Tasks tab Atoms) r.Post("/complete-atom", h.HandleCompleteAtom) // Unified Quick Add (for Tasks tab) r.Post("/unified-add", h.HandleUnifiedAdd) r.Get("/partials/lists", h.HandleGetListsOptions) // Task detail/edit r.Get("/tasks/detail", h.HandleGetTaskDetail) r.Post("/tasks/update", h.HandleUpdateTask) // Bug reporting r.Get("/bugs", h.HandleGetBugs) r.Post("/bugs", h.HandleReportBug) }) // Start server addr := ":" + cfg.Port srv := &http.Server{ Addr: addr, Handler: r, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } // Graceful shutdown go func() { log.Printf("Starting server on http://localhost%s", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } }() // Wait for interrupt signal quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Println("Shutting down server...") // Graceful shutdown with timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Fatalf("Server forced to shutdown: %v", err) } log.Println("Server exited") }