package main import ( "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" logger "git.site.quack-lab.dev/dave/cylogger" ) type APIStatisticsRequest struct { Ship *int64 `json:"ship,omitempty"` Systems []int64 `json:"systems,omitempty"` Modules []int64 `json:"modules,omitempty"` Groups []int64 `json:"groups,omitempty"` KillmailLimit *int `json:"killmailLimit,omitempty"` } type APIAnalyticsRequest struct { Filters AnalyticsFilters `json:"filters"` Limit *int `json:"limit,omitempty"` } type APIModuleCoOccurrenceRequest struct { Filters AnalyticsFilters `json:"filters"` SelectedModule string `json:"selectedModule"` SelectedSlot string `json:"selectedSlot"` Limit *int `json:"limit,omitempty"` } type APISearchResult struct { ID int64 `json:"id"` Name string `json:"name"` Type string `json:"type"` } type APIItemNames map[string]string type APIGroupInfo struct { GroupID int64 `json:"groupId"` } type APIImageBatchRequest struct { Images []APIImageRequest `json:"images"` } type APIImageRequest struct { TypeID int64 `json:"typeId"` Size int `json:"size"` } type APIImageBatchResponse struct { Images map[string]APIImageData `json:"images"` } type APIImageData struct { Data string `json:"data"` // base64 encoded ContentType string `json:"contentType"` NotFound bool `json:"notFound,omitempty"` } func handleStatistics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req APIStatisticsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } params := QueryParams{} if req.Ship != nil { params.Ship = *req.Ship } params.Systems = req.Systems params.Modules = req.Modules params.Groups = req.Groups if req.KillmailLimit != nil && *req.KillmailLimit > 0 { params.KillmailLimit = *req.KillmailLimit } else { params.KillmailLimit = 20 } db, err := GetDB() if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } stats, err := db.QueryFits(params) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(stats) } // Analytics handlers func handleAnalyticsTimeByHour(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryTimeByHour(r.Context(), req.Filters) }) } func handleAnalyticsTimeByDay(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryTimeByDay(r.Context(), req.Filters) }) } func handleAnalyticsTimeByDate(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryTimeByDate(r.Context(), req.Filters) }) } func handleAnalyticsTimeByMonth(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryTimeByMonth(r.Context(), req.Filters) }) } func handleAnalyticsLocationBySystem(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryLocationBySystem(r.Context(), req.Filters, limit) }) } func handleAnalyticsLocationByRegion(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryLocationByRegion(r.Context(), req.Filters) }) } func handleAnalyticsLocationByConstellation(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryLocationByConstellation(r.Context(), req.Filters) }) } func handleAnalyticsLocationBySecurity(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryLocationBySecurity(r.Context(), req.Filters) }) } func handleAnalyticsShipByVictim(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryShipByVictim(r.Context(), req.Filters, limit) }) } func handleAnalyticsShipByAttacker(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryShipByAttacker(r.Context(), req.Filters, limit) }) } func handleAnalyticsPlayerByVictimCharacter(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryPlayerByVictimCharacter(r.Context(), req.Filters, limit) }) } func handleAnalyticsPlayerByVictimCorporation(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryPlayerByVictimCorporation(r.Context(), req.Filters, limit) }) } func handleAnalyticsPlayerByVictimAlliance(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryPlayerByVictimAlliance(r.Context(), req.Filters, limit) }) } func handleAnalyticsPlayerByAttackerCharacter(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryPlayerByAttackerCharacter(r.Context(), req.Filters, limit) }) } func handleAnalyticsPlayerByAttackerCorporation(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryPlayerByAttackerCorporation(r.Context(), req.Filters, limit) }) } func handleAnalyticsPlayerByAttackerAlliance(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryPlayerByAttackerAlliance(r.Context(), req.Filters, limit) }) } func handleAnalyticsModuleBySlotType(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { return db.QueryModuleBySlotType(r.Context(), req.Filters) }) } func handleAnalyticsModuleByModule(w http.ResponseWriter, r *http.Request) { handleAnalyticsQuery(w, r, func(db DB, req APIAnalyticsRequest) (interface{}, error) { limit := 100 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } return db.QueryModuleByModule(r.Context(), req.Filters, limit) }) } func handleAnalyticsModuleCoOccurrence(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleAnalyticsModuleCoOccurrence") if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req APIModuleCoOccurrenceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { flog.Error("Failed to decode request: %v", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } db, err := GetDB() if err != nil { flog.Error("Failed to get DB: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } limit := 50 if req.Limit != nil && *req.Limit > 0 { limit = *req.Limit } results, err := db.QueryModuleCoOccurrence(r.Context(), req.Filters, req.SelectedModule, req.SelectedSlot, limit) if err != nil { flog.Error("Query failed: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(results); err != nil { flog.Error("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } type analyticsQueryFunc func(db DB, req APIAnalyticsRequest) (interface{}, error) func handleAnalyticsQuery(w http.ResponseWriter, r *http.Request, queryFn analyticsQueryFunc) { flog := logger.Default.WithPrefix("handleAnalyticsQuery") if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req APIAnalyticsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { flog.Error("Failed to decode request: %v", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } db, err := GetDB() if err != nil { flog.Error("Failed to get DB: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } results, err := queryFn(db, req) if err != nil { flog.Error("Query failed: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(results); err != nil { flog.Error("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } func handleSearch(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleSearch") flog.Trace("Request received: %s %s", r.Method, r.URL.Path) if r.Method != http.MethodGet { flog.Debug("Method not allowed: %s", r.Method) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } query := r.URL.Query().Get("q") flog.Debug("Search query: %q", query) if query == "" { flog.Info("Empty query, returning empty results") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]APISearchResult{}) return } db, err := GetDB() if err != nil { flog.Error("Failed to get database: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } flog.Trace("Database connection obtained") results := []APISearchResult{} flog.Debug("Searching ships") ships, err := db.SearchShips(query, 10) if err != nil { flog.Error("Failed to search ships: %v", err) } else { flog.Info("Found %d ships", len(ships)) for _, ship := range ships { results = append(results, APISearchResult{ ID: int64(ship.TypeID), Name: ship.TypeName, Type: "ship", }) } } flog.Debug("Searching systems") systems, err := db.SearchSystems(query, 10) if err != nil { flog.Error("Failed to search systems: %v", err) } else { flog.Info("Found %d systems", len(systems)) for _, system := range systems { results = append(results, APISearchResult{ ID: int64(system.SolarSystemID), Name: system.SolarSystemName, Type: "system", }) } } flog.Debug("Searching modules") modules, err := db.SearchModules(query, 10) if err != nil { flog.Error("Failed to search modules: %v", err) } else { flog.Info("Found %d modules", len(modules)) for _, module := range modules { results = append(results, APISearchResult{ ID: int64(module.TypeID), Name: module.TypeName, Type: "module", }) } } flog.Debug("Searching groups") groups, err := db.SearchGroups(query, 10) if err != nil { flog.Error("Failed to search groups: %v", err) } else { flog.Info("Found %d groups", len(groups)) for _, group := range groups { results = append(results, APISearchResult{ ID: int64(group.GroupID), Name: group.GroupName, Type: "group", }) } } flog.Info("Total search results: %d", len(results)) flog.Dump("Results", results) w.Header().Set("Content-Type", "application/json") flog.Trace("Encoding response") if err := json.NewEncoder(w).Encode(results); err != nil { flog.Error("Failed to encode response: %v", err) return } flog.Info("Response sent successfully") } func handleItemNames(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleItemNames") flog.Trace("Request received: %s %s", r.Method, r.URL.Path) if r.Method != http.MethodGet { flog.Debug("Method not allowed: %s", r.Method) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } idsParam := r.URL.Query().Get("ids") flog.Debug("IDs parameter: %q", idsParam) if idsParam == "" { flog.Info("Empty IDs parameter, returning empty map") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(APIItemNames{}) return } flog.Debug("Parsing IDs") idStrs := strings.Split(idsParam, ",") flog.Trace("ID strings: %v", idStrs) ids := make([]int32, 0, len(idStrs)) for _, idStr := range idStrs { id, err := strconv.ParseInt(idStr, 10, 32) if err != nil { flog.Debug("Failed to parse ID %q: %v", idStr, err) continue } ids = append(ids, int32(id)) } flog.Info("Parsed %d valid IDs from %d strings", len(ids), len(idStrs)) if len(ids) == 0 { flog.Info("No valid IDs, returning empty map") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(APIItemNames{}) return } db, err := GetDB() if err != nil { flog.Error("Failed to get database: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } flog.Trace("Database connection obtained") flog.Debug("Getting item types for %d IDs", len(ids)) itemIDs := make([]int64, len(ids)) for i, id := range ids { itemIDs[i] = int64(id) } items, err := db.GetItemTypes(itemIDs) if err != nil { flog.Error("Failed to get item types: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } names := make(APIItemNames) for _, item := range items { names[strconv.FormatInt(int64(item.TypeID), 10)] = item.TypeName } flog.Info("Total names: %d", len(names)) flog.Dump("Names", names) w.Header().Set("Content-Type", "application/json") flog.Trace("Encoding response") if err := json.NewEncoder(w).Encode(names); err != nil { flog.Error("Failed to encode response: %v", err) return } flog.Info("Response sent successfully") } func handleItemGroup(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleItemGroup") flog.Trace("Request received: %s %s", r.Method, r.URL.Path) if r.Method != http.MethodGet { flog.Debug("Method not allowed: %s", r.Method) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } flog.Debug("Parsing item ID from path: %s", r.URL.Path) path := strings.TrimPrefix(r.URL.Path, "/api/items/") path = strings.TrimSuffix(path, "/group") flog.Trace("Trimmed path: %q", path) parts := strings.Split(path, "/") flog.Trace("Path parts: %v", parts) if len(parts) == 0 { flog.Error("Invalid path: no parts after splitting") http.Error(w, "Invalid item ID", http.StatusBadRequest) return } itemID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { flog.Error("Failed to parse item ID %q: %v", parts[0], err) http.Error(w, "Invalid item ID", http.StatusBadRequest) return } flog.Info("Item ID: %d", itemID) db, err := GetDB() if err != nil { flog.Error("Failed to get database: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } flog.Trace("Database connection obtained") flog.Debug("Getting item type for itemID: %d", itemID) items, err := db.GetItemTypes([]int64{itemID}) if err != nil { flog.Error("Failed to get item type: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if len(items) == 0 { flog.Info("Item not found: typeID %d", itemID) http.Error(w, "Item not found", http.StatusNotFound) return } item := items[0] flog.Info("Found groupID %d for itemID %d", item.GroupID, itemID) groupInfo := APIGroupInfo{ GroupID: int64(item.GroupID), } flog.Dump("Group Info", groupInfo) w.Header().Set("Content-Type", "application/json") flog.Trace("Encoding response") if err := json.NewEncoder(w).Encode(groupInfo); err != nil { flog.Error("Failed to encode response: %v", err) return } flog.Info("Response sent successfully") } func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next(w, r) } } func handleImageBatch(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleImageBatch") if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req APIImageBatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { flog.Error("Failed to decode request: %v", err) http.Error(w, "Invalid JSON", http.StatusBadRequest) return } response := APIImageBatchResponse{ Images: make(map[string]APIImageData), } client := &http.Client{Timeout: 10 * time.Second} for _, img := range req.Images { key := fmt.Sprintf("%d_%d", img.TypeID, img.Size) esiURL := fmt.Sprintf("https://images.evetech.net/types/%d/icon?size=%d", img.TypeID, img.Size) flog.Debug("Fetching image: %s", esiURL) resp, err := client.Get(esiURL) if err != nil { flog.Debug("Failed to fetch image for typeID %d size %d: %v", img.TypeID, img.Size, err) response.Images[key] = APIImageData{NotFound: true} continue } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { response.Images[key] = APIImageData{NotFound: true} continue } if resp.StatusCode != http.StatusOK { flog.Debug("Bad status for image typeID %d size %d: %d", img.TypeID, img.Size, resp.StatusCode) response.Images[key] = APIImageData{NotFound: true} continue } data, err := io.ReadAll(resp.Body) if err != nil { flog.Debug("Failed to read image data for typeID %d size %d: %v", img.TypeID, img.Size, err) response.Images[key] = APIImageData{NotFound: true} continue } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "image/png" } response.Images[key] = APIImageData{ Data: base64.StdEncoding.EncodeToString(data), ContentType: contentType, } } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(response); err != nil { flog.Error("Failed to encode response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } func StartAPIServer(port string) { flog := logger.Default.WithPrefix("StartAPIServer") flog.Info("Initializing API server") mux := http.NewServeMux() mux.HandleFunc("/api/statistics", corsMiddleware(handleStatistics)) mux.HandleFunc("/api/search", corsMiddleware(handleSearch)) mux.HandleFunc("/api/items/", corsMiddleware(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/group") { handleItemGroup(w, r) } else { handleItemNames(w, r) } })) mux.HandleFunc("/api/images/batch", corsMiddleware(handleImageBatch)) // Analytics endpoints mux.HandleFunc("/api/analytics/time/hour", corsMiddleware(handleAnalyticsTimeByHour)) mux.HandleFunc("/api/analytics/time/day", corsMiddleware(handleAnalyticsTimeByDay)) mux.HandleFunc("/api/analytics/time/date", corsMiddleware(handleAnalyticsTimeByDate)) mux.HandleFunc("/api/analytics/time/month", corsMiddleware(handleAnalyticsTimeByMonth)) mux.HandleFunc("/api/analytics/location/system", corsMiddleware(handleAnalyticsLocationBySystem)) mux.HandleFunc("/api/analytics/location/region", corsMiddleware(handleAnalyticsLocationByRegion)) mux.HandleFunc("/api/analytics/location/constellation", corsMiddleware(handleAnalyticsLocationByConstellation)) mux.HandleFunc("/api/analytics/location/security", corsMiddleware(handleAnalyticsLocationBySecurity)) mux.HandleFunc("/api/analytics/ship/victim", corsMiddleware(handleAnalyticsShipByVictim)) mux.HandleFunc("/api/analytics/ship/attacker", corsMiddleware(handleAnalyticsShipByAttacker)) mux.HandleFunc("/api/analytics/player/victim/character", corsMiddleware(handleAnalyticsPlayerByVictimCharacter)) mux.HandleFunc("/api/analytics/player/victim/corporation", corsMiddleware(handleAnalyticsPlayerByVictimCorporation)) mux.HandleFunc("/api/analytics/player/victim/alliance", corsMiddleware(handleAnalyticsPlayerByVictimAlliance)) mux.HandleFunc("/api/analytics/player/attacker/character", corsMiddleware(handleAnalyticsPlayerByAttackerCharacter)) mux.HandleFunc("/api/analytics/player/attacker/corporation", corsMiddleware(handleAnalyticsPlayerByAttackerCorporation)) mux.HandleFunc("/api/analytics/player/attacker/alliance", corsMiddleware(handleAnalyticsPlayerByAttackerAlliance)) mux.HandleFunc("/api/analytics/module/slot-type", corsMiddleware(handleAnalyticsModuleBySlotType)) mux.HandleFunc("/api/analytics/module/module", corsMiddleware(handleAnalyticsModuleByModule)) mux.HandleFunc("/api/analytics/module/co-occurrence", corsMiddleware(handleAnalyticsModuleCoOccurrence)) flog.Debug("Registered routes:") flog.Debug(" POST /api/statistics") flog.Debug(" GET /api/search") flog.Debug(" GET /api/items/ (names)") flog.Debug(" GET /api/items/{id}/group") flog.Debug(" POST /api/images/batch") flog.Debug(" POST /api/analytics/time/hour") flog.Debug(" POST /api/analytics/time/day") flog.Debug(" POST /api/analytics/time/date") flog.Debug(" POST /api/analytics/time/month") flog.Debug(" POST /api/analytics/location/system") flog.Debug(" POST /api/analytics/location/region") flog.Debug(" POST /api/analytics/location/constellation") flog.Debug(" POST /api/analytics/location/security") flog.Debug(" POST /api/analytics/ship/victim") flog.Debug(" POST /api/analytics/ship/attacker") flog.Debug(" POST /api/analytics/player/victim/character") flog.Debug(" POST /api/analytics/player/victim/corporation") flog.Debug(" POST /api/analytics/player/victim/alliance") flog.Debug(" POST /api/analytics/player/attacker/character") flog.Debug(" POST /api/analytics/player/attacker/corporation") flog.Debug(" POST /api/analytics/player/attacker/alliance") flog.Debug(" POST /api/analytics/module/slot-type") flog.Debug(" POST /api/analytics/module/module") flog.Debug(" POST /api/analytics/module/co-occurrence") flog.Info("Starting API server on port %s", port) flog.Trace("Listening on :%s", port) if err := http.ListenAndServe(":"+port, mux); err != nil { flog.Error("Failed to start API server: %v", err) } }