package web import ( "context" "crypto/subtle" "embed" "encoding/json" "errors" "fmt" "html/template" "io" "log/slog" "net/http" "os" "regexp" "strconv" "strings" "time" "github.com/example/glpi-ai-agent/internal/config" "github.com/example/glpi-ai-agent/internal/metrics" "github.com/example/glpi-ai-agent/internal/model" "github.com/example/glpi-ai-agent/internal/queue" "github.com/example/glpi-ai-agent/internal/state" ) //go:embed templates/dashboard.html var files embed.FS type KnowledgeManager interface { List() []model.KnowledgeDoc Upsert(context.Context, model.KnowledgeDoc) error Delete(string) error IsManaged(string) bool Origin(string) string } type FeedbackManager interface { Categories(context.Context) ([]model.Category, error) RecordCategoryFeedback(context.Context, string, int64) (model.LearningExample, error) LearningExamples() []model.LearningExample DeleteLearning(string) error LearningCount() int } type Server struct { cfg config.Config metrics *metrics.Metrics state *state.Store q *queue.Queue knowledge KnowledgeManager feedback FeedbackManager tpl *template.Template } func New(cfg config.Config, m *metrics.Metrics, s *state.Store, q *queue.Queue, k KnowledgeManager, f FeedbackManager) (*Server, error) { t, err := template.ParseFS(files, "templates/dashboard.html") if err != nil { return nil, err } return &Server{cfg: cfg, metrics: m, state: s, q: q, knowledge: k, feedback: f, tpl: t}, nil } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.health) mux.HandleFunc("GET /readyz", s.ready) mux.HandleFunc("GET /metrics", s.prom) mux.Handle("GET /", s.auth(http.HandlerFunc(s.dashboard))) mux.Handle("GET /api/status", s.auth(http.HandlerFunc(s.status))) mux.Handle("GET /api/runs", s.auth(http.HandlerFunc(s.runs))) mux.Handle("GET /api/categories", s.auth(http.HandlerFunc(s.categories))) mux.Handle("GET /api/knowledge", s.auth(http.HandlerFunc(s.knowledgeList))) mux.Handle("POST /api/knowledge", s.auth(s.mutation(http.HandlerFunc(s.knowledgeUpsert)))) mux.Handle("DELETE /api/knowledge/{id}", s.auth(s.mutation(http.HandlerFunc(s.knowledgeDelete)))) mux.Handle("GET /api/learning", s.auth(http.HandlerFunc(s.learningList))) mux.Handle("POST /api/learning", s.auth(s.mutation(http.HandlerFunc(s.learningAdd)))) mux.Handle("DELETE /api/learning/{id}", s.auth(s.mutation(http.HandlerFunc(s.learningDelete)))) mux.HandleFunc("POST /webhook/glpi", s.webhook) return securityHeaders(requestLog(mux)) } func (s *Server) health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"status":"ok"}`) } func (s *Server) ready(w http.ResponseWriter, r *http.Request) { g, o := s.metrics.Health() w.Header().Set("Content-Type", "application/json") if !g || !o { w.WriteHeader(http.StatusServiceUnavailable) } json.NewEncoder(w).Encode(map[string]any{"glpi": g, "ollama": o}) } func (s *Server) prom(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4") s.metrics.WritePrometheus(w) } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = s.tpl.ExecuteTemplate(w, "dashboard.html", map[string]any{"DryRun": s.cfg.DryRun, "AutoReply": s.cfg.AutoReply, "AutoCategory": s.cfg.AutoCategory, "CommunicationLanguage": s.cfg.CommunicationLanguage, "CommunicationStyle": s.cfg.CommunicationStyle}) } func (s *Server) status(w http.ResponseWriter, r *http.Request) { g, o := s.metrics.Health() kbOK, kbDocs, kbLastSync, kbLastErr := s.metrics.GLPIKBStatus() respondJSON(w, map[string]any{ "uptime_seconds": int(time.Since(s.metrics.Started).Seconds()), "dry_run": s.cfg.DryRun, "auto_reply": s.cfg.AutoReply, "auto_category": s.cfg.AutoCategory, "processed": s.metrics.Processed.Load(), "skipped": s.metrics.Skipped.Load(), "errors": s.metrics.Errors.Load(), "category_changes": s.metrics.CategoryChanged.Load(), "replies": s.metrics.Replies.Load(), "queue_depth": s.q.Len(), "glpi_ok": g, "ollama_ok": o, "knowledge_docs": s.metrics.KnowledgeDocs(), "last_poll": s.metrics.LastPoll(), "communication_language": s.cfg.CommunicationLanguage, "communication_style": s.cfg.CommunicationStyle, "knowledge_allowed_sources": s.cfg.KnowledgeAllowedSources, "knowledge_auto_reply_sources": s.cfg.KnowledgeAutoReplySources, "category_confidence": s.cfg.CategoryConfidence, "reply_confidence": s.cfg.ReplyConfidence, "knowledge_min_score": s.cfg.KnowledgeMinScore, "context_enabled": s.cfg.ContextEnabled, "context_fetches": s.metrics.ContextFetches.Load(), "context_errors": s.metrics.ContextErrors.Load(), "change_calendar_enabled": s.cfg.ChangeCalendarEnabled, "major_incidents_enabled": s.cfg.MajorIncidentsEnabled, "user_device_context_enabled": s.cfg.UserDeviceContextEnabled, "knowledge_edit_enabled": s.cfg.KnowledgeWebEditEnabled, "learning_enabled": s.cfg.LearningEnabled, "learning_examples": s.feedback.LearningCount(), "glpi_kb_enabled": s.cfg.GLPIKBEnabled, "glpi_kb_ok": kbOK, "glpi_kb_documents": kbDocs, "glpi_kb_last_sync": kbLastSync, "glpi_kb_last_error": kbLastErr, "glpi_kb_source": s.cfg.GLPIKBSource, "glpi_kb_sync_interval": s.cfg.GLPIKBSyncInterval.String(), "uptime_kuma_enabled": s.cfg.UptimeKumaEnabled, "uptime_kuma_mode": s.cfg.UptimeKumaMode, "uptime_kuma_status_pages": s.cfg.UptimeKumaStatusPages, "context_fail_closed": s.cfg.ContextBlockReplyOnError, "context_incident_block": s.cfg.ContextBlockReplyOnIncident, }) } func (s *Server) runs(w http.ResponseWriter, r *http.Request) { limit := 50 if v := r.URL.Query().Get("limit"); v != "" { if n, e := strconv.Atoi(v); e == nil && n > 0 && n <= 200 { limit = n } } respondJSON(w, s.state.Recent(limit)) } func (s *Server) categories(w http.ResponseWriter, r *http.Request) { cats, err := s.feedback.Categories(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } type categoryView struct { ID int64 `json:"id"` Name string `json:"name"` CompleteName string `json:"completename"` } out := make([]categoryView, 0, len(cats)) for _, c := range cats { out = append(out, categoryView{ID: c.ID, Name: c.Name, CompleteName: c.CompleteName}) } respondJSON(w, out) } func (s *Server) knowledgeList(w http.ResponseWriter, r *http.Request) { type view struct { model.KnowledgeDoc Managed bool `json:"managed"` Origin string `json:"origin"` } docs := s.knowledge.List() out := make([]view, 0, len(docs)) for _, d := range docs { out = append(out, view{KnowledgeDoc: d, Managed: s.knowledge.IsManaged(d.ID), Origin: s.knowledge.Origin(d.ID)}) } respondJSON(w, out) } func (s *Server) knowledgeUpsert(w http.ResponseWriter, r *http.Request) { if !s.cfg.KnowledgeWebEditEnabled { http.Error(w, "knowledge editing disabled", http.StatusForbidden) return } var d model.KnowledgeDoc dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&d); err != nil { http.Error(w, "invalid knowledge document: "+err.Error(), 400) return } if strings.TrimSpace(d.Source) == "" { d.Source = "internal-kb" } if strings.TrimSpace(d.Language) == "" { d.Language = s.cfg.CommunicationLanguage } if strings.TrimSpace(d.CommunicationStyle) == "" { d.CommunicationStyle = s.cfg.CommunicationStyle } if len(d.Categories) > 0 { cats, err := s.feedback.Categories(r.Context()) if err != nil { http.Error(w, "cannot validate categories: "+err.Error(), http.StatusBadGateway) return } valid := make(map[int64]struct{}, len(cats)) for _, c := range cats { valid[c.ID] = struct{}{} } for _, id := range d.Categories { if _, ok := valid[id]; !ok { http.Error(w, fmt.Sprintf("unknown GLPI category id %d", id), http.StatusUnprocessableEntity) return } } } if err := s.knowledge.Upsert(r.Context(), d); err != nil { http.Error(w, err.Error(), 422) return } s.metrics.SetKnowledgeDocs(len(s.knowledge.List())) respondJSON(w, d) } func (s *Server) knowledgeDelete(w http.ResponseWriter, r *http.Request) { if !s.cfg.KnowledgeWebEditEnabled { http.Error(w, "knowledge editing disabled", http.StatusForbidden) return } if err := s.knowledge.Delete(r.PathValue("id")); err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "not found", 404) } else { http.Error(w, err.Error(), 422) } return } s.metrics.SetKnowledgeDocs(len(s.knowledge.List())) w.WriteHeader(http.StatusNoContent) } func (s *Server) learningList(w http.ResponseWriter, r *http.Request) { respondJSON(w, s.feedback.LearningExamples()) } func (s *Server) learningAdd(w http.ResponseWriter, r *http.Request) { if !s.cfg.LearningEnabled { http.Error(w, "learning disabled", http.StatusForbidden) return } var in struct { RunID string `json:"run_id"` CategoryID int64 `json:"category_id"` } if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil { http.Error(w, "invalid feedback", 400) return } ex, err := s.feedback.RecordCategoryFeedback(r.Context(), in.RunID, in.CategoryID) if err != nil { http.Error(w, err.Error(), 422) return } respondJSON(w, ex) } func (s *Server) learningDelete(w http.ResponseWriter, r *http.Request) { if err := s.feedback.DeleteLearning(r.PathValue("id")); err != nil { if errors.Is(err, os.ErrNotExist) { http.Error(w, "not found", 404) } else { http.Error(w, err.Error(), 422) } return } w.WriteHeader(http.StatusNoContent) } func (s *Server) mutation(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Requested-With") != "GLPI-AI-Agent" { http.Error(w, "missing request guard", http.StatusForbidden) return } if ct := r.Header.Get("Content-Type"); r.Method != "DELETE" && !strings.HasPrefix(strings.ToLower(ct), "application/json") { http.Error(w, "content-type must be application/json", http.StatusUnsupportedMediaType) return } next.ServeHTTP(w, r) }) } var ticketRE = regexp.MustCompile(`(?i)/Ticket/(\d+)`) func (s *Server) webhook(w http.ResponseWriter, r *http.Request) { if s.cfg.WebhookSecret == "" { http.Error(w, "webhook disabled", http.StatusNotFound) return } got := r.Header.Get("X-Webhook-Secret") if subtle.ConstantTimeCompare([]byte(got), []byte(s.cfg.WebhookSecret)) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "bad request", 400) return } id := extractTicketID(body) if id <= 0 { http.Error(w, "no ticket id found", 422) return } s.metrics.WebhookEvents.Add(1) if !s.q.Enqueue(id) { w.WriteHeader(http.StatusAccepted) return } s.metrics.QueueDepth.Store(int64(s.q.Len())) w.WriteHeader(http.StatusAccepted) } func extractTicketID(body []byte) int64 { var v any if json.Unmarshal(body, &v) == nil { if id := walkID(v); id > 0 { return id } } if m := ticketRE.FindSubmatch(body); len(m) == 2 { id, _ := strconv.ParseInt(string(m[1]), 10, 64) return id } return 0 } func walkID(v any) int64 { m, ok := v.(map[string]any) if !ok { return 0 } for _, k := range []string{"ticket_id", "ticketId"} { if n := num(m[k]); n > 0 { return n } } if typ, ok := m["itemtype"].(string); ok && strings.EqualFold(typ, "Ticket") { if n := num(m["id"]); n > 0 { return n } if n := num(m["items_id"]); n > 0 { return n } } // A nested object explicitly named "ticket" may legitimately only carry an id. if child, ok := m["ticket"].(map[string]any); ok { if n := num(child["id"]); n > 0 { return n } if n := walkID(child); n > 0 { return n } } // Other generic wrapper objects are searched only for explicit ticket markers; // their own generic "id" must never be mistaken for a ticket id. for _, k := range []string{"item", "data", "object"} { if child, ok := m[k]; ok { if n := walkID(child); n > 0 { return n } } } return 0 } func num(v any) int64 { switch x := v.(type) { case float64: return int64(x) case string: n, _ := strconv.ParseInt(x, 10, 64) return n } return 0 } func respondJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } func (s *Server) auth(next http.Handler) http.Handler { if s.cfg.WebAllowAnonymous { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, p, ok := r.BasicAuth() if !ok || subtle.ConstantTimeCompare([]byte(u), []byte(s.cfg.WebUsername)) != 1 || subtle.ConstantTimeCompare([]byte(p), []byte(s.cfg.WebPassword)) != 1 { w.Header().Set("WWW-Authenticate", `Basic realm="GLPI AI Agent"`) http.Error(w, "unauthorized", 401) return } next.ServeHTTP(w, r) }) } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'") next.ServeHTTP(w, r) }) } func requestLog(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) if r.URL.Path != "/healthz" { slog.Debug("http request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start).String()) } }) } func Listen(addr string, h http.Handler) *http.Server { return &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} } func (s *Server) String() string { return fmt.Sprintf("web(%s)", s.cfg.HTTPAddr) }