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, wa != nil)
// 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 and write operations
r.Group(func(r chi.Router) {
r.Use(h.AgentAuthMiddleware)
r.Get("/context", h.HandleAgentContext)
// Write Operations
r.Post("/tasks/{id}/complete", h.HandleAgentTaskComplete)
r.Post("/tasks/{id}/uncomplete", h.HandleAgentTaskUncomplete)
r.Patch("/tasks/{id}/due", h.HandleAgentTaskUpdateDue)
r.Patch("/tasks/{id}", h.HandleAgentTaskUpdate)
// Create Operations
r.Post("/tasks", h.HandleAgentTaskCreate)
r.Post("/shopping", h.HandleAgentShoppingAdd)
})
// 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)
// 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)
// 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/clear-cache", h.HandleClearCache)
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)
r.Delete("/settings/agents/{id}", h.HandleDeleteAgent)
// 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")
}