package handlers import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "log" "net/http" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" ) // ----------------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------------- const ( AgentRequestExpiry = 5 * time.Minute AgentSessionTTL = 1 * time.Hour TokenBytes = 32 ) // Context key for agent session in request context type contextKey string const agentSessionContextKey contextKey = "agent_session" // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- // AgentRequestPayload is sent via WebSocket when an agent requests access type AgentRequestPayload struct { RequestToken string `json:"request_token"` AgentName string `json:"agent_name"` AgentID string `json:"agent_id"` TrustLevel models.AgentTrustLevel `json:"trust_level"` ExpiresAt time.Time `json:"expires_at"` } // agentContextItem is the JSON-serializable timeline item for agent context API type agentContextItem struct { ID string `json:"id"` Source string `json:"source"` Type string `json:"type"` Title string `json:"title"` Description string `json:"description,omitempty"` Due *time.Time `json:"due,omitempty"` Priority int `json:"priority,omitempty"` Completable bool `json:"completable"` URL string `json:"url,omitempty"` DaySection string `json:"day_section,omitempty"` // "overdue", "today", "tomorrow", "later" } // agentCompletedItem represents a completed task in the log type agentCompletedItem struct { Source string `json:"source"` Title string `json:"title"` DueDate *time.Time `json:"due_date,omitempty"` CompletedAt time.Time `json:"completed_at"` } // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- // generateToken creates a cryptographically random token func generateToken() (string, error) { b := make([]byte, TokenBytes) if _, err := rand.Read(b); err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } // isSessionExpired checks if a pending session has expired func isSessionExpired(session *models.AgentSession) bool { return time.Now().After(session.ExpiresAt) && session.Status == "pending" } // timelineItemToAgentItem converts a TimelineItem to the agent API format func timelineItemToAgentItem(item models.TimelineItem) agentContextItem { t := item.Time return agentContextItem{ ID: item.ID, Source: item.Source, Type: string(item.Type), Title: item.Title, Description: item.Description, Due: &t, Completable: item.Type == models.TimelineItemTypeTask || item.Type == models.TimelineItemTypeCard || item.Type == models.TimelineItemTypeGTask, URL: item.URL, DaySection: string(item.DaySection), } } // renderAgentTemplate renders an agent template with common error handling func (h *Handler) renderAgentTemplate(w http.ResponseWriter, templateName string, data interface{}) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if h.renderer == nil { h.renderAgentError(w, "Renderer not configured", http.StatusInternalServerError) return } if err := h.renderer.Render(w, templateName, data); err != nil { h.renderAgentError(w, "Template error", http.StatusInternalServerError) } } // ----------------------------------------------------------------------------- // Auth Handlers // ----------------------------------------------------------------------------- // HandleAgentAuthRequest handles POST /agent/auth/request func (h *Handler) HandleAgentAuthRequest(w http.ResponseWriter, r *http.Request) { var req models.AgentAuthRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Name == "" || req.AgentID == "" { http.Error(w, "name and agent_id are required", http.StatusBadRequest) return } // Invalidate any previous sessions for this agent if err := h.store.InvalidatePreviousAgentSessions(req.AgentID); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Generate request token requestToken, err := generateToken() if err != nil { http.Error(w, "Failed to generate token", http.StatusInternalServerError) return } // Create pending session session := &models.AgentSession{ RequestToken: requestToken, AgentName: req.Name, AgentID: req.AgentID, ExpiresAt: time.Now().Add(AgentRequestExpiry), } if err := h.store.CreateAgentSession(session); err != nil { http.Error(w, "Failed to create session", http.StatusInternalServerError) return } // Check trust level for WebSocket notification trustLevel, err := h.store.CheckAgentTrust(req.Name, req.AgentID) if err != nil { trustLevel = models.AgentTrustNew } // Broadcast to connected browsers via WebSocket h.BroadcastAgentRequest(session, trustLevel) // Return response w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(models.AgentAuthResponse{ RequestToken: requestToken, Status: "pending", }) } // HandleAgentAuthPoll handles GET /agent/auth/poll func (h *Handler) HandleAgentAuthPoll(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { http.Error(w, "token parameter required", http.StatusBadRequest) return } session, err := h.store.GetAgentSessionByRequestToken(token) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } if session == nil { http.Error(w, "Session not found", http.StatusNotFound) return } if isSessionExpired(session) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(models.AgentPollResponse{Status: "expired"}) return } resp := models.AgentPollResponse{Status: session.Status} if session.Status == "approved" && session.SessionToken != "" { resp.SessionToken = session.SessionToken resp.ExpiresAt = session.SessionExpiresAt } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // HandleAgentAuthApprove handles POST /agent/auth/approve (browser auth required) func (h *Handler) HandleAgentAuthApprove(w http.ResponseWriter, r *http.Request) { var req struct { RequestToken string `json:"request_token"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.RequestToken == "" { http.Error(w, "request_token required", http.StatusBadRequest) return } // Verify session exists and is pending session, err := h.store.GetAgentSessionByRequestToken(req.RequestToken) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } if session == nil { http.Error(w, "Session not found", http.StatusNotFound) return } if session.Status != "pending" { http.Error(w, "Session already processed", http.StatusConflict) return } if time.Now().After(session.ExpiresAt) { http.Error(w, "Session expired", http.StatusGone) return } // Generate session token sessionToken, err := generateToken() if err != nil { http.Error(w, "Failed to generate session token", http.StatusInternalServerError) return } sessionExpiresAt := time.Now().Add(AgentSessionTTL) // Approve the session if err := h.store.ApproveAgentSession(req.RequestToken, sessionToken, sessionExpiresAt); err != nil { http.Error(w, "Failed to approve session", http.StatusInternalServerError) return } // Register/update agent in the trusted agents table if err := h.store.CreateOrUpdateAgent(session.AgentName, session.AgentID); err != nil { // Don't fail - the session was approved; this just affects future trust level checks log.Printf("warning: failed to register agent %q: %v", session.AgentName, err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) } // HandleAgentAuthDeny handles POST /agent/auth/deny (browser auth required) func (h *Handler) HandleAgentAuthDeny(w http.ResponseWriter, r *http.Request) { var req struct { RequestToken string `json:"request_token"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.RequestToken == "" { http.Error(w, "request_token required", http.StatusBadRequest) return } if err := h.store.DenyAgentSession(req.RequestToken); err != nil { http.Error(w, "Failed to deny session", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "denied"}) } // ----------------------------------------------------------------------------- // Context Handlers // ----------------------------------------------------------------------------- // HandleAgentContext handles GET /agent/context (agent auth required) func (h *Handler) HandleAgentContext(w http.ResponseWriter, r *http.Request) { session := r.Context().Value(agentSessionContextKey).(*models.AgentSession) _ = h.store.UpdateAgentLastSeen(session.AgentID) now := config.Now() today := config.Today() // Extend range: 7 days back (overdue) to 14 days forward startDate := today.Add(-7 * 24 * time.Hour) endDate := today.Add(14 * 24 * time.Hour) ctx := r.Context() timeline := h.buildAgentContext(ctx, startDate, endDate) completedLog := h.buildCompletedLog(50) // Last 50 completed tasks resp := map[string]interface{}{ "generated_at": now.Format(time.RFC3339), "range": map[string]string{ "start": startDate.Format("2006-01-02"), "end": endDate.Format("2006-01-02"), }, "timeline": timeline, "completed_log": completedLog, "summary": h.buildContextSummary(timeline, today), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } // buildAgentContext builds the context timeline by reusing BuildTimeline func (h *Handler) buildAgentContext(ctx context.Context, start, end time.Time) []agentContextItem { // Reuse the main BuildTimeline function (excludes live API calls for Google services) timelineItems, err := BuildTimeline(ctx, h.store, nil, start, end) if err != nil { return nil } // Convert to agent API format, filtering completed items var items []agentContextItem for _, item := range timelineItems { if item.IsCompleted { continue } items = append(items, timelineItemToAgentItem(item)) } return items } // buildContextSummary builds summary statistics for the agent context func (h *Handler) buildContextSummary(items []agentContextItem, today time.Time) map[string]interface{} { bySource := make(map[string]int) bySection := make(map[string]int) endOfToday := today.Add(24 * time.Hour) for _, item := range items { bySource[item.Source]++ if item.DaySection != "" { bySection[item.DaySection]++ } else if item.Due != nil { if item.Due.Before(today) { bySection["overdue"]++ } else if item.Due.Before(endOfToday) { bySection["today"]++ } } } return map[string]interface{}{ "total_items": len(items), "by_source": bySource, "by_section": bySection, } } // buildCompletedLog retrieves recently completed tasks func (h *Handler) buildCompletedLog(limit int) []agentCompletedItem { completed, err := h.store.GetCompletedTasks(limit) if err != nil { return nil } items := make([]agentCompletedItem, len(completed)) for i, c := range completed { items[i] = agentCompletedItem{ Source: c.Source, Title: c.Title, DueDate: c.DueDate, CompletedAt: c.CompletedAt, } } return items } // ----------------------------------------------------------------------------- // Middleware // ----------------------------------------------------------------------------- // AgentAuthMiddleware verifies agent session token func (h *Handler) AgentAuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" || len(authHeader) < 8 || authHeader[:7] != "Bearer " { http.Error(w, "Authorization header required", http.StatusUnauthorized) return } token := authHeader[7:] session, err := h.store.GetAgentSessionBySessionToken(token) if err != nil || session == nil { http.Error(w, "Invalid session token", http.StatusUnauthorized) return } // Check session expiry if session.SessionExpiresAt != nil && time.Now().After(*session.SessionExpiresAt) { http.Error(w, "Session expired", http.StatusUnauthorized) return } // Add session to context ctx := context.WithValue(r.Context(), agentSessionContextKey, session) next.ServeHTTP(w, r.WithContext(ctx)) }) } // ----------------------------------------------------------------------------- // Web Handlers (HTML pages for browser-only agents) // ----------------------------------------------------------------------------- // HandleAgentWebRequest handles GET /agent/web/request for browser-only agents func (h *Handler) HandleAgentWebRequest(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") agentID := r.URL.Query().Get("agent_id") if name == "" || agentID == "" { h.renderAgentError(w, "name and agent_id query parameters are required", http.StatusBadRequest) return } // Check for existing pending session existingSession, err := h.store.GetPendingAgentSessionByAgentID(agentID) if err != nil { h.renderAgentError(w, "Internal server error", http.StatusInternalServerError) return } if existingSession != nil { // Return existing pending session h.renderAgentRequest(w, existingSession) return } // Invalidate any previous sessions for this agent if err := h.store.InvalidatePreviousAgentSessions(agentID); err != nil { h.renderAgentError(w, "Internal server error", http.StatusInternalServerError) return } // Generate request token requestToken, err := generateToken() if err != nil { h.renderAgentError(w, "Failed to generate token", http.StatusInternalServerError) return } // Create pending session session := &models.AgentSession{ RequestToken: requestToken, AgentName: name, AgentID: agentID, ExpiresAt: time.Now().Add(AgentRequestExpiry), } if err := h.store.CreateAgentSession(session); err != nil { h.renderAgentError(w, "Failed to create session", http.StatusInternalServerError) return } // Check trust level for WebSocket notification trustLevel, err := h.store.CheckAgentTrust(name, agentID) if err != nil { trustLevel = models.AgentTrustNew } // Broadcast to connected browsers via WebSocket h.BroadcastAgentRequest(session, trustLevel) h.renderAgentRequest(w, session) } // HandleAgentWebStatus handles GET /agent/web/status for browser-only agents // Returns HTML page with approval status and session token if approved func (h *Handler) HandleAgentWebStatus(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { h.renderAgentError(w, "token query parameter required", http.StatusBadRequest) return } session, err := h.store.GetAgentSessionByRequestToken(token) if err != nil { h.renderAgentError(w, "Internal server error", http.StatusInternalServerError) return } if session == nil { h.renderAgentError(w, "Session not found", http.StatusNotFound) return } h.renderAgentStatus(w, session) } // HandleAgentWebContext handles GET /agent/web/context for browser-only agents func (h *Handler) HandleAgentWebContext(w http.ResponseWriter, r *http.Request) { sessionToken := r.URL.Query().Get("session") if sessionToken == "" { h.renderAgentError(w, "session query parameter required", http.StatusBadRequest) return } session, err := h.store.GetAgentSessionBySessionToken(sessionToken) if err != nil || session == nil { h.renderAgentError(w, "Invalid session token", http.StatusUnauthorized) return } if session.SessionExpiresAt != nil && time.Now().After(*session.SessionExpiresAt) { h.renderAgentError(w, "Session expired", http.StatusUnauthorized) return } _ = h.store.UpdateAgentLastSeen(session.AgentID) now := config.Now() today := config.Today() startDate := today.Add(-7 * 24 * time.Hour) endDate := today.Add(14 * 24 * time.Hour) ctx := r.Context() timeline := h.buildAgentContext(ctx, startDate, endDate) completedLog := h.buildCompletedLog(50) h.renderAgentContextFull(w, session, timeline, completedLog, today, startDate, endDate, now) } // renderAgentError renders an error page for agent web endpoints func (h *Handler) renderAgentError(w http.ResponseWriter, message string, status int) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if h.renderer != nil { _ = h.renderer.Render(w, "agent-error.html", map[string]interface{}{ "Error": message, "Status": status, }) } else { // Fallback if renderer not configured w.Write([]byte(`
` + message + `
`)) } } // renderAgentRequest renders the request page with token info func (h *Handler) renderAgentRequest(w http.ResponseWriter, session *models.AgentSession) { h.renderAgentTemplate(w, "agent-request.html", map[string]interface{}{ "RequestToken": session.RequestToken, "AgentName": session.AgentName, "AgentID": session.AgentID, "Status": "pending", "PollURL": "/agent/web/status?token=" + session.RequestToken, "ExpiresAt": session.ExpiresAt.Format(time.RFC3339), }) } // renderAgentStatus renders the status page func (h *Handler) renderAgentStatus(w http.ResponseWriter, session *models.AgentSession) { status := session.Status if isSessionExpired(session) { status = "expired" } data := map[string]interface{}{ "RequestToken": session.RequestToken, "AgentName": session.AgentName, "Status": status, } if status == "approved" && session.SessionToken != "" { data["SessionToken"] = session.SessionToken data["ContextURL"] = "/agent/web/context?session=" + session.SessionToken if session.SessionExpiresAt != nil { data["SessionExpiresAt"] = session.SessionExpiresAt.Format(time.RFC3339) } } h.renderAgentTemplate(w, "agent-status.html", data) } // renderAgentContextFull renders the context page with full timeline data and completed log func (h *Handler) renderAgentContextFull(w http.ResponseWriter, session *models.AgentSession, timeline []agentContextItem, completedLog []agentCompletedItem, today, startDate, endDate, now time.Time) { // Separate today's items for calendar view var todayItems []agentContextItem var otherItems []agentContextItem endOfToday := today.Add(24 * time.Hour) for _, item := range timeline { if item.Due != nil && !item.Due.Before(today) && item.Due.Before(endOfToday) { todayItems = append(todayItems, item) } else { otherItems = append(otherItems, item) } } h.renderAgentTemplate(w, "agent-context.html", map[string]interface{}{ "AgentName": session.AgentName, "GeneratedAt": now.Format(time.RFC3339), "Today": today.Format("2006-01-02"), "RangeStart": startDate.Format("2006-01-02"), "RangeEnd": endDate.Format("2006-01-02"), "TodayItems": todayItems, "Timeline": otherItems, "CompletedLog": completedLog, "Summary": h.buildContextSummary(timeline, today), }) }