package handlers import ( "encoding/json" "errors" "log" "net/http" "strconv" "strings" "time" "unicode" "blackbox/shared/types" "github.com/go-chi/chi/v5" "gorm.io/gorm" ) type entryCursor struct { Timestamp time.Time ID string } func ListEntries(database *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cursor := r.URL.Query().Get("cursor") limitStr := r.URL.Query().Get("limit") node := r.URL.Query().Get("node") source := r.URL.Query().Get("source") service := r.URL.Query().Get("service") q := r.URL.Query().Get("q") timeStartStr := r.URL.Query().Get("time_start") timeEndStr := r.URL.Query().Get("time_end") limit := 50 if limitStr != "" { if n, err := strconv.Atoi(limitStr); err == nil && n > 0 && n <= 200 { limit = n } } usingFTS := false tx := database.Model(&types.Entry{}) if q != "" { if ftsQuery := buildEntryFTSQuery(q); ftsQuery != "" { usingFTS = true tx = database.Table("entries").Select("entries.*"). Joins("JOIN entries_fts ON entries.rowid = entries_fts.rowid"). Where("entries_fts MATCH ?", ftsQuery) } } timestampColumn := "timestamp" idColumn := "id" if usingFTS { timestampColumn = "entries.timestamp" idColumn = "entries.id" } tx = tx.Order(timestampColumn + " DESC").Order(idColumn + " DESC").Limit(limit + 1) if cursor != "" { parsedCursor, err := parseEntryCursor(cursor) if err != nil { writeError(w, http.StatusBadRequest, "invalid cursor") return } tx = tx.Where( timestampColumn+" < ? OR ("+timestampColumn+" = ? AND "+idColumn+" < ?)", parsedCursor.Timestamp, parsedCursor.Timestamp, parsedCursor.ID, ) } if timeStartStr != "" { parsedTimeStart, err := time.Parse(time.RFC3339Nano, timeStartStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid time_start") return } tx = tx.Where(timestampColumn+" >= ?", parsedTimeStart) } if timeEndStr != "" { parsedTimeEnd, err := time.Parse(time.RFC3339Nano, timeEndStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid time_end") return } tx = tx.Where(timestampColumn+" <= ?", parsedTimeEnd) } if node != "" { tx = tx.Where(columnName("node_name", usingFTS)+" = ?", node) } if source != "" { tx = tx.Where(columnName("source", usingFTS)+" = ?", source) } if service != "" { tx = tx.Where(columnName("service", usingFTS)+" = ?", service) } if q != "" && !usingFTS { like := "%" + escapeLike(q) + "%" tx = tx.Where(columnName("content", usingFTS)+" LIKE ? ESCAPE '\\' OR "+columnName("service", usingFTS)+" LIKE ? ESCAPE '\\'", like, like) } hideHeartbeat := r.URL.Query().Get("hide_heartbeat") == "true" if hideHeartbeat { tx = tx.Where("NOT (" + columnName("source", usingFTS) + " = 'agent' AND " + columnName("event", usingFTS) + " = 'heartbeat')") } var entries []types.Entry if err := tx.Find(&entries).Error; err != nil { if usingFTS { log.Printf("entries: FTS query failed, falling back to LIKE: %v", err) entries, err = listEntriesFallback(database, cursor, limit, node, source, service, q, timeStartStr, timeEndStr, hideHeartbeat) if err != nil { writeError(w, http.StatusInternalServerError, "failed to fetch entries") return } } else { writeError(w, http.StatusInternalServerError, "failed to fetch entries") return } } type response struct { Entries []types.Entry `json:"entries"` NextCursor string `json:"next_cursor,omitempty"` } resp := response{Entries: entries} if len(entries) > limit { resp.Entries = entries[:limit] resp.NextCursor = encodeEntryCursor(resp.Entries[len(resp.Entries)-1]) } if resp.Entries == nil { resp.Entries = []types.Entry{} } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { log.Printf("ListEntries encode: %v", err) } } } func columnName(name string, qualified bool) string { if qualified { return "entries." + name } return name } func escapeLike(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "%", "\\%") s = strings.ReplaceAll(s, "_", "\\_") return s } func buildEntryFTSQuery(q string) string { terms := strings.FieldsFunc(q, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) cleaned := make([]string, 0, len(terms)) for _, term := range terms { term = strings.TrimSpace(term) if term == "" { continue } cleaned = append(cleaned, term+"*") } return strings.Join(cleaned, " AND ") } func listEntriesFallback(database *gorm.DB, cursor string, limit int, node, source, service, q, timeStartStr, timeEndStr string, hideHeartbeat bool) ([]types.Entry, error) { tx := database.Model(&types.Entry{}).Order("timestamp DESC").Order("id DESC").Limit(limit + 1) if cursor != "" { parsedCursor, err := parseEntryCursor(cursor) if err != nil { return nil, err } tx = tx.Where("timestamp < ? OR (timestamp = ? AND id < ?)", parsedCursor.Timestamp, parsedCursor.Timestamp, parsedCursor.ID) } if timeStartStr != "" { parsed, err := time.Parse(time.RFC3339Nano, timeStartStr) if err != nil { return nil, err } tx = tx.Where("timestamp >= ?", parsed) } if timeEndStr != "" { parsed, err := time.Parse(time.RFC3339Nano, timeEndStr) if err != nil { return nil, err } tx = tx.Where("timestamp <= ?", parsed) } if node != "" { tx = tx.Where("node_name = ?", node) } if source != "" { tx = tx.Where("source = ?", source) } if service != "" { tx = tx.Where("service = ?", service) } if q != "" { like := "%" + escapeLike(q) + "%" tx = tx.Where("content LIKE ? ESCAPE '\\' OR service LIKE ? ESCAPE '\\'", like, like) } if hideHeartbeat { tx = tx.Where("NOT (source = 'agent' AND event = 'heartbeat')") } var entries []types.Entry return entries, tx.Find(&entries).Error } func ListEntryServices(database *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var services []string if err := database.Model(&types.Entry{}). Distinct(). Where("service != ''"). Order("service ASC"). Pluck("service", &services).Error; err != nil { writeError(w, http.StatusInternalServerError, "failed to list services") return } if services == nil { services = []string{} } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string][]string{"services": services}); err != nil { log.Printf("ListEntryServices encode: %v", err) } } } func encodeEntryCursor(entry types.Entry) string { return entry.Timestamp.UTC().Format(time.RFC3339Nano) + "|" + entry.ID } func parseEntryCursor(cursor string) (entryCursor, error) { ts, id, ok := strings.Cut(cursor, "|") if !ok || id == "" { return entryCursor{}, errors.New("cursor missing delimiter") } parsedTime, err := time.Parse(time.RFC3339Nano, ts) if err != nil { return entryCursor{}, err } return entryCursor{ Timestamp: parsedTime.UTC(), ID: id, }, nil } func GetEntry(database *gorm.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var entry types.Entry if err := database.First(&entry, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(w, http.StatusNotFound, "entry not found") return } writeError(w, http.StatusInternalServerError, "failed to fetch entry") return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(entry); err != nil { log.Printf("GetEntry encode: %v", err) } } }