Files
sing-box-extended/transport/call/dion/api.go

651 lines
18 KiB
Go

package dion
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/sagernet/sing-box/transport/call/common"
N "github.com/sagernet/sing/common/network"
)
var ErrSessionExpired = errors.New("dion: session expired, re-login required")
var errLoginEndpointMissing = errors.New("dion: login endpoint not available")
const (
accessCookieName = "vc-access-token"
refreshCookieName = "vc-refresh-token"
loginClientsPath = "/v2/users/login/web"
loginPlatformPath = "/platform/v2/auth/auth-providers/dion/login/password"
)
const (
refreshSkewSeconds = 60
refreshMaxAttempts = 3
refreshBaseDelay = 2 * time.Second
refreshDelayMultiply = 1.75
)
const (
APIBase = "https://api.dion.vc"
APIClientsBase = "https://api-clients.dion.vc"
WebBase = "https://dion.vc"
Origin = "https://dion.vc"
CookieDomain = "dion.vc"
)
type GuestUser struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Initials string `json:"initials"`
Position string `json:"position"`
AvatarHTTPPath string `json:"avatar_http_path"`
IsProfileFilledIn bool `json:"is_profile_filled_in"`
Roles []string `json:"roles"`
}
type GuestAuthResponse struct {
AccessToken string `json:"access_token"`
AuthProvider string `json:"auth_provider"`
IsAuthBySSO bool `json:"is_auth_by_sso"`
User GuestUser `json:"user"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
AuthProvider string `json:"auth_provider"`
IsAuthBySSO bool `json:"is_auth_by_sso"`
User GuestUser `json:"user"`
}
type EventInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
OrgID string `json:"org_id"`
Admins []string `json:"admins"`
PSTN struct {
Number string `json:"number"`
Pin int `json:"pin"`
Prefix string `json:"prefix"`
} `json:"pstn"`
}
type WSSConnectResponse struct {
Host string `json:"host"`
Path string `json:"path"`
Schema string `json:"schema"`
URL string `json:"url"`
Params map[string]string `json:"params"`
}
type Session struct {
HTTPClient *http.Client
Device DeviceProfile
AccessToken string
AccessTokenExp time.Time
UserID string
SessionID string
cookiesPath string
email string
password string
refreshMu sync.Mutex
}
type AuthResult struct {
Session *Session
Event *EventInfo
WSS *WSSConnectResponse
SessionID string
}
func NewSession(dialer N.Dialer) (*Session, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("cookiejar: %w", err)
}
httpClient := common.HttpClient(dialer)
httpClient.Jar = jar
return &Session{HTTPClient: httpClient, Device: RandomDeviceProfile()}, nil
}
func (s *Session) RegisterGuest() (*GuestAuthResponse, error) {
auth, err := s.callRefreshOnce()
if err != nil {
return nil, err
}
s.applyRefreshResult(auth)
return auth, nil
}
func (s *Session) RegisterAnonymousGuest(eventID, displayName string) (*GuestAuthResponse, error) {
if eventID == "" {
return nil, fmt.Errorf("empty event_id")
}
if displayName == "" {
displayName = "Guest"
}
body, _ := json.Marshal(map[string]any{
"event_id": eventID,
"name": displayName,
})
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/users/register/guest", bytes.NewReader(body))
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Type", "application/json")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("register/guest: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("register/guest: status %d: %s", resp.StatusCode, string(raw))
}
var auth GuestAuthResponse
if err := json.Unmarshal(raw, &auth); err != nil {
return nil, fmt.Errorf("register/guest decode: %w", err)
}
if auth.AccessToken == "" {
return nil, fmt.Errorf("register/guest: empty access_token: %s", string(raw))
}
s.applyRefreshResult(&auth)
return &auth, nil
}
// SetCredentials stores an email/password pair used by refreshLocked to
// re-authenticate when the refresh cookie is missing or rejected.
func (s *Session) SetCredentials(email, password string) {
s.email = strings.TrimSpace(email)
s.password = password
}
// LoginWithPassword exchanges credentials for a fresh token pair. The web
// front-end posts to api-clients, and switches to the platform endpoint when
// the DION_PLATFORM_COOKIE_AUTH_ENABLED toggle is on, so both are tried.
func (s *Session) LoginWithPassword(email, password string) error {
if email == "" || password == "" {
return fmt.Errorf("login: email and password are required")
}
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
login, err := s.postLogin(APIClientsBase+loginClientsPath, body)
if errors.Is(err, errLoginEndpointMissing) {
login, err = s.postLogin(APIBase+loginPlatformPath, body)
}
if err != nil {
return err
}
s.email = email
s.password = password
s.applyLoginResult(login)
return nil
}
func (s *Session) postLogin(target string, body []byte) (*LoginResponse, error) {
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Type", "application/json")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("login: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
return nil, errLoginEndpointMissing
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("login: status %d: %s", resp.StatusCode, string(raw))
}
var login LoginResponse
if err := json.Unmarshal(raw, &login); err != nil {
return nil, fmt.Errorf("login decode: %w", err)
}
if login.AccessToken == "" {
return nil, fmt.Errorf("login: empty access_token: %s", string(raw))
}
return &login, nil
}
func (s *Session) applyLoginResult(login *LoginResponse) {
s.AccessToken = login.AccessToken
s.UserID = login.User.ID
if exp, err := parseJWTExpiry(login.AccessToken); err == nil {
s.AccessTokenExp = exp
}
if login.RefreshToken != "" {
s.SetCookieInJar(refreshCookieName, login.RefreshToken)
}
s.SetCookieInJar(accessCookieName, login.AccessToken)
}
func (s *Session) Refresh() error {
s.refreshMu.Lock()
defer s.refreshMu.Unlock()
return s.refreshLocked()
}
func (s *Session) EnsureValidToken() error {
s.refreshMu.Lock()
defer s.refreshMu.Unlock()
if s.AccessToken != "" && !s.AccessTokenExp.IsZero() &&
time.Until(s.AccessTokenExp) > time.Duration(refreshSkewSeconds)*time.Second {
return nil
}
return s.refreshLocked()
}
func (s *Session) DoAuthenticated(buildRequest func() (*http.Request, error)) (*http.Response, error) {
if err := s.EnsureValidToken(); err != nil {
return nil, err
}
req, err := buildRequest()
if err != nil {
return nil, err
}
s.setBaseHeaders(req, s.AccessToken)
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
staleToken := s.AccessToken
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
s.refreshMu.Lock()
if s.AccessToken == staleToken {
if err := s.refreshLocked(); err != nil {
s.refreshMu.Unlock()
return nil, err
}
}
s.refreshMu.Unlock()
retryReq, err := buildRequest()
if err != nil {
return nil, err
}
s.setBaseHeaders(retryReq, s.AccessToken)
return s.HTTPClient.Do(retryReq)
}
func (s *Session) WhoAmI() (json.RawMessage, error) {
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, APIBase+"/platform/v1/whoami", nil)
})
if err != nil {
return nil, fmt.Errorf("whoami: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("whoami: status %d: %s", resp.StatusCode, string(raw))
}
return raw, nil
}
func (s *Session) GetEventBySlug(slug string) (*EventInfo, error) {
if slug == "" {
return nil, fmt.Errorf("empty room ID")
}
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return nil, fmt.Errorf("get event: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get event: status %d: %s", resp.StatusCode, string(raw))
}
var event EventInfo
if err := json.Unmarshal(raw, &event); err != nil {
return nil, fmt.Errorf("get event decode: %w", err)
}
if event.ID == "" {
return nil, fmt.Errorf("get event: empty id: %s", string(raw))
}
return &event, nil
}
func (s *Session) GenerateSlug() (string, error) {
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
return http.NewRequest(http.MethodGet, APIClientsBase+"/v2/events/slug/generate", nil)
})
if err != nil {
return "", fmt.Errorf("generate room ID: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("generate room ID: status %d: %s", resp.StatusCode, string(raw))
}
var out struct {
Slug string `json:"slug"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("generate room ID decode: %w", err)
}
if out.Slug == "" {
return "", fmt.Errorf("generate room ID: empty: %s", string(raw))
}
return out.Slug, nil
}
type CreateEventOptions struct {
Slug string
EventParams []string
IsImpersonalSlug bool
IsOnCloud bool
}
func (s *Session) CreateEvent(opts CreateEventOptions) (*EventInfo, error) {
if opts.Slug == "" {
return nil, fmt.Errorf("empty room ID")
}
if opts.EventParams == nil {
opts.EventParams = []string{"guest_access"}
}
body, _ := json.Marshal(map[string]any{
"event_params": opts.EventParams,
"is_impersonal_slug": opts.IsImpersonalSlug,
"is_on_cloud": opts.IsOnCloud,
"slug": opts.Slug,
})
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/events", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return nil, fmt.Errorf("create event: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("create event: status %d: %s", resp.StatusCode, string(raw))
}
var event EventInfo
if err := json.Unmarshal(raw, &event); err != nil {
return nil, fmt.Errorf("create event decode: %w", err)
}
if event.ID == "" {
return nil, fmt.Errorf("create event: empty id: %s", string(raw))
}
return &event, nil
}
func (s *Session) CreateRoom() (*EventInfo, error) {
slug, err := s.GenerateSlug()
if err != nil {
return nil, err
}
return s.CreateEvent(CreateEventOptions{
Slug: slug,
EventParams: []string{"guest_access"},
IsImpersonalSlug: true,
IsOnCloud: true,
})
}
func (s *Session) ConnectWSS(sessionID string) (*WSSConnectResponse, error) {
if sessionID == "" {
sessionID = uuid.New().String()
}
body, _ := json.Marshal(map[string]string{"session_id": sessionID})
resp, err := s.DoAuthenticated(func() (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, APIBase+"/conference/v1/connect/wss", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return nil, fmt.Errorf("connect/wss: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("connect/wss: status %d: %s", resp.StatusCode, string(raw))
}
var wss WSSConnectResponse
if err := json.Unmarshal(raw, &wss); err != nil {
return nil, fmt.Errorf("connect/wss decode: %w", err)
}
if wss.URL == "" {
return nil, fmt.Errorf("connect/wss: empty url: %s", string(raw))
}
s.SessionID = sessionID
return &wss, nil
}
func (s *Session) LookupEventBySlugAnonymous(slug string) (*EventInfo, error) {
if slug == "" {
return nil, fmt.Errorf("empty room ID")
}
eventURL := fmt.Sprintf("%s/conference/v1/events/slug/%s", APIBase, slug)
req, err := http.NewRequest(http.MethodGet, eventURL, nil)
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Type", "application/json")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get event anon: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get event anon: status %d: %s", resp.StatusCode, string(raw))
}
var event EventInfo
if err := json.Unmarshal(raw, &event); err != nil {
return nil, fmt.Errorf("get event anon decode: %w", err)
}
if event.ID == "" {
return nil, fmt.Errorf("get event anon: empty id: %s", string(raw))
}
return &event, nil
}
func JoinAsGuest(dialer N.Dialer, slug, displayName string) (*Session, *EventInfo, error) {
session, err := NewSession(dialer)
if err != nil {
return nil, nil, err
}
if err := session.PrimeCookies(slug); err != nil {
return nil, nil, fmt.Errorf("prime cookies: %w", err)
}
event, err := session.LookupEventBySlugAnonymous(slug)
if err != nil {
return nil, nil, err
}
if _, err := session.RegisterAnonymousGuest(event.ID, displayName); err != nil {
return nil, nil, fmt.Errorf("RegisterAnonymousGuest: %w", err)
}
return session, event, nil
}
func AuthAndGetTicket(dialer N.Dialer, slug string) (*AuthResult, error) {
session, err := NewSession(dialer)
if err != nil {
return nil, err
}
if err := session.PrimeCookies(slug); err != nil {
return nil, err
}
if _, err := session.RegisterGuest(); err != nil {
return nil, err
}
if _, err := session.WhoAmI(); err != nil {
return nil, fmt.Errorf("whoami after guest auth: %w", err)
}
event, err := session.GetEventBySlug(slug)
if err != nil {
return nil, err
}
sessionID := uuid.New().String()
wss, err := session.ConnectWSS(sessionID)
if err != nil {
return nil, err
}
return &AuthResult{
Session: session,
Event: event,
WSS: wss,
SessionID: sessionID,
}, nil
}
func ParseRoom(input string) string {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return ""
}
trimmed = strings.TrimPrefix(trimmed, "dion://")
trimmed = strings.TrimPrefix(trimmed, "https://")
trimmed = strings.TrimPrefix(trimmed, "http://")
trimmed = strings.TrimPrefix(trimmed, "dion.vc/")
trimmed = strings.TrimPrefix(trimmed, "event/")
if idx := strings.Index(trimmed, "?"); idx >= 0 {
trimmed = trimmed[:idx]
}
if idx := strings.Index(trimmed, "/"); idx >= 0 {
trimmed = trimmed[:idx]
}
return trimmed
}
func (s *Session) setBaseHeaders(req *http.Request, accessToken string) {
req.Header.Set("User-Agent", s.Device.UserAgent)
req.Header.Set("Origin", Origin)
req.Header.Set("Referer", Origin+"/")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "en")
req.Header.Set("X-Request-Id", uuid.New().String())
for name, value := range s.Device.Headers() {
req.Header.Set(name, value)
}
if accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
}
func (s *Session) callRefreshOnce() (*GuestAuthResponse, error) {
req, err := http.NewRequest(http.MethodPost, APIBase+"/platform/v1/auth/refresh/web", bytes.NewReader(nil))
if err != nil {
return nil, err
}
s.setBaseHeaders(req, "")
req.Header.Set("Content-Length", "0")
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("auth/refresh/web: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return nil, fmt.Errorf("%w: status %d: %s", ErrSessionExpired, resp.StatusCode, string(raw))
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("auth/refresh/web: status %d: %s", resp.StatusCode, string(raw))
}
var auth GuestAuthResponse
if err := json.Unmarshal(raw, &auth); err != nil {
return nil, fmt.Errorf("auth/refresh/web decode: %w", err)
}
if auth.AccessToken == "" {
return nil, fmt.Errorf("auth/refresh/web: empty access_token: %s", string(raw))
}
return &auth, nil
}
func (s *Session) applyRefreshResult(auth *GuestAuthResponse) {
s.AccessToken = auth.AccessToken
s.UserID = auth.User.ID
if exp, err := parseJWTExpiry(auth.AccessToken); err == nil {
s.AccessTokenExp = exp
}
s.SetCookieInJar(accessCookieName, auth.AccessToken)
}
func (s *Session) refreshLocked() error {
if !s.HasRefreshCookie() && s.HasCredentials() {
return s.LoginWithPassword(s.email, s.password)
}
var lastErr error
delay := refreshBaseDelay
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
auth, err := s.callRefreshOnce()
if err == nil {
s.applyRefreshResult(auth)
return nil
}
if errors.Is(err, ErrSessionExpired) {
if s.HasCredentials() {
return s.LoginWithPassword(s.email, s.password)
}
return err
}
lastErr = err
if attempt < refreshMaxAttempts {
time.Sleep(delay)
delay = time.Duration(float64(delay) * refreshDelayMultiply)
}
}
return fmt.Errorf("refresh failed after %d attempts: %w", refreshMaxAttempts, lastErr)
}
func parseJWTExpiry(token string) (time.Time, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return time.Time{}, fmt.Errorf("invalid JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return time.Time{}, fmt.Errorf("decode payload: %w", err)
}
var claims struct {
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return time.Time{}, fmt.Errorf("parse claims: %w", err)
}
if claims.Exp == 0 {
return time.Time{}, fmt.Errorf("no exp claim")
}
return time.Unix(claims.Exp, 0), nil
}