package main import ( "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "sync" "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"` } type APIItemCount struct { ItemId int64 `json:"itemId"` Count int64 `json:"count"` } type APIFitStatistics struct { TotalKillmails int64 `json:"totalKillmails"` Ships []APIItemCount `json:"ships"` SystemBreakdown []APIItemCount `json:"systemBreakdown"` HighSlotModules []APIItemCount `json:"highSlotModules"` MidSlotModules []APIItemCount `json:"midSlotModules"` LowSlotModules []APIItemCount `json:"lowSlotModules"` Rigs []APIItemCount `json:"rigs"` Drones []APIItemCount `json:"drones"` } 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"` } const cacheTTL = 24 * time.Hour func convertFitStatistics(stats *FitStatistics) *APIFitStatistics { api := &APIFitStatistics{ TotalKillmails: stats.TotalKillmails, Ships: convertMapToItemCounts(stats.ShipBreakdown), SystemBreakdown: convertMapToItemCounts(stats.SystemBreakdown), HighSlotModules: convertModuleMapToItemCounts(stats.HighSlotModules), MidSlotModules: convertModuleMapToItemCounts(stats.MidSlotModules), LowSlotModules: convertModuleMapToItemCounts(stats.LowSlotModules), Rigs: convertModuleMapToItemCounts(stats.Rigs), Drones: convertModuleMapToItemCounts(stats.Drones), } return api } func convertMapToItemCounts(m map[int64]SystemStats) []APIItemCount { result := make([]APIItemCount, 0, len(m)) for id, stats := range m { result = append(result, APIItemCount{ ItemId: id, Count: stats.Count, }) } return result } func convertModuleMapToItemCounts(m map[int32]ModuleStats) []APIItemCount { result := make([]APIItemCount, 0, len(m)) for id, stats := range m { result = append(result, APIItemCount{ ItemId: int64(id), Count: stats.Count, }) } return result } func handleStatistics(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleStatistics") flog.Trace("Request received: %s %s", r.Method, r.URL.Path) if r.Method != http.MethodPost { flog.Debug("Method not allowed: %s", r.Method) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } flog.Debug("Decoding request body") var req APIStatisticsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { flog.Error("Failed to decode request body: %v", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } flog.Dump("Request", req) params := QueryParams{} if req.Ship != nil { params.Ship = *req.Ship flog.Debug("Ship filter: %d", params.Ship) } params.Systems = req.Systems if len(params.Systems) > 0 { flog.Debug("Systems filter: %d systems", len(params.Systems)) } params.Modules = req.Modules if len(params.Modules) > 0 { flog.Debug("Modules filter: %d modules", len(params.Modules)) } params.Groups = req.Groups if len(params.Groups) > 0 { flog.Debug("Groups filter: %d groups", len(params.Groups)) } 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") cacheKeyBytes, _ := json.Marshal(req) cacheKey := "stats:" + string(cacheKeyBytes) flog.Debug("Checking cache for key: %s", cacheKey) cachedData, found := db.GetCacheEntry(cacheKey, 3*24*time.Hour) if found { flog.Info("Serving from cache") w.Header().Set("Content-Type", "application/json") w.Write(cachedData) return } flog.Info("Cache miss, querying database") flog.Debug("Executing QueryFits with params: %+v", params) stats, err := db.QueryFits(params) if err != nil { flog.Error("Failed to query fits: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } flog.Info("Query completed: %d total killmails", stats.TotalKillmails) flog.Dump("Statistics", stats) flog.Debug("Converting statistics to API format") apiStats := convertFitStatistics(stats) flog.Dump("API Statistics", apiStats) responseData, err := json.Marshal(apiStats) if err != nil { flog.Error("Failed to marshal response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } flog.Debug("Storing in cache") if err := db.CacheEntry(cacheKey, responseData); err != nil { flog.Error("Failed to cache statistics: %v", err) } w.Header().Set("Content-Type", "application/json") flog.Trace("Encoding response") w.Write(responseData) flog.Info("Response sent successfully") } 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 names for %d IDs", len(ids)) names, err := db.GetItemNames(ids) if err != nil { flog.Error("Failed to get item names: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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 group for itemID: %d", itemID) groupID, err := db.GetItemGroup(itemID) if err != nil { if strings.Contains(err.Error(), "record not found") { flog.Info("Item not found: typeID %d", itemID) http.Error(w, "Item not found", http.StatusNotFound) return } flog.Error("Failed to get item group: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } flog.Info("Found groupID %d for itemID %d", groupID, itemID) groupInfo := APIGroupInfo{ GroupID: 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 handleImage(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleImage") 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 } path := strings.TrimPrefix(r.URL.Path, "/api/images/") flog.Trace("Trimmed path: %q", path) parts := strings.Split(strings.Trim(path, "/"), "/") flog.Trace("Path parts: %v", parts) if len(parts) < 1 || parts[0] == "" { flog.Error("Invalid image path: %s (original: %s)", path, r.URL.Path) http.Error(w, "Invalid image path", http.StatusBadRequest) return } typeIDStr := parts[0] sizeStr := "32" if len(parts) > 1 && parts[1] != "" { sizeStr = strings.TrimSuffix(parts[1], ".png") } flog.Debug("Parsed: typeID=%q, size=%q", typeIDStr, sizeStr) typeID, err := strconv.ParseInt(typeIDStr, 10, 64) if err != nil { flog.Error("Invalid typeID %q: %v", typeIDStr, err) http.Error(w, "Invalid type ID", http.StatusBadRequest) return } size, err := strconv.Atoi(sizeStr) if err != nil { flog.Error("Invalid size %q: %v", sizeStr, err) http.Error(w, "Invalid size", http.StatusBadRequest) return } cacheKey := fmt.Sprintf("image:%d_%d", typeID, size) flog.Debug("Image request: typeID=%d, size=%d, cacheKey=%s", typeID, size, cacheKey) // Check database cache cachedData, found := db.GetCacheEntry(cacheKey, cacheTTL) if found { flog.Trace("Serving from cache") // If found but data is nil, it means 404 was cached if cachedData == nil { flog.Trace("Cached 404, returning 404") http.Error(w, "Image not found", http.StatusNotFound) return } // Determine content type from data contentType := "image/png" if len(cachedData) > 0 { // Try to detect from magic bytes if len(cachedData) >= 2 && cachedData[0] == 0xFF && cachedData[1] == 0xD8 { contentType = "image/jpeg" } else if len(cachedData) >= 8 && string(cachedData[0:8]) == "\x89PNG\r\n\x1a\n" { contentType = "image/png" } } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(cachedData) return } flog.Debug("Fetching from ESI") esiURL := fmt.Sprintf("https://images.evetech.net/types/%d/icon?size=%d", typeID, size) flog.Trace("ESI URL: %s", esiURL) resp, err := http.Get(esiURL) if err != nil { flog.Error("Failed to fetch from ESI: %v", err) http.Error(w, "Failed to fetch image", http.StatusInternalServerError) return } defer resp.Body.Close() flog.Debug("ESI response status: %d for typeID=%d, size=%d", resp.StatusCode, typeID, size) if resp.StatusCode == http.StatusNotFound { flog.Debug("Image not found on ESI: typeID=%d, size=%d", typeID, size) // Cache 404 as nil blob in database if err := db.CacheEntry(cacheKey, nil); err != nil { flog.Error("Failed to cache 404: %v", err) } http.Error(w, "Image not found", http.StatusNotFound) return } if resp.StatusCode != http.StatusOK { flog.Error("ESI returned status %d for typeID=%d, size=%d, URL=%s", resp.StatusCode, typeID, size, esiURL) http.Error(w, "Failed to fetch image", http.StatusInternalServerError) return } data, err := io.ReadAll(resp.Body) if err != nil { flog.Error("Failed to read image data: %v", err) http.Error(w, "Failed to read image", http.StatusInternalServerError) return } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "image/png" } // Store as blob in database cache if err := db.CacheEntry(cacheKey, data); err != nil { flog.Error("Failed to cache image: %v", err) } flog.Info("Cached image: typeID=%d, size=%d, size=%d bytes", typeID, size, len(data)) w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(data) } func handleImageBatch(w http.ResponseWriter, r *http.Request) { flog := logger.Default.WithPrefix("handleImageBatch") flog.Trace("Request received: %s %s", r.Method, r.URL.Path) if r.Method != http.MethodPost { flog.Debug("Method not allowed: %s", r.Method) 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 body: %v", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } if len(req.Images) == 0 { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(APIImageBatchResponse{Images: make(map[string]APIImageData)}) return } type imageResult struct { key string data APIImageData } results := make(map[string]APIImageData) resultChan := make(chan imageResult, len(req.Images)) var wg sync.WaitGroup for _, imgReq := range req.Images { wg.Add(1) go func(img APIImageRequest) { defer wg.Done() cacheKey := fmt.Sprintf("image:%d_%d", img.TypeID, img.Size) // Check database cache cachedData, found := db.GetCacheEntry(cacheKey, cacheTTL) if found { if cachedData == nil { resultChan <- imageResult{ key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response data: APIImageData{NotFound: true}, } return } // Determine content type from data contentType := "image/png" if len(cachedData) > 0 { if len(cachedData) >= 2 && cachedData[0] == 0xFF && cachedData[1] == 0xD8 { contentType = "image/jpeg" } else if len(cachedData) >= 8 && string(cachedData[0:8]) == "\x89PNG\r\n\x1a\n" { contentType = "image/png" } } resultChan <- imageResult{ key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response data: APIImageData{ Data: base64.StdEncoding.EncodeToString(cachedData), ContentType: contentType, }, } return } esiURL := fmt.Sprintf("https://images.evetech.net/types/%d/icon?size=%d", img.TypeID, img.Size) resp, err := http.Get(esiURL) if err != nil { flog.Error("Failed to fetch from ESI: %v", err) return } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { // Cache 404 as nil blob in database if err := db.CacheEntry(cacheKey, nil); err != nil { flog.Error("Failed to cache 404: %v", err) } resultChan <- imageResult{ key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response data: APIImageData{NotFound: true}, } return } if resp.StatusCode != http.StatusOK { flog.Error("ESI returned status %d for typeID=%d, size=%d", resp.StatusCode, img.TypeID, img.Size) return } data, err := io.ReadAll(resp.Body) if err != nil { flog.Error("Failed to read image data: %v", err) return } contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "image/png" } // Store as blob in database cache if err := db.CacheEntry(cacheKey, data); err != nil { flog.Error("Failed to cache image: %v", err) } encodedData := base64.StdEncoding.EncodeToString(data) resultChan <- imageResult{ key: fmt.Sprintf("%d_%d", img.TypeID, img.Size), // Return without prefix for response data: APIImageData{ Data: encodedData, ContentType: contentType, }, } }(imgReq) } go func() { wg.Wait() close(resultChan) }() for result := range resultChan { results[result.key] = result.data } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(APIImageBatchResponse{Images: results}) } 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/names", corsMiddleware(handleItemNames)) mux.HandleFunc("/api/items/", corsMiddleware(handleItemGroup)) mux.HandleFunc("/api/images/batch", corsMiddleware(handleImageBatch)) mux.HandleFunc("/api/images/", corsMiddleware(handleImage)) 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(" GET /api/images/{typeId}/{size}") 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) } }