Compare commits
25 Commits
v3.0.0
...
fb3ebc10ff
Author | SHA1 | Date | |
---|---|---|---|
fb3ebc10ff | |||
2a098ec0d2 | |||
ee2a1fcde0 | |||
a584bc9c55 | |||
e1e961ebea | |||
f06a60c701 | |||
ef57bf4cde | |||
cd1cc6dc5f | |||
e7a8014a50 | |||
13da1c8340 | |||
3f9d315978 | |||
ca610000db | |||
33fcaaaf52 | |||
1b73642940 | |||
aa15a8c2c9 | |||
478a628b6f | |||
98b6397dcc | |||
715e4559aa | |||
d42c245c9d | |||
ff840299d6 | |||
f69c93ba91 | |||
c0f2430590 | |||
c999a500f8 | |||
c668bb83f5 | |||
e140fe0a00 |
1
signalerr/.gitignore → .gitignore
vendored
1
signalerr/.gitignore → .gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
build/bin
|
||||
node_modules
|
||||
frontend/dist
|
||||
build
|
137
app.go
Normal file
137
app.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
ssi *ESISSO
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
// startup is called when the app starts. The context is saved
|
||||
// so we can call the runtime methods
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
|
||||
clientID := os.Getenv("EVE_SSO_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
clientID = "5091f74037374697938384bdbac2698c"
|
||||
}
|
||||
redirectURI := os.Getenv("EVE_SSO_REDIRECT_URI")
|
||||
if redirectURI == "" {
|
||||
redirectURI = "http://localhost:8080/callback"
|
||||
}
|
||||
|
||||
a.ssi = NewESISSO(clientID, redirectURI, []string{"esi-ui.write_waypoint.v1"})
|
||||
}
|
||||
|
||||
// Greet returns a greeting for the given name
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hello %s, It's show time!", name)
|
||||
}
|
||||
|
||||
// StartESILogin begins the PKCE SSO flow and opens a browser to the EVE login page
|
||||
func (a *App) StartESILogin() (string, error) {
|
||||
if a.ssi == nil {
|
||||
return "", errors.New("ESI not initialised")
|
||||
}
|
||||
url, err := a.ssi.BuildAuthorizeURL()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := a.ssi.StartCallbackServerAsync(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
runtime.BrowserOpenURL(a.ctx, url)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (a *App) ESILoginStatus() string {
|
||||
if a.ssi == nil {
|
||||
return "not initialised"
|
||||
}
|
||||
st := a.ssi.Status()
|
||||
if st.LoggedIn {
|
||||
return fmt.Sprintf("logged in as %s (%d)", st.CharacterName, st.CharacterID)
|
||||
}
|
||||
return "not logged in"
|
||||
}
|
||||
|
||||
func (a *App) ESILoggedIn() bool {
|
||||
if a.ssi == nil {
|
||||
return false
|
||||
}
|
||||
return a.ssi.Status().LoggedIn
|
||||
}
|
||||
|
||||
func (a *App) SetDestinationForAll(systemName string, clearOthers bool, addToBeginning bool) error {
|
||||
if a.ssi == nil {
|
||||
return errors.New("ESI not initialised")
|
||||
}
|
||||
id, err := a.ssi.ResolveSystemIDByName(a.ctx, systemName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.ssi.PostWaypointForAll(id, clearOthers, addToBeginning)
|
||||
}
|
||||
|
||||
func (a *App) AddWaypointForAllByName(systemName string, addToBeginning bool) error {
|
||||
if a.ssi == nil {
|
||||
return errors.New("ESI not initialised")
|
||||
}
|
||||
id, err := a.ssi.ResolveSystemIDByName(a.ctx, systemName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.ssi.PostWaypointForAll(id, false, addToBeginning)
|
||||
}
|
||||
|
||||
// PostRouteForAllByNames posts a full route: via names (in order) then destination at the end (clearing first)
|
||||
func (a *App) PostRouteForAllByNames(destination string, vias []string) error {
|
||||
if a.ssi == nil {
|
||||
return errors.New("ESI not initialised")
|
||||
}
|
||||
ids, err := a.ssi.ResolveSystemIDsByNames(a.ctx, append(vias, destination))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
viaIDs := ids[:len(vias)]
|
||||
destID := ids[len(vias)]
|
||||
return a.ssi.PostRouteForAll(destID, viaIDs)
|
||||
}
|
||||
|
||||
func (a *App) ListCharacters() ([]CharacterInfo, error) {
|
||||
if a.ssi == nil || a.ssi.db == nil {
|
||||
return nil, errors.New("ESI not initialised")
|
||||
}
|
||||
var tokens []ESIToken
|
||||
if err := a.ssi.db.Find(&tokens).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]CharacterInfo, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
list = append(list, CharacterInfo{CharacterID: t.CharacterID, CharacterName: t.CharacterName})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetCharacterLocations exposes current locations for all characters
|
||||
func (a *App) GetCharacterLocations() ([]CharacterLocation, error) {
|
||||
if a.ssi == nil { return nil, errors.New("ESI not initialised") }
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
return a.ssi.GetCharacterLocations(ctx)
|
||||
}
|
709
esi_sso.go
Normal file
709
esi_sso.go
Normal file
@@ -0,0 +1,709 @@
|
||||
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
|
||||
|
||||
nameCacheOnce sync.Once
|
||||
nameToID map[string]int64 // lowercased name -> id
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// CharacterLocation represents a character's current location
|
||||
type CharacterLocation struct {
|
||||
CharacterID int64 `json:"character_id"`
|
||||
CharacterName string `json:"character_name"`
|
||||
SolarSystemID int64 `json:"solar_system_id"`
|
||||
SolarSystemName string `json:"solar_system_name"`
|
||||
RetrievedAt time.Time `json:"retrieved_at"`
|
||||
}
|
||||
|
||||
type esiCharacterLocationResponse struct {
|
||||
SolarSystemID int64 `json:"solar_system_id"`
|
||||
StationID int64 `json:"station_id"`
|
||||
StructureID int64 `json:"structure_id"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// resolveSystemNameByID returns the system name for an ID from the local DB, or empty if not found
|
||||
func (s *ESISSO) resolveSystemNameByID(id int64) string {
|
||||
if s.db == nil || id == 0 { return "" }
|
||||
var ss SolarSystem
|
||||
if err := s.db.Select("solarSystemName").First(&ss, "solarSystemID = ?", id).Error; err != nil { return "" }
|
||||
return ss.SolarSystemName
|
||||
}
|
||||
|
||||
// GetCharacterLocations returns current locations for all stored characters
|
||||
func (s *ESISSO) GetCharacterLocations(ctx context.Context) ([]CharacterLocation, error) {
|
||||
if s.db == nil { return nil, errors.New("db not initialised") }
|
||||
var tokens []ESIToken
|
||||
if err := s.db.Find(&tokens).Error; err != nil { return nil, err }
|
||||
out := make([]CharacterLocation, 0, len(tokens))
|
||||
client := &http.Client{ Timeout: 5 * time.Second }
|
||||
for i := range tokens {
|
||||
t := &tokens[i]
|
||||
tok, err := s.ensureAccessTokenFor(ctx, t)
|
||||
if err != nil { continue }
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, esiBase+"/v2/characters/"+strconv.FormatInt(t.CharacterID,10)+"/location", nil)
|
||||
if err != nil { continue }
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil { continue }
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK { return }
|
||||
var lr esiCharacterLocationResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { return }
|
||||
name := s.resolveSystemNameByID(lr.SolarSystemID)
|
||||
out = append(out, CharacterLocation{ CharacterID: t.CharacterID, CharacterName: t.CharacterName, SolarSystemID: lr.SolarSystemID, SolarSystemName: name, RetrievedAt: time.Now() })
|
||||
}()
|
||||
}
|
||||
return out, 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
|
||||
}
|
||||
|
||||
// ensureNameCache loads a lowercase name->id map from the local DB once
|
||||
func (s *ESISSO) ensureNameCache() error {
|
||||
var err error
|
||||
s.nameCacheOnce.Do(func() {
|
||||
cache := make(map[string]int64, 50000)
|
||||
if s.db != nil {
|
||||
var rows []SolarSystem
|
||||
// Only select required columns
|
||||
if e := s.db.Select("solarSystemID, solarSystemName").Find(&rows).Error; e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
for _, r := range rows {
|
||||
cache[strings.ToLower(r.SolarSystemName)] = r.SolarSystemID
|
||||
}
|
||||
}
|
||||
s.nameToID = cache
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveSystemIDByName resolves using ONLY the local DB cache (case-insensitive)
|
||||
func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) {
|
||||
if err := s.ensureNameCache(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if id, ok := s.nameToID[key]; ok {
|
||||
return id, nil
|
||||
}
|
||||
return 0, fmt.Errorf("system not found in local DB: %s", name)
|
||||
}
|
||||
|
||||
// ResolveSystemIDsByNames returns IDs in the same order as names using ONLY the local DB cache
|
||||
func (s *ESISSO) ResolveSystemIDsByNames(ctx context.Context, names []string) ([]int64, error) {
|
||||
if err := s.ensureNameCache(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]int64, len(names))
|
||||
missing := []string{}
|
||||
for i, n := range names {
|
||||
key := strings.ToLower(strings.TrimSpace(n))
|
||||
if id, ok := s.nameToID[key]; ok {
|
||||
out[i] = id
|
||||
} else {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("systems not found in local DB: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PostRouteForAll clears route and posts vias then destination last
|
||||
func (s *ESISSO) PostRouteForAll(destID int64, viaIDs []int64) 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 }
|
||||
// Deduplicate by CharacterID
|
||||
uniq := make(map[int64]ESIToken, len(tokens))
|
||||
for _, t := range tokens {
|
||||
uniq[t.CharacterID] = t
|
||||
}
|
||||
uniqueTokens := make([]ESIToken, 0, len(uniq))
|
||||
for _, t := range uniq { uniqueTokens = append(uniqueTokens, t) }
|
||||
|
||||
var mu sync.Mutex
|
||||
var firstErr error
|
||||
var wg sync.WaitGroup
|
||||
// Run per-character in parallel
|
||||
for i := range uniqueTokens {
|
||||
wg.Add(1)
|
||||
go func(t ESIToken) {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
tok, err := s.ensureAccessTokenFor(ctx, &t)
|
||||
cancel()
|
||||
if err != nil {
|
||||
mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return
|
||||
}
|
||||
// Post sequence for this character
|
||||
if len(viaIDs) > 0 {
|
||||
if err := s.postWaypointWithToken(tok, viaIDs[0], true, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
for _, id := range viaIDs[1:] {
|
||||
if err := s.postWaypointWithToken(tok, id, false, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
}
|
||||
if err := s.postWaypointWithToken(tok, destID, false, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
} else {
|
||||
if err := s.postWaypointWithToken(tok, destID, true, false); err != nil { mu.Lock(); if firstErr == nil { firstErr = err }; mu.Unlock(); return }
|
||||
}
|
||||
}(uniqueTokens[i])
|
||||
}
|
||||
wg.Wait()
|
||||
return firstErr
|
||||
}
|
@@ -1,20 +1,9 @@
|
||||
FROM oven/bun:1.0.25-slim as builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY bun.lockb ./
|
||||
COPY dist dist
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Install dependencies
|
||||
RUN bun install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN bun run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user