626 lines
16 KiB
Go
626 lines
16 KiB
Go
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 (
|
|
// SSO endpoints
|
|
issuerAuthorizeURL = "https://login.eveonline.com/v2/oauth/authorize"
|
|
issuerTokenURL = "https://login.eveonline.com/v2/oauth/token"
|
|
|
|
// ESI base
|
|
esiBase = "https://esi.evetech.net"
|
|
)
|
|
|
|
// ESISSO encapsulates a minimal PKCE SSO client and token store
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
// Ensure tokens table exists in same DB; safe to AutoMigrate
|
|
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
|
|
}
|
|
}
|
|
|
|
// BuildAuthorizeURL prepares state and PKCE challenge and returns the browser URL
|
|
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
|
|
}
|
|
|
|
// StartCallbackServerAsync starts the callback server in the background and returns immediately
|
|
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
|
|
}
|
|
|
|
// Deprecated: blocking start; prefer StartCallbackServerAsync
|
|
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()
|
|
defer s.mu.Unlock()
|
|
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)
|
|
}
|
|
// Parse basic claims for display
|
|
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()
|
|
defer s.mu.Unlock()
|
|
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)
|
|
}
|
|
name, cid := parseTokenCharacter(tr.AccessToken)
|
|
s.characterName = name
|
|
s.characterID = cid
|
|
s.saveToken()
|
|
return nil
|
|
}
|
|
|
|
func (s *ESISSO) ensureAccessToken(ctx context.Context) (string, error) {
|
|
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
|
|
}
|
|
|
|
// PostWaypoint calls ESI to set destination or add waypoint
|
|
func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginning bool) error {
|
|
tok, err := s.ensureAccessToken(context.Background())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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))
|
|
}
|
|
|
|
// Status reports current login state and character details
|
|
type SSOStatus struct {
|
|
LoggedIn bool
|
|
CharacterID int64
|
|
CharacterName string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// ResolveSystemIDByName first checks local DB via GORM, then falls back to ESI
|
|
func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return 0, errors.New("empty system name")
|
|
}
|
|
// 0) Try DB first
|
|
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
|
|
}
|
|
}
|
|
// Fallback to ESI logic
|
|
// 1) Prefer universe/ids (name->id) for accuracy
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) Fallback: strict search
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3) Fallback: non-strict search then best-effort name match via universe/names
|
|
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 one result, return it
|
|
if len(payload.SolarSystem) == 1 {
|
|
fmt.Printf("ESI: non-strict search single hit: %d\n", payload.SolarSystem[0])
|
|
return payload.SolarSystem[0], nil
|
|
}
|
|
// Multiple: resolve names and pick exact case-insensitive match if possible
|
|
ids := payload.SolarSystem
|
|
var idNamesReq = make([]int64, 0, len(ids))
|
|
idNamesReq = append(idNamesReq, ids...)
|
|
|
|
namesURL := esiBase + "/v3/universe/names/?datasource=tranquility"
|
|
idsBody, _ := json.Marshal(idNamesReq)
|
|
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
|
|
}
|
|
}
|
|
// Fallback: return first
|
|
fmt.Printf("ESI: names resolved fallback: returning %d for %q\n", namesResp[0].ID, name)
|
|
return namesResp[0].ID, nil
|
|
}
|
|
|
|
// Helpers
|
|
|
|
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 {
|
|
// format EVE:CHARACTER:<id>
|
|
if idx := strings.LastIndexByte(v, ':'); idx > -1 {
|
|
if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil {
|
|
id = idv
|
|
}
|
|
}
|
|
}
|
|
return
|
|
} |