package sdkserver import ( "encoding/json" "fmt" "io" "net/http" "os" "sync" "time" "gac-hkrpg-go/internal/common" ) var apiMu sync.Mutex var exRankAvatarNames = map[uint32]string{ 1204: "Jing Yuan (จิ่งหยวน)", 1213: "Dan Heng • Imbibitor Lunae (ดันเฮง จ้าวยลจันทรา)", 1308: "Acheron (อาเคอรอน)", } // corsWrapper adds standard CORS headers so external tools (e.g. GaC-Srtool) can communicate cleanly. func corsWrapper(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, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } next(w, r) } } func registerAPIRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/status", corsWrapper(apiStatus)) mux.HandleFunc("/api/freesr", corsWrapper(apiFreesrData)) mux.HandleFunc("/api/freesr/avatars", corsWrapper(apiFreesrAvatars)) mux.HandleFunc("/api/persistent", corsWrapper(apiPersistentData)) mux.HandleFunc("/api/exrank/toggle", corsWrapper(apiExRankToggle)) mux.HandleFunc("/api/avatar/rank", corsWrapper(apiAvatarRank)) mux.HandleFunc("/api/avatar/max_all", corsWrapper(apiMaxAll)) mux.HandleFunc("/api/teleport", corsWrapper(apiTeleport)) mux.HandleFunc("/api/gachabase/fetch", corsWrapper(apiGachabaseFetch)) } func apiRoot(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } cfg, err := common.LoadRuntimeConfig() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "name": "GaC-hkrpg-go API Gateway", "version": cfg.ClientVersion, "status": 200, "description": "Backend connection endpoints for freesr-data, persistent, and GaC-Srtool", "endpoints": map[string]string{ "status": "GET /api/status", "freesr": "GET, POST /api/freesr", "avatars": "GET, POST /api/freesr/avatars", "persistent": "GET, POST /api/persistent", "exrank_toggle": "POST /api/exrank/toggle", "avatar_rank": "POST /api/avatar/rank", "avatar_max_all": "POST /api/avatar/max_all", "teleport": "POST /api/teleport", }, }) } // apiStatus returns server health, statistics, ex-ranks, and basic persistent overview. func apiStatus(w http.ResponseWriter, r *http.Request) { apiMu.Lock() defer apiMu.Unlock() data, err := common.LoadFreesrData() if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } type ExRankInfo struct { AvatarID uint32 `json:"avatar_id"` Name string `json:"name"` ExRankID uint32 `json:"ex_rank_id"` Unlocked bool `json:"unlocked"` Rank uint32 `json:"rank"` Level uint32 `json:"level"` } var exList []ExRankInfo for aid, exID := range common.AvatarExRankMap { name := exRankAvatarNames[aid] if name == "" { name = fmt.Sprintf("Avatar %d", aid) } unlocked := false rank := uint32(0) level := uint32(80) if av, ok := data.Avatars[aid]; ok { unlocked = av.ExRankUnlocked rank = av.Data.Rank level = av.Level } exList = append(exList, ExRankInfo{ AvatarID: aid, Name: name, ExRankID: exID, Unlocked: unlocked, Rank: rank, Level: level, }) } writeJSON(w, map[string]any{ "status": 200, "ex_ranks": exList, "avatar_count": len(data.Avatars), "lightcone_count": len(data.Lightcones), "relic_count": len(data.Relics), "lineups": data.Lineups, "scene": data.Scene, "position": data.Position, "main_character": data.MainCharacter.String(), "march_type": data.MarchType.String(), }) } // apiFreesrData handles full read/write of freesr-data.json func apiFreesrData(w http.ResponseWriter, r *http.Request) { apiMu.Lock() defer apiMu.Unlock() switch r.Method { case http.MethodGet: raw, err := os.ReadFile("freesr-data.json") if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } var parsed any if err := json.Unmarshal(raw, &parsed); err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } writeJSON(w, map[string]any{"status": 200, "data": parsed}) case http.MethodPost, http.MethodPut: body, err := io.ReadAll(r.Body) if err != nil || len(body) == 0 { writeJSON(w, map[string]any{"error": "empty body", "status": 400}) return } // Support both { "data": {...} } and direct {...} payload var wrap struct { Data json.RawMessage `json:"data"` } toWrite := body if err := json.Unmarshal(body, &wrap); err == nil && len(wrap.Data) > 0 { toWrite = wrap.Data } var parsed map[string]any if err := json.Unmarshal(toWrite, &parsed); err != nil { writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400}) return } out, _ := json.MarshalIndent(parsed, "", " ") if err := os.WriteFile("freesr-data.json", out, 0o644); err != nil { writeJSON(w, map[string]any{"error": "failed to write freesr-data.json: " + err.Error(), "status": 500}) return } // Reload and sync persistent if d, err := common.LoadFreesrData(); err == nil { d.SavePersistent() } writeJSON(w, map[string]any{"message": "freesr-data.json saved successfully", "status": 200}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // apiFreesrAvatars handles querying and modifying avatar details func apiFreesrAvatars(w http.ResponseWriter, r *http.Request) { apiMu.Lock() defer apiMu.Unlock() data, err := common.LoadFreesrData() if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } switch r.Method { case http.MethodGet: type AvatarItem struct { AvatarID uint32 `json:"avatar_id"` Name string `json:"name,omitempty"` Level uint32 `json:"level"` Promotion uint32 `json:"promotion"` Rank uint32 `json:"rank"` ExRankUnlocked bool `json:"ex_rank_unlocked"` HasExRank bool `json:"has_ex_rank"` } var list []AvatarItem for _, aid := range common.BaseAvatarIDs { if av, ok := data.Avatars[aid]; ok { _, hasEx := common.AvatarExRankMap[aid] name := exRankAvatarNames[aid] list = append(list, AvatarItem{ AvatarID: aid, Name: name, Level: av.Level, Promotion: av.Promotion, Rank: av.Data.Rank, ExRankUnlocked: av.ExRankUnlocked, HasExRank: hasEx, }) } } writeJSON(w, map[string]any{ "status": 200, "avatars": list, }) case http.MethodPost, http.MethodPut: var req struct { AvatarID uint32 `json:"avatar_id"` Level *uint32 `json:"level"` Promotion *uint32 `json:"promotion"` Rank *uint32 `json:"rank"` ExRankUnlocked *bool `json:"ex_rank_unlocked"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AvatarID == 0 { writeJSON(w, map[string]any{"error": "invalid payload or missing avatar_id", "status": 400}) return } av, ok := data.Avatars[req.AvatarID] if !ok { writeJSON(w, map[string]any{"error": fmt.Sprintf("avatar %d not found", req.AvatarID), "status": 404}) return } if req.Level != nil { av.Level = *req.Level } if req.Promotion != nil { av.Promotion = *req.Promotion } if req.Rank != nil && *req.Rank <= 6 { av.Data.Rank = *req.Rank } if req.ExRankUnlocked != nil { av.ExRankUnlocked = *req.ExRankUnlocked } raw, _ := json.MarshalIndent(data, "", " ") _ = os.WriteFile("freesr-data.json", raw, 0o644) data.SavePersistent() writeJSON(w, map[string]any{ "message": fmt.Sprintf("avatar %d updated", req.AvatarID), "status": 200, "avatar_id": req.AvatarID, }) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // apiPersistentData handles full read/write of "persistent" file func apiPersistentData(w http.ResponseWriter, r *http.Request) { apiMu.Lock() defer apiMu.Unlock() switch r.Method { case http.MethodGet: raw, err := os.ReadFile("persistent") if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } var parsed any if err := json.Unmarshal(raw, &parsed); err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } writeJSON(w, map[string]any{"status": 200, "persistent": parsed}) case http.MethodPost, http.MethodPut: body, err := io.ReadAll(r.Body) if err != nil || len(body) == 0 { writeJSON(w, map[string]any{"error": "empty body", "status": 400}) return } var parsed map[string]any if err := json.Unmarshal(body, &parsed); err != nil { writeJSON(w, map[string]any{"error": "invalid json: " + err.Error(), "status": 400}) return } // Merge into existing persistent file so partial updates (e.g. lineups or switches) preserve other settings existing := make(map[string]any) if raw, err := os.ReadFile("persistent"); err == nil { _ = json.Unmarshal(raw, &existing) } for k, v := range parsed { existing[k] = v } out, _ := json.MarshalIndent(existing, "", " ") if err := os.WriteFile("persistent", out, 0o644); err != nil { writeJSON(w, map[string]any{"error": "failed to write persistent: " + err.Error(), "status": 500}) return } writeJSON(w, map[string]any{"message": "persistent saved successfully", "status": 200}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } // apiExRankToggle toggles Ex-Rank state for characters func apiExRankToggle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req struct { AvatarID uint32 `json:"avatar_id"` Unlocked *bool `json:"unlocked"` All bool `json:"all"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, map[string]any{"error": "invalid json", "status": 400}) return } apiMu.Lock() defer apiMu.Unlock() data, err := common.LoadFreesrData() if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } if req.All { setVal := false if req.Unlocked != nil { setVal = *req.Unlocked } for aid := range common.AvatarExRankMap { if av, ok := data.Avatars[aid]; ok { av.ExRankUnlocked = setVal } } } else if req.AvatarID != 0 { if av, ok := data.Avatars[req.AvatarID]; ok { if req.Unlocked != nil { av.ExRankUnlocked = *req.Unlocked } else { av.ExRankUnlocked = !av.ExRankUnlocked } } } data.SavePersistent() writeJSON(w, map[string]any{"message": "OK", "status": 200}) } // apiAvatarRank modifies an avatar's eidolon/rank func apiAvatarRank(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req struct { AvatarID uint32 `json:"avatar_id"` Rank uint32 `json:"rank"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, map[string]any{"error": "invalid json", "status": 400}) return } apiMu.Lock() defer apiMu.Unlock() data, err := common.LoadFreesrData() if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } if av, ok := data.Avatars[req.AvatarID]; ok { if req.Rank <= 6 { av.Data.Rank = req.Rank } } raw, _ := json.MarshalIndent(data, "", " ") _ = os.WriteFile("freesr-data.json", raw, 0o644) data.SavePersistent() writeJSON(w, map[string]any{"message": "OK", "status": 200}) } // apiMaxAll sets all characters to max level 80 and rank 6 func apiMaxAll(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } apiMu.Lock() defer apiMu.Unlock() data, err := common.LoadFreesrData() if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } for _, av := range data.Avatars { av.Level = 80 av.Promotion = 6 av.Data.Rank = 6 } raw, _ := json.MarshalIndent(data, "", " ") _ = os.WriteFile("freesr-data.json", raw, 0o644) data.SavePersistent() writeJSON(w, map[string]any{"message": "OK", "status": 200}) } // apiTeleport updates scene and position in persistent func apiTeleport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req struct { PlaneID uint32 `json:"plane_id"` FloorID uint32 `json:"floor_id"` EntryID uint32 `json:"entry_id"` X *int32 `json:"x"` Y *int32 `json:"y"` Z *int32 `json:"z"` RotY *int32 `json:"rot_y"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, map[string]any{"error": "invalid json", "status": 400}) return } apiMu.Lock() defer apiMu.Unlock() data, err := common.LoadFreesrData() if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } if req.PlaneID != 0 { data.Scene.PlaneID = req.PlaneID } if req.FloorID != 0 { data.Scene.FloorID = req.FloorID } if req.EntryID != 0 { data.Scene.EntryID = req.EntryID } if req.X != nil { data.Position.X = *req.X } if req.Y != nil { data.Position.Y = *req.Y } if req.Z != nil { data.Position.Z = *req.Z } if req.RotY != nil { data.Position.RotY = *req.RotY } data.SavePersistent() writeJSON(w, map[string]any{"message": "teleported successfully", "status": 200}) } // apiGachabaseFetch proxies requests to hsr.gachabase.net to bypass browser CORS restrictions. func apiGachabaseFetch(w http.ResponseWriter, r *http.Request) { targetURL := r.URL.Query().Get("url") targetType := r.URL.Query().Get("type") // "character" | "lightcone" | "relic" targetID := r.URL.Query().Get("id") branch := r.URL.Query().Get("branch") if branch == "" { branch = "beta" } lang := r.URL.Query().Get("lang") if lang == "" { lang = "en" } if targetURL == "" && targetID != "" && targetType != "" { switch targetType { case "character", "avatar": targetURL = fmt.Sprintf("https://hsr.gachabase.net/characters/%s/slug/%s?lang=%s", targetID, branch, lang) case "lightcone", "lc": targetURL = fmt.Sprintf("https://hsr.gachabase.net/lightcones/%s/slug/%s?lang=%s", targetID, branch, lang) case "relic": targetURL = fmt.Sprintf("https://hsr.gachabase.net/relics/%s/slug/%s?lang=%s", targetID, branch, lang) } } if targetURL == "" { writeJSON(w, map[string]any{"error": "missing url or id/type parameters", "status": 400}) return } client := &http.Client{Timeout: 12 * time.Second} req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL, nil) if err != nil { writeJSON(w, map[string]any{"error": err.Error(), "status": 500}) return } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") resp, err := client.Do(req) if err != nil { writeJSON(w, map[string]any{"error": "failed to connect to gachabase: " + err.Error(), "status": 502}) return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { writeJSON(w, map[string]any{"error": "failed to read gachabase response: " + err.Error(), "status": 500}) return } writeJSON(w, map[string]any{ "status": resp.StatusCode, "html": string(body), "url": targetURL, }) }