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" appmiddleware "task-dashboard/internal/middleware" "task-dashboard/internal/store" "github.com/go-webauthn/webauthn/webauthn" ) // Set via -ldflags at build time var ( buildCommit = "dev" buildTime = "unknown" ) func main() { log.Printf("task-dashboard build=%s time=%s", buildCommit, buildTime) // 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) } // Set display timezone (must be done early, before any time operations) config.SetDisplayTimezone(cfg.Timezone) log.Printf("Display timezone set to: %s", cfg.Timezone) // Initialize database db, err := store.New(cfg.DatabasePath, cfg.MigrationDir) if err != nil { log.Fatalf("Failed to initialize database: %v", err) } defer func() { _ = db.Close() }() // Initialize session manager sessionManager := scs.New() sessionManager.Store = sqlite3store.New(db.DB()) sessionManager.Lifetime = config.SessionLifetime sessionManager.IdleTimeout = 24 * time.Hour // Extend session on activity sessionManager.Cookie.Persist = true sessionManager.Cookie.Secure = !cfg.Debug sessionManager.Cookie.SameSite = http.SameSiteLaxMode sessionManager.Cookie.Name = "session" // Standard name for better compatibility // 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 == "" { log.Fatal("CRITICAL: DEFAULT_PASS environment variable must be set. Cannot start without a password.") } 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 WebAuthn (optional - only if configured) var wa *webauthn.WebAuthn if cfg.WebAuthnRPID != "" && cfg.WebAuthnOrigin != "" { var err error wa, err = webauthn.New(&webauthn.Config{ RPDisplayName: "Task Dashboard", RPID: cfg.WebAuthnRPID, RPOrigins: []string{cfg.WebAuthnOrigin}, }) if err != nil { log.Fatalf("Failed to initialize WebAuthn: %v", err) } log.Printf("WebAuthn initialized (RP ID: %s, Origin: %s)", cfg.WebAuthnRPID, cfg.WebAuthnOrigin) } // Initialize auth handlers authHandlers := auth.NewHandlers(authService, sessionManager, authTemplates, wa) // Initialize API clients todoistClient := api.NewTodoistClient(cfg.TodoistAPIKey) trelloClient := api.NewTrelloClient(cfg.TrelloAPIKey, cfg.TrelloToken) var planToEatClient api.PlanToEatAPI if cfg.HasPlanToEat() { pteClient := api.NewPlanToEatClient(cfg.PlanToEatAPIKey) if cfg.PlanToEatSession != "" { pteClient.SetSessionCookie(cfg.PlanToEatSession) } planToEatClient = pteClient } var googleCalendarClient api.GoogleCalendarAPI if cfg.HasGoogleCalendar() { // Use timeout context to prevent startup hangs if credentials file is unreachable initCtx, cancel := context.WithTimeout(context.Background(), config.GoogleCalendarInitTimeout) var err error googleCalendarClient, err = api.NewGoogleCalendarClient(initCtx, cfg.GoogleCredentialsFile, cfg.GoogleCalendarID, cfg.Timezone) cancel() if err != nil { log.Printf("Warning: failed to initialize Google Calendar client: %v", err) } else { log.Printf("Google Calendar client initialized for calendars: %s (timezone: %s)", cfg.GoogleCalendarID, cfg.Timezone) } } var googleTasksClient api.GoogleTasksAPI if cfg.HasGoogleTasks() { initCtx, cancel := context.WithTimeout(context.Background(), config.GoogleCalendarInitTimeout) var err error googleTasksClient, err = api.NewGoogleTasksClient(initCtx, cfg.GoogleCredentialsFile, cfg.GoogleTasksListID, cfg.Timezone) cancel() if err != nil { log.Printf("Warning: failed to initialize Google Tasks client: %v", err) } else { log.Printf("Google Tasks client initialized for list: %s", cfg.GoogleTasksListID) } } // Initialize handlers h := handlers.New(db, todoistClient, trelloClient, planToEatClient, googleCalendarClient, googleTasksClient, cfg, buildCommit) // Set up router r := chi.NewRouter() // Global middleware r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(config.RequestTimeout)) r.Use(appmiddleware.SecurityHeaders(cfg.Debug)) // Security headers r.Use(sessionManager.LoadAndSave) // Session middleware must be applied globally r.Use(authHandlers.Middleware().CSRFProtect) // CSRF protection // Rate limiter for auth endpoints authRateLimiter := appmiddleware.NewRateLimiter(config.AuthRateLimitRequests, config.AuthRateLimitWindow) // Rate limiter for agent auth (stricter - 10 requests/minute per IP) agentAuthRateLimiter := appmiddleware.NewRateLimiter(10, time.Minute) // Public routes (no auth required) r.Get("/login", authHandlers.HandleLoginPage) r.With(authRateLimiter.Limit).Post("/login", authHandlers.HandleLogin) r.Post("/logout", authHandlers.HandleLogout) // WebAuthn public routes (rate-limited) r.With(authRateLimiter.Limit).Post("/passkeys/login/begin", authHandlers.HandlePasskeyLoginBegin) r.With(authRateLimiter.Limit).Post("/passkeys/login/finish", authHandlers.HandlePasskeyLoginFinish) // Serve static files (public) fileServer := http.FileServer(http.Dir(cfg.StaticDir)) r.Handle("/static/*", http.StripPrefix("/static/", fileServer)) // Conditions page (public - no auth required) r.Get("/conditions", h.HandleConditionsPage) // Agent API r.Route("/agent", func(r chi.Router) { // Public endpoints (no browser auth, but rate limited) r.With(agentAuthRateLimiter.Limit).Post("/auth/request", h.HandleAgentAuthRequest) r.Get("/auth/poll", h.HandleAgentAuthPoll) // Browser auth required for approve/deny r.Group(func(r chi.Router) { r.Use(authHandlers.Middleware().RequireAuth) r.Post("/auth/approve", h.HandleAgentAuthApprove) r.Post("/auth/deny", h.HandleAgentAuthDeny) }) // Agent session required for context r.Group(func(r chi.Router) { r.Use(h.AgentAuthMiddleware) r.Get("/context", h.HandleAgentContext) }) // HTML endpoints for browser-only agents (GET requests only) r.Route("/web", func(r chi.Router) { r.With(agentAuthRateLimiter.Limit).Get("/request", h.HandleAgentWebRequest) r.Get("/status", h.HandleAgentWebStatus) r.Get("/context", h.HandleAgentWebContext) }) }) // 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) r.Get("/api/shopping", h.HandleGetShoppingList) // Tab routes for HTMX r.Get("/tabs/tasks", h.HandleTabTasks) r.Get("/tabs/planning", h.HandleTabPlanning) r.Get("/tabs/meals", h.HandleTabMeals) r.Get("/tabs/timeline", h.HandleTimeline) r.Get("/tabs/shopping", h.HandleTabShopping) r.Get("/tabs/conditions", h.HandleTabConditions) 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) r.Post("/uncomplete-atom", h.HandleUncompleteAtom) // Unified Quick Add (for Tasks tab) r.Post("/unified-add", h.HandleUnifiedAdd) r.Get("/partials/lists", h.HandleGetListsOptions) r.Get("/partials/shopping-lists", h.HandleGetShoppingLists) // 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) // Shopping quick-add r.Post("/shopping/add", h.HandleShoppingQuickAdd) r.Post("/shopping/toggle", h.HandleShoppingToggle) // Shopping mode (focused single-store view) r.Get("/shopping/mode/{store}", h.HandleShoppingMode) r.Post("/shopping/mode/{store}/toggle", h.HandleShoppingModeToggle) r.Post("/shopping/mode/{store}/complete", h.HandleShoppingModeComplete) // Passkey management (WebAuthn) r.Get("/settings/passkeys", authHandlers.HandleListPasskeys) r.Post("/passkeys/register/begin", authHandlers.HandlePasskeyRegisterBegin) r.Post("/passkeys/register/finish", authHandlers.HandlePasskeyRegisterFinish) r.Delete("/passkeys/{id}", authHandlers.HandleDeletePasskey) // Settings r.Get("/settings", h.HandleSettingsPage) r.Post("/settings/sync", h.HandleSyncSources) r.Post("/settings/toggle", h.HandleToggleSourceConfig) r.Post("/settings/features", h.HandleCreateFeature) r.Post("/settings/features/toggle", h.HandleToggleFeature) r.Delete("/settings/features/{name}", h.HandleDeleteFeature) // WebSocket for notifications r.Get("/ws/notifications", h.HandleWebSocket) }) // 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(), config.GracefulShutdownTimeout) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Fatalf("Server forced to shutdown: %v", err) } log.Println("Server exited") }