package main import ( "time" ) 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 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"` KillmailIDs []int64 `json:"killmailIds,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"` } 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), // KillmailIDs: stats.KillmailIDs, // } // return api // } // func convertMapToItemCounts(m map[int64]Stats) []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]Stats) []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)) // } // // Killmail limit defaults to 20 when not provided or invalid // if req.KillmailLimit != nil && *req.KillmailLimit > 0 { // params.KillmailLimit = *req.KillmailLimit // } else { // params.KillmailLimit = 20 // } // flog.Debug("Killmail limit: %d", params.KillmailLimit) // 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) // } // }