package main import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" "gorm.io/driver/sqlite" "gorm.io/gorm" ) const ( issuerAuthorizeURL = "https://login.eveonline.com/v2/oauth/authorize" issuerTokenURL = "https://login.eveonline.com/v2/oauth/token" esiBase = "https://esi.evetech.net" ) type ESISSO struct { clientID string redirectURI string scopes []string state string codeVerifier string codeChallenge string mu sync.Mutex accessToken string refreshToken string expiresAt time.Time characterID int64 characterName string callbackOnce sync.Once server *http.Server db *gorm.DB } type SolarSystem struct { SolarSystemID int64 `gorm:"column:solarSystemID;primaryKey"` SolarSystemName string `gorm:"column:solarSystemName"` } func (SolarSystem) TableName() string { return "mapSolarSystems" } type ESIToken struct { ID uint `gorm:"primaryKey"` CharacterID int64 `gorm:"index"` CharacterName string AccessToken string RefreshToken string ExpiresAt time.Time UpdatedAt time.Time CreatedAt time.Time } type CharacterInfo struct { CharacterID int64 `json:"character_id"` CharacterName string `json:"character_name"` } func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO { s := &ESISSO{ clientID: clientID, redirectURI: redirectURI, scopes: scopes, } _ = s.initDB() return s } func (s *ESISSO) initDB() error { home, err := os.UserHomeDir() if err != nil { return err } dbPath := filepath.Join(home, ".industrializer", "sqlite-latest.sqlite") db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) if err != nil { return err } if err := db.AutoMigrate(&ESIToken{}); err != nil { return err } s.db = db return nil } func (s *ESISSO) saveToken() { if s.db == nil || s.characterID == 0 { return } t := ESIToken{} s.db.Where("character_id = ?", s.characterID).First(&t) t.CharacterID = s.characterID t.CharacterName = s.characterName t.AccessToken = s.accessToken t.RefreshToken = s.refreshToken t.ExpiresAt = s.expiresAt s.db.Save(&t) } func (s *ESISSO) loadToken() { if s.db == nil || s.characterID == 0 { return } t := ESIToken{} if err := s.db.Where("character_id = ?", s.characterID).First(&t).Error; err == nil { s.accessToken = t.AccessToken s.refreshToken = t.RefreshToken s.expiresAt = t.ExpiresAt } } func (s *ESISSO) BuildAuthorizeURL() (string, error) { if s.clientID == "" { return "", errors.New("EVE_SSO_CLIENT_ID not set") } verifier, challenge, err := generatePKCE() if err != nil { return "", err } s.codeVerifier = verifier s.codeChallenge = challenge s.state = randString(24) q := url.Values{} q.Set("response_type", "code") q.Set("client_id", s.clientID) q.Set("redirect_uri", s.redirectURI) if len(s.scopes) > 0 { q.Set("scope", strings.Join(s.scopes, " ")) } q.Set("state", s.state) q.Set("code_challenge", s.codeChallenge) q.Set("code_challenge_method", "S256") return issuerAuthorizeURL + "?" + q.Encode(), nil } func (s *ESISSO) StartCallbackServerAsync() error { u, err := url.Parse(s.redirectURI) if err != nil { return err } if u.Scheme != "http" && u.Scheme != "https" { return errors.New("redirect URI must be http(s)") } hostPort := u.Host if !strings.Contains(hostPort, ":") { if u.Scheme == "https" { hostPort += ":443" } else { hostPort += ":80" } } mux := http.NewServeMux() mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } q := r.URL.Query() code := q.Get("code") st := q.Get("state") if code == "" || st == "" || st != s.state { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte("Invalid SSO response")) return } if err := s.exchangeToken(r.Context(), code); err != nil { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Token exchange failed: " + err.Error())) return } _, _ = io.WriteString(w, "Login successful. You can close this window.") go func() { time.Sleep(200 * time.Millisecond) _ = s.server.Shutdown(context.Background()) }() }) ln, err := net.Listen("tcp", hostPort) if err != nil { return err } s.server = &http.Server{Handler: mux} go func() { _ = s.server.Serve(ln) }() return nil } func (s *ESISSO) StartCallbackServer() error { u, err := url.Parse(s.redirectURI) if err != nil { return err } if u.Scheme != "http" && u.Scheme != "https" { return errors.New("redirect URI must be http(s)") } hostPort := u.Host if !strings.Contains(hostPort, ":") { if u.Scheme == "https" { hostPort += ":443" } else { hostPort += ":80" } } mux := http.NewServeMux() mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } q := r.URL.Query() code := q.Get("code") st := q.Get("state") if code == "" || st == "" || st != s.state { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte("Invalid SSO response")) return } if err := s.exchangeToken(r.Context(), code); err != nil { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Token exchange failed: " + err.Error())) return } _, _ = io.WriteString(w, "Login successful. You can close this window.") go func() { time.Sleep(200 * time.Millisecond) _ = s.server.Shutdown(context.Background()) }() }) ln, err := net.Listen("tcp", hostPort) if err != nil { return err } s.server = &http.Server{Handler: mux} return s.server.Serve(ln) } func (s *ESISSO) exchangeToken(ctx context.Context, code string) error { form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("code", code) form.Set("client_id", s.clientID) form.Set("code_verifier", s.codeVerifier) req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("token exchange failed: %s: %s", resp.Status, string(b)) } var tr tokenResponse if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return err } s.mu.Lock() if tr.AccessToken != "" { s.accessToken = tr.AccessToken } if tr.RefreshToken != "" { s.refreshToken = tr.RefreshToken } if tr.ExpiresIn > 0 { s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second) } s.mu.Unlock() name, cid := parseTokenCharacter(tr.AccessToken) s.characterName = name s.characterID = cid s.saveToken() return nil } func (s *ESISSO) refresh(ctx context.Context) error { s.mu.Lock() rt := s.refreshToken s.mu.Unlock() if rt == "" { return errors.New("no refresh token") } form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", rt) form.Set("client_id", s.clientID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b)) } var tr tokenResponse if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return err } s.mu.Lock() s.accessToken = tr.AccessToken if tr.RefreshToken != "" { s.refreshToken = tr.RefreshToken } if tr.ExpiresIn > 0 { s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second) } s.mu.Unlock() name, cid := parseTokenCharacter(tr.AccessToken) s.characterName = name s.characterID = cid s.saveToken() return nil } func (s *ESISSO) refreshForToken(ctx context.Context, t *ESIToken) (*ESIToken, error) { form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", t.RefreshToken) form.Set("client_id", s.clientID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b)) } var tr tokenResponse if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return nil, err } t.AccessToken = tr.AccessToken if tr.RefreshToken != "" { t.RefreshToken = tr.RefreshToken } if tr.ExpiresIn > 0 { t.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second) } if s.db != nil { _ = s.db.Save(t).Error } return t, nil } func (s *ESISSO) ensureAccessToken(ctx context.Context) (string, error) { s.mu.Lock() if s.accessToken == "" && s.db != nil && s.characterID != 0 { s.mu.Unlock() s.loadToken() s.mu.Lock() } tok := s.accessToken exp := s.expiresAt s.mu.Unlock() if tok == "" { return "", errors.New("not logged in") } if time.Now().After(exp.Add(-60 * time.Second)) { if err := s.refresh(ctx); err != nil { return "", err } s.mu.Lock() tok = s.accessToken s.mu.Unlock() } return tok, nil } func (s *ESISSO) ensureAccessTokenFor(ctx context.Context, t *ESIToken) (string, error) { if t.AccessToken == "" || time.Now().After(t.ExpiresAt.Add(-60*time.Second)) { nt, err := s.refreshForToken(ctx, t) if err != nil { return "", err } return nt.AccessToken, nil } return t.AccessToken, nil } func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginning bool) error { tok, err := s.ensureAccessToken(context.Background()) if err != nil { return err } return s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning) } func (s *ESISSO) postWaypointWithToken(tok string, destinationID int64, clearOthers bool, addToBeginning bool) error { q := url.Values{} q.Set("destination_id", strconv.FormatInt(destinationID, 10)) q.Set("add_to_beginning", strconv.FormatBool(addToBeginning)) q.Set("clear_other_waypoints", strconv.FormatBool(clearOthers)) q.Set("datasource", "tranquility") endpoint := esiBase + "/v2/ui/autopilot/waypoint?" + q.Encode() fmt.Printf("ESI: POST waypoint dest=%d clear=%v addToBeginning=%v\n", destinationID, clearOthers, addToBeginning) req, err := http.NewRequest(http.MethodPost, endpoint, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+tok) req.Header.Set("Accept", "application/json") req.Header.Set("X-User-Agent", "signalerr/1.0") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { fmt.Println("ESI: waypoint set OK", resp.Status) return nil } b, _ := io.ReadAll(resp.Body) fmt.Printf("ESI: waypoint failed %s body=%s\n", resp.Status, string(b)) return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b)) } func (s *ESISSO) PostWaypointForAll(destinationID int64, clearOthers bool, addToBeginning bool) error { if s.db == nil { return errors.New("db not initialised") } var tokens []ESIToken if err := s.db.Find(&tokens).Error; err != nil { return err } var firstErr error for i := range tokens { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) tok, err := s.ensureAccessTokenFor(ctx, &tokens[i]) cancel() if err != nil { if firstErr == nil { firstErr = err } continue } if err := s.postWaypointWithToken(tok, destinationID, clearOthers, addToBeginning); err != nil && firstErr == nil { firstErr = err } } return firstErr } func (s *ESISSO) Status() SSOStatus { s.mu.Lock() defer s.mu.Unlock() return SSOStatus{ LoggedIn: s.accessToken != "", CharacterID: s.characterID, CharacterName: s.characterName, ExpiresAt: s.expiresAt, } } type SSOStatus struct { LoggedIn bool CharacterID int64 CharacterName string ExpiresAt time.Time } type tokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } func generatePKCE() (verifier string, challenge string, err error) { buf := make([]byte, 32) if _, err = rand.Read(buf); err != nil { return } v := base64.RawURLEncoding.EncodeToString(buf) h := sha256.Sum256([]byte(v)) c := base64.RawURLEncoding.EncodeToString(h[:]) return v, c, nil } func randString(n int) string { buf := make([]byte, n) _, _ = rand.Read(buf) return base64.RawURLEncoding.EncodeToString(buf) } func parseTokenCharacter(jwt string) (name string, id int64) { parts := strings.Split(jwt, ".") if len(parts) != 3 { return "", 0 } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return "", 0 } var m map[string]any if err := json.Unmarshal(payload, &m); err != nil { return "", 0 } if v, ok := m["name"].(string); ok { name = v } if v, ok := m["sub"].(string); ok { if idx := strings.LastIndexByte(v, ':'); idx > -1 { if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil { id = idv } } } return } // ResolveSystemIDByName ... DB-first then ESI fallbacks func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) { name = strings.TrimSpace(name) if name == "" { return 0, errors.New("empty system name") } if s.db != nil { var sys SolarSystem if err := s.db.Where("solarSystemName = ?", name).First(&sys).Error; err == nil && sys.SolarSystemID != 0 { fmt.Printf("DB: resolved %q -> %d\n", name, sys.SolarSystemID) return sys.SolarSystemID, nil } } // Fallbacks (universe/ids -> strict search -> non-strict + names) type idsReq struct{ Names []string `json:"names"` } body, _ := json.Marshal(idsReq{Names: []string{name}}) idsURL := esiBase + "/v3/universe/ids/?datasource=tranquility" fmt.Printf("ESI: resolve system id via universe/ids for name=%q\n", name) req, err := http.NewRequestWithContext(ctx, http.MethodPost, idsURL, strings.NewReader(string(body))) if err == nil { req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, errDo := http.DefaultClient.Do(req) if errDo == nil { defer resp.Body.Close() if resp.StatusCode == http.StatusOK { var idsResp struct{ Systems []struct{ ID int64 `json:"id"`; Name string `json:"name"` } `json:"systems"` } if err := json.NewDecoder(resp.Body).Decode(&idsResp); err == nil { if len(idsResp.Systems) > 0 { fmt.Printf("ESI: universe/ids hit: %s -> %d\n", idsResp.Systems[0].Name, idsResp.Systems[0].ID) return idsResp.Systems[0].ID, nil } } } } } q := url.Values{} q.Set("categories", "solar_system") q.Set("search", name) q.Set("strict", "true") searchURL := esiBase + "/v3/search/?" + q.Encode() fmt.Printf("ESI: strict search for %q\n", name) req2, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil) if err != nil { return 0, err } req2.Header.Set("Accept", "application/json") resp2, err := http.DefaultClient.Do(req2) if err == nil { defer resp2.Body.Close() if resp2.StatusCode == http.StatusOK { var payload struct{ SolarSystem []int64 `json:"solar_system"` } if err := json.NewDecoder(resp2.Body).Decode(&payload); err == nil && len(payload.SolarSystem) > 0 { fmt.Printf("ESI: strict search hit: %d\n", payload.SolarSystem[0]) return payload.SolarSystem[0], nil } } } q.Set("strict", "false") searchURL2 := esiBase + "/v3/search/?" + q.Encode() fmt.Printf("ESI: non-strict search for %q\n", name) req3, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL2, nil) if err != nil { return 0, err } req3.Header.Set("Accept", "application/json") resp3, err := http.DefaultClient.Do(req3) if err != nil { return 0, err } defer resp3.Body.Close() if resp3.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp3.Body) return 0, fmt.Errorf("search failed: %s: %s", resp3.Status, string(b)) } var payload struct{ SolarSystem []int64 `json:"solar_system"` } if err := json.NewDecoder(resp3.Body).Decode(&payload); err != nil || len(payload.SolarSystem) == 0 { return 0, fmt.Errorf("system not found: %s", name) } if len(payload.SolarSystem) == 1 { fmt.Printf("ESI: non-strict search single hit: %d\n", payload.SolarSystem[0]) return payload.SolarSystem[0], nil } ids := payload.SolarSystem namesURL := esiBase + "/v3/universe/names/?datasource=tranquility" idsBody, _ := json.Marshal(ids) req4, err := http.NewRequestWithContext(ctx, http.MethodPost, namesURL, strings.NewReader(string(idsBody))) if err != nil { return 0, err } req4.Header.Set("Content-Type", "application/json") req4.Header.Set("Accept", "application/json") resp4, err := http.DefaultClient.Do(req4) if err != nil { return 0, err } defer resp4.Body.Close() if resp4.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp4.Body) return 0, fmt.Errorf("names lookup failed: %s: %s", resp4.Status, string(b)) } var namesResp []struct{ Category string `json:"category"`; ID int64 `json:"id"`; Name string `json:"name"` } if err := json.NewDecoder(resp4.Body).Decode(&namesResp); err != nil { return 0, err } lower := strings.ToLower(name) for _, n := range namesResp { if n.Category == "solar_system" && strings.ToLower(n.Name) == lower { fmt.Printf("ESI: names resolved exact: %s -> %d\n", n.Name, n.ID) return n.ID, nil } } fmt.Printf("ESI: names resolved fallback: returning %d for %q\n", namesResp[0].ID, name) return namesResp[0].ID, nil }