// SPDX-License-Identifier: Apache-2.0 package server import ( "net/http" "strconv" "strings" "time" "github.com/X4Applegate/caddyui/internal/models" ) // The request explorer (issue #145). // // The reporter's complaint was not that the data was missing but that getting // to it took "a lot of back and forth": status mix on one page, visitors on // another, user agents only under an individual visitor. Each drill-down // answered one question and then dead-ended. // // This is one page that takes every dimension at once — host, status class, // user agent, path, client IP and an explicit time window — and lists the // matching requests. The existing per-host drill-downs still exist and now // link here, so "4xx for this host, last hour, from this bot" is one view // rather than four. // requestFilterForm is the parsed filter, kept as strings so the template can // render the form back without reformatting. type requestFilterForm struct { Host string Status string UA string Path string ClientIP string Window string From string // RFC3339-ish, from chart drill-through To string } // any reports whether the operator has narrowed anything at all. An unfiltered // explorer is just a firehose, so the page says so rather than rendering the // most recent 200 requests as if they were a result. func (f requestFilterForm) any() bool { return f.Host != "" || f.Status != "" || f.UA != "" || f.Path != "" || f.ClientIP != "" || f.From != "" } // parseRequestFilter reads the query string into a filter form. func parseRequestFilter(r *http.Request) requestFilterForm { q := r.URL.Query() get := func(k string) string { return strings.TrimSpace(q.Get(k)) } f := requestFilterForm{ Host: strings.ToLower(get("host")), Status: strings.ToLower(get("status")), UA: get("ua"), Path: get("path"), ClientIP: get("ip"), Window: strings.ToLower(get("window")), From: get("from"), To: get("to"), } switch f.Status { case "2xx", "3xx", "4xx", "5xx", "other": default: f.Status = "" } return f } // windowDuration maps the window token to a duration, defaulting to 7 days. func windowDuration(token string) time.Duration { switch token { case "1h": return time.Hour case "6h": return 6 * time.Hour case "1d", "24h", "day": return 24 * time.Hour case "30d": return 30 * 24 * time.Hour default: return 7 * 24 * time.Hour } } // parseUnixParam reads a Unix-seconds query parameter. The chart links pass // bucket boundaries this way, which keeps the drill-through exact rather than // re-deriving a window from a label. func parseUnixParam(v string) (time.Time, bool) { if v == "" { return time.Time{}, false } sec, err := strconv.ParseInt(v, 10, 64) if err != nil || sec <= 0 { return time.Time{}, false } return time.Unix(sec, 0).UTC(), true } // getAnalyticsRequests renders the request explorer. func (s *Server) getAnalyticsRequests(w http.ResponseWriter, r *http.Request) { u := s.currentUser(r) if u == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } isAdmin := u.Role == models.RoleAdmin serverScopeID := s.analyticsServerScope(r) f := parseRequestFilter(r) // Non-admins may only see hosts they own. The filter is intersected with // that set rather than trusted, so a hand-edited ?host= cannot reach // another tenant's traffic. var allowedHosts []string if !isAdmin { var err error allowedHosts, err = s.scopedHostsForAnalytics(u, serverScopeID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if f.Host != "" { permitted := false for _, h := range allowedHosts { if h == f.Host { permitted = true break } } if !permitted { // 404 rather than 403, so the set of hosts stays unguessable. http.NotFound(w, r) return } } } now := time.Now() window := windowDuration(f.Window) since := now.Add(-window) var until time.Time // An explicit from/to (a chart drill-through) overrides the window. if from, ok := parseUnixParam(f.From); ok { since = from if to, ok2 := parseUnixParam(f.To); ok2 { until = to } } filter := models.InsightFilter{ From: since, To: until, Host: f.Host, Status: f.Status, Path: f.Path, ClientIP: f.ClientIP, UASearch: f.UA, ServerID: serverScopeID, // v2.68.3: a viewer's total, status mix and facets count only their // own hosts. Filtering the rows afterwards left the counts fleet-wide. Scoped: !isAdmin, OnlyHosts: allowedHosts, } var ( rows []models.RequestRow total int64 facets []models.HostFacet mix models.StatusBuckets errMsg string ) if f.any() { var err error if rows, err = models.SearchRequests(s.DB, filter, 200); err != nil { errMsg = "Could not read requests: " + err.Error() } total, _ = models.SearchRequestCount(s.DB, filter) facets, _ = models.SearchHostFacets(s.DB, filter, 12) mix, _ = models.SearchStatusMix(s.DB, filter) // Belt and braces: a viewer's SQL is already scoped to these hosts. if !isAdmin { rows = filterRowsToHosts(rows, allowedHosts) facets = filterFacetsToHosts(facets, allowedHosts) } } allServers, _ := models.ListCaddyServers(s.DB) s.render(w, r, "analytics_requests.html", map[string]any{ "User": u, "IsAdmin": isAdmin, "Filter": f, "HasFilter": f.any(), "Rows": rows, "RowCount": len(rows), "Total": total, "Truncated": total > int64(len(rows)), "Facets": facets, "StatusMix": mix, "Error": errMsg, "WindowLabel": windowLabelFor(f.Window), "ExplicitRange": f.From != "", "RangeFrom": since, "RangeTo": until, "AllServers": allServers, "SelectedServerID": serverScopeID, "Section": "analytics", }) } // windowLabelFor is the human label for a window token. func windowLabelFor(token string) string { switch token { case "1h": return "last hour" case "6h": return "last 6 hours" case "1d", "24h", "day": return "last 24 hours" case "30d": return "last 30 days" default: return "last 7 days" } } // filterRowsToHosts drops rows for hosts the caller may not see, matching // wildcard hosts the same way the SQL does. An empty allowed list keeps // nothing (v2.68.3: it used to keep everything). func filterRowsToHosts(rows []models.RequestRow, allowed []string) []models.RequestRow { out := rows[:0] for _, r := range rows { if eventHostAllowed(r.Host, allowed) { out = append(out, r) } } return out } func filterFacetsToHosts(facets []models.HostFacet, allowed []string) []models.HostFacet { out := facets[:0] for _, f := range facets { if eventHostAllowed(f.Host, allowed) { out = append(out, f) } } return out }