sync
This commit is contained in:
@@ -215,6 +215,11 @@ func (b *Birc) doConnect() {
|
||||
// Sanitize nicks for RELAYMSG: replace IRC characters with special meanings with "-"
|
||||
func sanitizeNick(nick string) string {
|
||||
sanitize := func(r rune) rune {
|
||||
// Allow invisible characters used for preventing highlights
|
||||
// U+200B: zero-width space, U+2060: word joiner
|
||||
if r == '\u200B' || r == '\u2060' || r == '\x0F' {
|
||||
return r
|
||||
}
|
||||
if strings.ContainsRune("!+%@&#$:'\"?*,. ", r) {
|
||||
return '-'
|
||||
}
|
||||
@@ -229,12 +234,36 @@ func (b *Birc) doSend() {
|
||||
for msg := range b.Local {
|
||||
<-throttle.C
|
||||
username := msg.Username
|
||||
// Insert invisible characters into the actual username to prevent highlights
|
||||
// The username may already be formatted like "[protocol] <nick> " so we need to find
|
||||
// the actual nick part and modify that
|
||||
if len(msg.Username) > 0 {
|
||||
// Try to find the actual username within angle brackets <username>
|
||||
if strings.Contains(username, "<") && strings.Contains(username, ">") {
|
||||
startIdx := strings.Index(username, "<") + 1
|
||||
endIdx := strings.Index(username, ">")
|
||||
if startIdx < endIdx && endIdx <= len(username) {
|
||||
actualNick := username[startIdx:endIdx]
|
||||
if len(actualNick) > 1 {
|
||||
// Insert invisible characters after first character of actual nick
|
||||
modifiedNick := string(actualNick[0]) + "\u200B\u2060\x0F" + actualNick[1:]
|
||||
username = username[:startIdx] + modifiedNick + username[endIdx:]
|
||||
b.Log.Infof("Modified username: %q -> %q", msg.Username, username)
|
||||
}
|
||||
}
|
||||
} else if len(username) > 1 {
|
||||
// Fallback: no angle brackets, just modify the username directly
|
||||
username = string(username[0]) + "\u200B\u2060\x0F" + username[1:]
|
||||
b.Log.Infof("Modified username (no brackets): %q -> %q", msg.Username, username)
|
||||
}
|
||||
}
|
||||
// Optional support for the proposed RELAYMSG extension, described at
|
||||
// https://github.com/jlu5/ircv3-specifications/blob/master/extensions/relaymsg.md
|
||||
// nolint:nestif
|
||||
if (b.i.HasCapability("overdrivenetworks.com/relaymsg") || b.i.HasCapability("draft/relaymsg")) &&
|
||||
b.GetBool("UseRelayMsg") {
|
||||
username = sanitizeNick(username)
|
||||
b.Log.Infof("After sanitizeNick: %q (len=%d, bytes=%v)", username, len(username), []byte(username))
|
||||
text := msg.Text
|
||||
|
||||
// Work around girc chomping leading commas on single word messages?
|
||||
@@ -245,23 +274,24 @@ func (b *Birc) doSend() {
|
||||
if msg.Event == config.EventUserAction {
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :\x01ACTION %s\x01", msg.Channel, username, text) //nolint:errcheck
|
||||
} else {
|
||||
b.Log.Debugf("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
|
||||
b.Log.Infof("Sending RELAYMSG to channel %s: nick=%s", msg.Channel, username)
|
||||
b.i.Cmd.SendRawf("RELAYMSG %s %s :%s", msg.Channel, username, text) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
if b.GetBool("Colornicks") {
|
||||
checksum := crc32.ChecksumIEEE([]byte(msg.Username))
|
||||
colorCode := checksum%14 + 2 // quick fix - prevent white or black color codes
|
||||
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, msg.Username)
|
||||
username = fmt.Sprintf("\x03%02d%s\x0F", colorCode, username)
|
||||
}
|
||||
b.Log.Infof("Final username before send: %q (len=%d, bytes=%v)", username, len(username), []byte(username))
|
||||
switch msg.Event {
|
||||
case config.EventUserAction:
|
||||
b.i.Cmd.Action(msg.Channel, username+msg.Text)
|
||||
case config.EventNoticeIRC:
|
||||
b.Log.Debugf("Sending notice to channel %s", msg.Channel)
|
||||
b.Log.Infof("Sending notice to channel %s", msg.Channel)
|
||||
b.i.Cmd.Notice(msg.Channel, username+msg.Text)
|
||||
default:
|
||||
b.Log.Debugf("Sending to channel %s", msg.Channel)
|
||||
b.Log.Infof("Sending to channel %s", msg.Channel)
|
||||
b.i.Cmd.Message(msg.Channel, username+msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
139
bridge/jackbox/errors.go
Normal file
139
bridge/jackbox/errors.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package jackbox
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sentinel errors for common failure scenarios
|
||||
var (
|
||||
// ErrNotAuthenticated indicates the client is not authenticated
|
||||
ErrNotAuthenticated = errors.New("not authenticated")
|
||||
|
||||
// ErrAuthFailed indicates authentication failed
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
|
||||
// ErrConnectionLost indicates the WebSocket connection was lost
|
||||
ErrConnectionLost = errors.New("connection lost")
|
||||
|
||||
// ErrTokenExpired indicates the authentication token has expired
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
|
||||
// ErrInvalidResponse indicates an unexpected response from the API
|
||||
ErrInvalidResponse = errors.New("invalid response from API")
|
||||
|
||||
// ErrSessionNotFound indicates the specified session does not exist
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
|
||||
// ErrNotSubscribed indicates not subscribed to any session
|
||||
ErrNotSubscribed = errors.New("not subscribed to any session")
|
||||
)
|
||||
|
||||
// APIError represents an API-related error with context
|
||||
type APIError struct {
|
||||
Op string // Operation that failed (e.g., "vote", "get_session", "authenticate")
|
||||
StatusCode int // HTTP status code
|
||||
Message string // Error message from API
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.StatusCode > 0 {
|
||||
return fmt.Sprintf("API error during %s (HTTP %d): %s", e.Op, e.StatusCode, e.Message)
|
||||
}
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("API error during %s: %s (%v)", e.Op, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("API error during %s: %s", e.Op, e.Message)
|
||||
}
|
||||
|
||||
func (e *APIError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// WebSocketError represents a WebSocket-related error with context
|
||||
type WebSocketError struct {
|
||||
Op string // Operation that failed (e.g., "connect", "subscribe", "send")
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *WebSocketError) Error() string {
|
||||
return fmt.Sprintf("WebSocket error during %s: %v", e.Op, e.Err)
|
||||
}
|
||||
|
||||
func (e *WebSocketError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// SessionError represents a session-related error with context
|
||||
type SessionError struct {
|
||||
Op string // Operation that failed (e.g., "subscribe", "get_active")
|
||||
SessionID int // Session ID
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *SessionError) Error() string {
|
||||
return fmt.Sprintf("session error during %s for session %d: %v", e.Op, e.SessionID, e.Err)
|
||||
}
|
||||
|
||||
func (e *SessionError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// IsRetryable returns true if the error is transient and the operation should be retried
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known retryable errors
|
||||
if errors.Is(err, ErrConnectionLost) ||
|
||||
errors.Is(err, ErrTokenExpired) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for API errors with retryable status codes
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// 5xx errors are typically retryable
|
||||
if apiErr.StatusCode >= 500 && apiErr.StatusCode < 600 {
|
||||
return true
|
||||
}
|
||||
// 429 Too Many Requests is retryable
|
||||
if apiErr.StatusCode == 429 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket errors are generally retryable
|
||||
var wsErr *WebSocketError
|
||||
if errors.As(err, &wsErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsFatal returns true if the error is fatal and reconnection should not be attempted
|
||||
func IsFatal(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known fatal errors
|
||||
if errors.Is(err, ErrAuthFailed) ||
|
||||
errors.Is(err, ErrSessionNotFound) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for API errors with fatal status codes
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
// 4xx errors (except 429) are typically fatal
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != 429 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
396
bridge/kosmi/auth.go
Normal file
396
bridge/kosmi/auth.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// Token expiry buffer - refresh if token expires within this window
|
||||
tokenExpiryBuffer = 5 * time.Minute
|
||||
)
|
||||
|
||||
// AuthManager handles authentication with Kosmi
|
||||
type AuthManager struct {
|
||||
email string
|
||||
password string
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
refreshToken string
|
||||
userID string
|
||||
mu sync.RWMutex
|
||||
log *logrus.Entry
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewAuthManager creates a new authentication manager
|
||||
func NewAuthManager(email, password string, log *logrus.Entry) *AuthManager {
|
||||
return &AuthManager{
|
||||
email: email,
|
||||
password: password,
|
||||
log: log,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Login performs email/password authentication
|
||||
//
|
||||
// NOTE: The actual login API format needs to be verified through monitoring.
|
||||
// This implementation is based on common GraphQL patterns and may need adjustment.
|
||||
func (a *AuthManager) Login() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
a.log.Info("Logging in to Kosmi...")
|
||||
|
||||
// Prepare login mutation
|
||||
// Based on common GraphQL patterns, likely something like:
|
||||
// mutation { login(email: "...", password: "...") { token user { id displayName } } }
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
username
|
||||
}
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"email": a.email,
|
||||
"password": a.password,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(mutation)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "failed to marshal request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "failed to create request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "request failed",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 401 {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "invalid credentials",
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "failed to parse response",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract token and user info
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if login, ok := data["login"].(map[string]interface{}); ok {
|
||||
if token, ok := login["token"].(string); ok {
|
||||
a.token = token
|
||||
|
||||
// Extract refresh token if present
|
||||
if refreshToken, ok := login["refreshToken"].(string); ok {
|
||||
a.refreshToken = refreshToken
|
||||
}
|
||||
|
||||
// Calculate token expiry
|
||||
if expiresIn, ok := login["expiresIn"].(float64); ok {
|
||||
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
} else {
|
||||
// Default to 24 hours if not specified
|
||||
a.tokenExpiry = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// Extract user ID
|
||||
if user, ok := login["user"].(map[string]interface{}); ok {
|
||||
if userID, ok := user["id"].(string); ok {
|
||||
a.userID = userID
|
||||
}
|
||||
if displayName, ok := user["displayName"].(string); ok {
|
||||
a.log.Infof("Logged in as: %s", displayName)
|
||||
}
|
||||
}
|
||||
|
||||
a.log.Info("✅ Login successful")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for GraphQL errors
|
||||
if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 {
|
||||
if errObj, ok := errors[0].(map[string]interface{}); ok {
|
||||
if message, ok := errObj["message"].(string); ok {
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: message,
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &AuthError{
|
||||
Op: "login",
|
||||
Reason: "no token in response",
|
||||
Err: ErrAuthFailed,
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken returns a valid token, refreshing if necessary
|
||||
func (a *AuthManager) GetToken() (string, error) {
|
||||
a.mu.RLock()
|
||||
|
||||
// Check if token is still valid
|
||||
if a.token != "" && time.Now().Before(a.tokenExpiry.Add(-tokenExpiryBuffer)) {
|
||||
token := a.token
|
||||
a.mu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
|
||||
a.mu.RUnlock()
|
||||
|
||||
// Token is expired or about to expire, refresh it
|
||||
if a.refreshToken != "" {
|
||||
if err := a.RefreshToken(); err != nil {
|
||||
a.log.Warnf("Token refresh failed, attempting re-login: %v", err)
|
||||
if err := a.Login(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No refresh token, need to login again
|
||||
if err := a.Login(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.token, nil
|
||||
}
|
||||
|
||||
// RefreshToken renews the access token using the refresh token
|
||||
func (a *AuthManager) RefreshToken() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.refreshToken == "" {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "no refresh token available",
|
||||
Err: ErrTokenExpired,
|
||||
}
|
||||
}
|
||||
|
||||
a.log.Debug("Refreshing authentication token...")
|
||||
|
||||
// Prepare refresh mutation
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation RefreshToken($refreshToken: String!) {
|
||||
refreshToken(refreshToken: $refreshToken) {
|
||||
token
|
||||
refreshToken
|
||||
expiresIn
|
||||
}
|
||||
}`,
|
||||
"variables": map[string]interface{}{
|
||||
"refreshToken": a.refreshToken,
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(mutation)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "failed to marshal request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "failed to create request",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "request failed",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
Err: ErrTokenExpired,
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "failed to parse response",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract new token
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if refresh, ok := data["refreshToken"].(map[string]interface{}); ok {
|
||||
if token, ok := refresh["token"].(string); ok {
|
||||
a.token = token
|
||||
|
||||
// Update refresh token if provided
|
||||
if newRefreshToken, ok := refresh["refreshToken"].(string); ok {
|
||||
a.refreshToken = newRefreshToken
|
||||
}
|
||||
|
||||
// Update expiry
|
||||
if expiresIn, ok := refresh["expiresIn"].(float64); ok {
|
||||
a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
} else {
|
||||
a.tokenExpiry = time.Now().Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
a.log.Debug("✅ Token refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &AuthError{
|
||||
Op: "token_refresh",
|
||||
Reason: "no token in response",
|
||||
Err: ErrTokenExpired,
|
||||
}
|
||||
}
|
||||
|
||||
// Logout invalidates the current session
|
||||
func (a *AuthManager) Logout() error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.token == "" {
|
||||
return nil // Already logged out
|
||||
}
|
||||
|
||||
a.log.Info("Logging out from Kosmi...")
|
||||
|
||||
// Prepare logout mutation
|
||||
mutation := map[string]interface{}{
|
||||
"query": `mutation Logout {
|
||||
logout {
|
||||
ok
|
||||
}
|
||||
}`,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(mutation)
|
||||
if err != nil {
|
||||
a.log.Warnf("Failed to marshal logout request: %v", err)
|
||||
// Continue with local cleanup
|
||||
} else {
|
||||
req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Referer", "https://app.kosmi.io/")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token))
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
a.log.Warnf("Logout request failed: %v", err)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
a.log.Warnf("Logout returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear local state regardless of server response
|
||||
a.token = ""
|
||||
a.refreshToken = ""
|
||||
a.tokenExpiry = time.Time{}
|
||||
a.userID = ""
|
||||
|
||||
a.log.Info("✅ Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAuthenticated checks if we have a valid token
|
||||
func (a *AuthManager) IsAuthenticated() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.token != "" && time.Now().Before(a.tokenExpiry)
|
||||
}
|
||||
|
||||
// GetUserID returns the authenticated user's ID
|
||||
func (a *AuthManager) GetUserID() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.userID
|
||||
}
|
||||
|
||||
462
bridge/kosmi/browser_auth.go
Normal file
462
bridge/kosmi/browser_auth.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// Check token expiry 7 days before it expires
|
||||
tokenExpiryCheckBuffer = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// BrowserAuthManager handles automated browser-based authentication
|
||||
type BrowserAuthManager struct {
|
||||
email string
|
||||
password string
|
||||
token string
|
||||
tokenExpiry time.Time
|
||||
log *logrus.Entry
|
||||
lastCheckTime time.Time
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
// NewBrowserAuthManager creates a new browser-based authentication manager
|
||||
func NewBrowserAuthManager(email, password string, log *logrus.Entry) *BrowserAuthManager {
|
||||
return &BrowserAuthManager{
|
||||
email: email,
|
||||
password: password,
|
||||
log: log,
|
||||
checkInterval: 24 * time.Hour, // Check daily for token expiry
|
||||
}
|
||||
}
|
||||
|
||||
// GetToken returns a valid token, obtaining a new one via browser if needed
|
||||
func (b *BrowserAuthManager) GetToken() (string, error) {
|
||||
// Check if we need to obtain or refresh the token
|
||||
if b.token == "" || b.shouldRefreshToken() {
|
||||
b.log.Info("Obtaining authentication token via browser automation...")
|
||||
if err := b.loginViaBrowser(); err != nil {
|
||||
return "", &AuthError{
|
||||
Op: "browser_login",
|
||||
Reason: "failed to obtain token via browser",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.token, nil
|
||||
}
|
||||
|
||||
// shouldRefreshToken checks if the token needs to be refreshed
|
||||
func (b *BrowserAuthManager) shouldRefreshToken() bool {
|
||||
// No token yet
|
||||
if b.token == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Token expired or about to expire
|
||||
if time.Now().After(b.tokenExpiry.Add(-tokenExpiryCheckBuffer)) {
|
||||
b.log.Info("Token expired or expiring soon, will refresh")
|
||||
return true
|
||||
}
|
||||
|
||||
// Periodic check (daily) to verify token is still valid
|
||||
if time.Since(b.lastCheckTime) > b.checkInterval {
|
||||
b.log.Debug("Performing periodic token validity check")
|
||||
b.lastCheckTime = time.Now()
|
||||
// For now, we trust the expiry time. Could add a validation check here.
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// loginViaBrowser uses chromedp to automate login and extract token
|
||||
func (b *BrowserAuthManager) loginViaBrowser() error {
|
||||
// Set up Chrome options
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
)
|
||||
|
||||
// Create allocator context
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := chromedp.NewContext(allocCtx)
|
||||
defer cancel()
|
||||
|
||||
// Set a reasonable timeout for the entire login process
|
||||
ctx, cancel = context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var token string
|
||||
|
||||
// Run the browser automation tasks
|
||||
err := chromedp.Run(ctx,
|
||||
// Navigate to Kosmi
|
||||
chromedp.Navigate("https://app.kosmi.io"),
|
||||
|
||||
// Wait for page to load completely
|
||||
chromedp.WaitReady("body"),
|
||||
chromedp.Sleep(2*time.Second),
|
||||
|
||||
// Click Login button (find by text content using JS with error handling)
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// First, log what buttons we can see
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
b.log.Debugf("Found buttons: %v", buttonTexts)
|
||||
|
||||
var found bool
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
// Try both "Login" and "Log in"
|
||||
const btn = buttons.find(el => {
|
||||
const text = el.textContent.trim();
|
||||
return text === 'Login' || text === 'Log in';
|
||||
});
|
||||
if (btn) {
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`, &found).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("Login button not found (found buttons: %v)", buttonTexts)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login modal
|
||||
chromedp.Sleep(3*time.Second),
|
||||
|
||||
// Click "Login with Email" button
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
// Log what buttons we can see now
|
||||
var buttonTexts []string
|
||||
chromedp.Evaluate(`
|
||||
Array.from(document.querySelectorAll('button')).map(el => el.textContent.trim())
|
||||
`, &buttonTexts).Do(ctx)
|
||||
b.log.Debugf("After clicking Log in, found buttons: %v", buttonTexts)
|
||||
|
||||
var found bool
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const btn = Array.from(document.querySelectorAll('button')).find(el => el.textContent.includes('Email'));
|
||||
if (btn) {
|
||||
btn.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
`, &found).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("Login with Email button not found (found buttons: %v)", buttonTexts)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for email form
|
||||
chromedp.Sleep(3*time.Second),
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debug("Waiting for password input...")
|
||||
return nil
|
||||
}),
|
||||
chromedp.WaitVisible(`input[type="password"]`, chromedp.ByQuery),
|
||||
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debug("Password input found, preparing to fill form...")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Click on the email input to focus it
|
||||
chromedp.Click(`input[placeholder*="Email"], input[placeholder*="Username"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type email character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debugf("Typing email: %s", b.email)
|
||||
return chromedp.SendKeys(`input[placeholder*="Email"], input[placeholder*="Username"]`, b.email, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
// Click on the password input to focus it
|
||||
chromedp.Click(`input[type="password"]`, chromedp.ByQuery),
|
||||
chromedp.Sleep(200*time.Millisecond),
|
||||
|
||||
// Type password character by character
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debugf("Typing password (length: %d)", len(b.password))
|
||||
return chromedp.SendKeys(`input[type="password"]`, b.password, chromedp.ByQuery).Do(ctx)
|
||||
}),
|
||||
|
||||
// Verify password was filled correctly
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var actualLength int
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const passwordInput = document.querySelector('input[type="password"]');
|
||||
return passwordInput ? passwordInput.value.length : 0;
|
||||
})()
|
||||
`, &actualLength).Do(ctx)
|
||||
|
||||
b.log.Debugf("Password filled (actual length: %d, expected: %d)", actualLength, len(b.password))
|
||||
|
||||
if actualLength != len(b.password) {
|
||||
return fmt.Errorf("password length mismatch: got %d, expected %d", actualLength, len(b.password))
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
|
||||
chromedp.Sleep(500*time.Millisecond),
|
||||
|
||||
// Wait a moment for form validation
|
||||
chromedp.Sleep(1*time.Second),
|
||||
|
||||
// Click the login submit button (be very specific)
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
b.log.Debug("Attempting to click submit button...")
|
||||
var result string
|
||||
if err := chromedp.Evaluate(`
|
||||
(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
|
||||
// Find the submit button in the login form
|
||||
// It should be visible, enabled, and contain "Login" but not be the main nav button
|
||||
const submitBtn = buttons.find(el => {
|
||||
const text = el.textContent.trim();
|
||||
const isLoginBtn = text === 'Login' || text.startsWith('Login');
|
||||
const isEnabled = !el.disabled;
|
||||
const isVisible = el.offsetParent !== null;
|
||||
const isInForm = el.closest('form') !== null || el.closest('[role="dialog"]') !== null;
|
||||
|
||||
return isLoginBtn && isEnabled && isVisible && isInForm;
|
||||
});
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.click();
|
||||
return 'CLICKED: ' + submitBtn.textContent.trim();
|
||||
}
|
||||
|
||||
return 'NOT_FOUND';
|
||||
})()
|
||||
`, &result).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.log.Debugf("Submit button result: %s", result)
|
||||
|
||||
if result == "NOT_FOUND" {
|
||||
return fmt.Errorf("Login submit button not found or not clickable")
|
||||
}
|
||||
|
||||
b.log.Debug("Submit button clicked")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Wait for login to complete (page will reload/redirect)
|
||||
chromedp.Sleep(5*time.Second),
|
||||
|
||||
// Check if login succeeded by looking for error messages
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var errorText string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
const errorEl = document.querySelector('[role="alert"], .error, .alert-error');
|
||||
return errorEl ? errorEl.textContent : '';
|
||||
})()
|
||||
`, &errorText).Do(ctx)
|
||||
|
||||
if errorText != "" {
|
||||
return fmt.Errorf("login failed with error: %s", errorText)
|
||||
}
|
||||
|
||||
b.log.Debug("No error messages found, checking token...")
|
||||
return nil
|
||||
}),
|
||||
|
||||
// Extract token from localStorage
|
||||
chromedp.Evaluate(`localStorage.getItem('token')`, &token),
|
||||
|
||||
// Verify the token is not anonymous by checking if user info exists
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
var userInfo string
|
||||
chromedp.Evaluate(`
|
||||
(() => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return 'NO_TOKEN';
|
||||
|
||||
// Decode JWT payload (middle part)
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return 'INVALID_TOKEN';
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return JSON.stringify({
|
||||
sub: payload.sub,
|
||||
typ: payload.typ,
|
||||
isAnon: payload.sub ? false : true
|
||||
});
|
||||
} catch (e) {
|
||||
return 'ERROR: ' + e.message;
|
||||
}
|
||||
})()
|
||||
`, &userInfo).Do(ctx)
|
||||
|
||||
b.log.Debugf("Token info from browser: %s", userInfo)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("browser automation failed: %w", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return fmt.Errorf("no token found in localStorage after login")
|
||||
}
|
||||
|
||||
b.token = token
|
||||
b.log.Infof("✅ Successfully obtained token via browser automation")
|
||||
b.log.Infof(" Email used: %s", b.email)
|
||||
b.log.Infof(" Token (first 50 chars): %s...", token[:min(50, len(token))])
|
||||
b.log.Infof(" Token (last 50 chars): ...%s", token[max(0, len(token)-50):])
|
||||
|
||||
// Parse token to get expiry
|
||||
if err := b.parseTokenExpiry(); err != nil {
|
||||
b.log.Warnf("Failed to parse token expiry: %v", err)
|
||||
// Default to 1 year if we can't parse
|
||||
b.tokenExpiry = time.Now().Add(365 * 24 * time.Hour)
|
||||
}
|
||||
|
||||
b.lastCheckTime = time.Now()
|
||||
|
||||
expiresIn := time.Until(b.tokenExpiry)
|
||||
b.log.Infof("Token expires in: %v", expiresIn.Round(24*time.Hour))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTokenExpiry extracts the expiry time from the JWT token
|
||||
func (b *BrowserAuthManager) parseTokenExpiry() error {
|
||||
// JWT format: header.payload.signature
|
||||
parts := strings.Split(b.token, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("invalid JWT format")
|
||||
}
|
||||
|
||||
// Decode the payload (base64url without padding)
|
||||
payload := parts[1]
|
||||
// Add padding if needed
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
// Replace URL-safe characters
|
||||
payload = strings.ReplaceAll(payload, "-", "+")
|
||||
payload = strings.ReplaceAll(payload, "_", "/")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode JWT payload: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var claims struct {
|
||||
Exp int64 `json:"exp"`
|
||||
Sub string `json:"sub"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return fmt.Errorf("failed to parse JWT claims: %w", err)
|
||||
}
|
||||
|
||||
if claims.Exp == 0 {
|
||||
return fmt.Errorf("no expiry in token")
|
||||
}
|
||||
|
||||
b.tokenExpiry = time.Unix(claims.Exp, 0)
|
||||
b.log.Infof(" Token user ID (sub): %s", claims.Sub)
|
||||
b.log.Infof(" Token type (typ): %s", claims.Typ)
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// IsAuthenticated checks if we have a valid token
|
||||
func (b *BrowserAuthManager) IsAuthenticated() bool {
|
||||
return b.token != "" && time.Now().Before(b.tokenExpiry)
|
||||
}
|
||||
|
||||
// GetUserID returns the user ID from the token (if available)
|
||||
func (b *BrowserAuthManager) GetUserID() string {
|
||||
if b.token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(b.token, ".")
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload := parts[1]
|
||||
switch len(payload) % 4 {
|
||||
case 2:
|
||||
payload += "=="
|
||||
case 3:
|
||||
payload += "="
|
||||
}
|
||||
|
||||
payload = strings.ReplaceAll(payload, "-", "+")
|
||||
payload = strings.ReplaceAll(payload, "_", "/")
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
if err := json.Unmarshal(decoded, &claims); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return claims.Sub
|
||||
}
|
||||
|
||||
155
bridge/kosmi/errors.go
Normal file
155
bridge/kosmi/errors.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package bkosmi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sentinel errors for common failure scenarios
|
||||
var (
|
||||
// ErrNotConnected indicates the WebSocket connection is not established
|
||||
ErrNotConnected = errors.New("not connected to Kosmi")
|
||||
|
||||
// ErrAuthFailed indicates authentication with Kosmi failed
|
||||
ErrAuthFailed = errors.New("authentication failed")
|
||||
|
||||
// ErrConnectionLost indicates the WebSocket connection was lost
|
||||
ErrConnectionLost = errors.New("connection lost")
|
||||
|
||||
// ErrTokenExpired indicates the authentication token has expired
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
|
||||
// ErrInvalidRoomID indicates the room ID format is invalid
|
||||
ErrInvalidRoomID = errors.New("invalid room ID")
|
||||
|
||||
// ErrRoomNotFound indicates the specified room does not exist
|
||||
ErrRoomNotFound = errors.New("room not found")
|
||||
|
||||
// ErrMessageSendFailed indicates a message could not be sent
|
||||
ErrMessageSendFailed = errors.New("failed to send message")
|
||||
|
||||
// ErrSubscriptionFailed indicates subscription to room messages failed
|
||||
ErrSubscriptionFailed = errors.New("failed to subscribe to messages")
|
||||
|
||||
// ErrJoinRoomFailed indicates joining the room failed
|
||||
ErrJoinRoomFailed = errors.New("failed to join room")
|
||||
|
||||
// ErrConnectionTimeout indicates a connection timeout occurred
|
||||
ErrConnectionTimeout = errors.New("connection timeout")
|
||||
|
||||
// ErrInvalidResponse indicates an unexpected response from the server
|
||||
ErrInvalidResponse = errors.New("invalid response from server")
|
||||
)
|
||||
|
||||
// ConnectionError represents a connection-related error with context
|
||||
type ConnectionError struct {
|
||||
Op string // Operation that failed (e.g., "dial", "handshake", "subscribe")
|
||||
URL string // WebSocket URL
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *ConnectionError) Error() string {
|
||||
return fmt.Sprintf("connection error during %s to %s: %v", e.Op, e.URL, e.Err)
|
||||
}
|
||||
|
||||
func (e *ConnectionError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// AuthError represents an authentication-related error with context
|
||||
type AuthError struct {
|
||||
Op string // Operation that failed (e.g., "login", "token_refresh", "anonymous_login")
|
||||
Reason string // Human-readable reason
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("auth error during %s: %s (%v)", e.Op, e.Reason, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("auth error during %s: %s", e.Op, e.Reason)
|
||||
}
|
||||
|
||||
func (e *AuthError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// MessageError represents a message-related error with context
|
||||
type MessageError struct {
|
||||
Op string // Operation that failed (e.g., "send", "receive", "parse")
|
||||
RoomID string // Room ID
|
||||
Message string // Message content (truncated if long)
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *MessageError) Error() string {
|
||||
return fmt.Sprintf("message error during %s in room %s: %v", e.Op, e.RoomID, e.Err)
|
||||
}
|
||||
|
||||
func (e *MessageError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// RoomError represents a room-related error with context
|
||||
type RoomError struct {
|
||||
Op string // Operation that failed (e.g., "join", "leave", "subscribe")
|
||||
RoomID string // Room ID
|
||||
Err error // Underlying error
|
||||
}
|
||||
|
||||
func (e *RoomError) Error() string {
|
||||
return fmt.Sprintf("room error during %s for room %s: %v", e.Op, e.RoomID, e.Err)
|
||||
}
|
||||
|
||||
func (e *RoomError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// IsRetryable returns true if the error is transient and the operation should be retried
|
||||
func IsRetryable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known retryable errors
|
||||
if errors.Is(err, ErrConnectionLost) ||
|
||||
errors.Is(err, ErrConnectionTimeout) ||
|
||||
errors.Is(err, ErrTokenExpired) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for connection errors (usually retryable)
|
||||
var connErr *ConnectionError
|
||||
if errors.As(err, &connErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsFatal returns true if the error is fatal and reconnection should not be attempted
|
||||
func IsFatal(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for known fatal errors
|
||||
if errors.Is(err, ErrAuthFailed) ||
|
||||
errors.Is(err, ErrInvalidRoomID) ||
|
||||
errors.Is(err, ErrRoomNotFound) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for auth errors with specific reasons
|
||||
var authErr *AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
// Invalid credentials are fatal
|
||||
if authErr.Reason == "invalid credentials" ||
|
||||
authErr.Reason == "account not found" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user