403Webshell
Server IP : 157.230.181.24  /  Your IP : 216.73.217.11
Web Server : Apache/2.4.58 (Ubuntu)
System : Linux conductive 6.8.0-117-generic #117-Ubuntu SMP PREEMPT_DYNAMIC Tue May 5 19:26:24 UTC 2026 x86_64
User :  ( 1000)
PHP Version : 8.3.31
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /var/www/vhosts/ceagon/open-oscar-server/state/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/vhosts/ceagon/open-oscar-server/state/webapi_session.go
package state

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"log/slog"
	mrand "math/rand/v2"
	"strconv"
	"sync"
	"time"

	"github.com/mk6i/open-oscar-server/server/webapi/types"
	"github.com/mk6i/open-oscar-server/wire"
)

var (
	// ErrNoWebAPISession is returned when a WebAPI session is not found.
	ErrNoWebAPISession = errors.New("WebAPI session not found")
	// ErrWebAPISessionExpired is returned when a WebAPI session has expired.
	ErrWebAPISessionExpired = errors.New("WebAPI session expired")
	// ErrWebAPISessionManagerClosed is returned when a session is requested from
	// a manager that has been shut down.
	ErrWebAPISessionManagerClosed = errors.New("WebAPI session manager is shut down")
)

// Web API session lifecycle timeline.
//
// A web client keeps its session alive by long-polling GET /aim/fetchEvents.
// Every authenticated request touches the session (middleware.RequireSession
// calls TouchSession at request arrival), sliding expiry to now + the TTL. A
// single poll blocks for up to 60s (the fetchEvents long-poll cap) and the
// client waits ~500ms (TimeToNextFetch) before re-polling, so in steady state a
// healthy client touches the session at worst every ~60-65s once jitter is
// included. That worst-case touch interval is the floor the TTL must clear.
//
// If a client hangs up without calling endSession, its last touch was at its
// last poll: the session then expires webAPISessionTTL later and the reaper
// sweeps it within one webAPISessionReapInterval tick. So a silent client is
// removed (and its OSCAR session closed) within TTL + tick of going quiet.
const (
	// webAPISessionTTL bounds how long a session survives without a poll. It is
	// sized to absorb one missed poll cycle: ~60s for the normal cycle, ~60s for
	// the absorbed miss, plus ~20s of jitter margin. Two consecutive misses mean
	// the client is genuinely gone and the session is reaped.
	webAPISessionTTL = 150 * time.Second

	// webAPISessionReapInterval is how often the cleanup goroutine sweeps for
	// expired sessions (~TTL/5). A dead session lingers at most
	// webAPISessionTTL + webAPISessionReapInterval before removal.
	webAPISessionReapInterval = 30 * time.Second
)

// WebAPISession represents an active Web AIM API session.
type WebAPISession struct {
	AimSID              string                                         // Unique session ID for web client
	ScreenName          DisplayScreenName                              // User identity
	OSCARSession        *SessionInstance                               // Bridge to existing OSCAR session
	OSCARCookie         []byte                                         // OSCAR auth cookie for the startOSCARSession handoff
	BOSHost             string                                         // BOS host advertised to the web client
	BOSPort             int                                            // BOS port advertised to the web client
	UseSSL              bool                                           // Whether the handoff advertised an SSL BOS connection
	Events              []string                                       // Subscribed event types
	EventQueue          *types.EventQueue                              // Per-session event queue
	DevID               string                                         // Developer ID that created this session
	ClientName          string                                         // Client application name
	ClientVersion       string                                         // Client application version
	CreatedAt           time.Time                                      // SessionInstance creation time
	LastAccessed        time.Time                                      // Last activity time
	ExpiresAt           time.Time                                      // SessionInstance expiration time
	FetchTimeout        int                                            // Long-polling timeout in milliseconds
	TimeToNextFetch     int                                            // Suggested delay before next fetch
	RemoteAddr          string                                         // Client IP address
	TempBuddies         map[string]bool                                // Temporary buddies for this session only
	BuddyListRefresher  func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push buddylist event
	PermitDenyRefresher func(ctx context.Context) (interface{}, error) // Called on feedbag changes to push permitDeny event
	BuddyAliasLoader    func(ctx context.Context) (map[string]string, error)
	aliases             map[string]string // cached BuddyAliasLoader result, nil when unloaded or invalidated
	aliasMu             sync.Mutex
	imLog               map[string][]WebAPIStoredIM
	imLogMu             sync.Mutex
	logger              *slog.Logger // Logger for debugging
}

// IsExpired checks if the session has expired.
func (s *WebAPISession) IsExpired() bool {
	return time.Now().After(s.ExpiresAt)
}

// Aliases returns this session owner's private buddy aliases, keyed by normalized
// screen name. Aliases live in the owner's feedbag, so the map is loaded once and
// cached until a feedbag change invalidates it: a signon that brings a large buddy
// list online costs one feedbag query instead of one per buddy.
//
// The map is owned by the session and must not be mutated by callers.
//
// aliasMu is deliberately held across the load rather than released while the
// feedbag is queried. Another instance of the owner can rename a buddy mid-query,
// and its FeedbagUpdateItem SNAC invalidates this cache; if the load ran outside
// the lock, that query's pre-rename result could be stored *after* the
// invalidation and serve the old alias until the next feedbag change. Holding the
// lock makes the invalidation wait for the load and then win.
func (s *WebAPISession) Aliases(ctx context.Context) map[string]string {
	s.aliasMu.Lock()
	defer s.aliasMu.Unlock()

	// The loader is wired after the session is created, so an event arriving in
	// that window has no way to resolve aliases.
	if s.BuddyAliasLoader == nil {
		return nil
	}
	if s.aliases == nil {
		aliases, err := s.BuddyAliasLoader(ctx)
		if err != nil {
			s.logger.Error("failed to load buddy aliases", "err", err.Error())
			return nil
		}
		s.aliases = aliases
	}
	return s.aliases
}

// InvalidateAliases drops the cached alias map so the next Aliases call reloads it.
// Callers that change the owner's feedbag must call this: the feedbag service
// relays FeedbagUpdateItem only to the owner's *other* instances, so a session
// never sees a SNAC for its own writes.
func (s *WebAPISession) InvalidateAliases() {
	s.aliasMu.Lock()
	defer s.aliasMu.Unlock()
	s.aliases = nil
}

// aliasFor returns this session owner's private alias for buddy, or "" when none is
// set. The web client deletes the alias it holds whenever it merges a user map, so
// every event naming a buddy has to repeat it.
func (s *WebAPISession) aliasFor(buddy IdentScreenName) string {
	// Runs on the SNAC listener goroutine, which has no request context.
	return s.Aliases(context.Background())[buddy.String()]
}

// Touch updates the last accessed time and extends expiration if needed.
func (s *WebAPISession) Touch() {
	s.LastAccessed = time.Now()
	newExpiry := s.LastAccessed.Add(webAPISessionTTL)
	if newExpiry.After(s.ExpiresAt) {
		s.ExpiresAt = newExpiry
	}
}

// IsSubscribedTo checks if the session is subscribed to a specific event type.
func (s *WebAPISession) IsSubscribedTo(eventType string) bool {
	for _, event := range s.Events {
		if event == eventType {
			return true
		}
	}
	return false
}

// StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
// message channel and converts SNAC messages into WebAPI events.
func (s *WebAPISession) StartListeningToOSCARSession() {
	if s.OSCARSession == nil {
		return
	}

	// Start goroutine to listen for OSCAR messages
	go func() {
		msgCh := s.OSCARSession.ReceiveMessage()
		for {
			select {
			case msg, ok := <-msgCh:
				if !ok {
					// Channel closed, OSCAR session ended
					return
				}
				s.handleSNACMessage(msg)
			case <-s.OSCARSession.Closed():
				// OSCAR session closed
				return
			}
		}
	}()
}

// handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
func (s *WebAPISession) handleSNACMessage(msg wire.SNACMessage) {
	if s.EventQueue == nil {
		return
	}

	// Convert SNAC message to WebAPI events based on food group and subgroup
	switch msg.Frame.FoodGroup {
	case wire.ICBM:
		s.handleICBMMessage(msg)
	case wire.Buddy:
		s.handleBuddyMessage(msg)
	case wire.Feedbag:
		s.handleFeedbagMessage(msg)
	}
}

// handleICBMMessage handles ICBM (instant messaging) SNAC messages.
func (s *WebAPISession) handleICBMMessage(msg wire.SNACMessage) {
	switch msg.Frame.SubGroup {
	case wire.ICBMChannelMsgToClient:
		s.handleIncomingIM(msg)
	case wire.ICBMClientEvent:
		s.handleTypingNotification(msg)
	}
}

// handleIncomingIM handles incoming instant messages.
func (s *WebAPISession) handleIncomingIM(msg wire.SNACMessage) {
	if !s.IsSubscribedTo("im") {
		return
	}

	body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
	if !ok {
		return
	}

	// Extract message text from TLV data
	var messageText string
	if msgData, hasMsg := body.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
		if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
			messageText = text
		}
	}

	if messageText == "" {
		return
	}

	// Check if it's an auto-response (channel 2)
	autoResponse := body.ChannelID == 0x0002

	// msgId must be unique per delivered event. The OSCAR cookie is not a
	// reliable unique id (some clients reuse it across messages), and the web
	// client dedupes its conversation list by msgId, silently dropping any
	// collisions. Mint a fresh random id instead of reusing body.Cookie.
	msgID := strconv.FormatUint(mrand.Uint64(), 16)
	// SNAC user info carries the sender's display screen name. The web client
	// keys conversations and users by the normalized aimId and only renders
	// displayId, so the two forms must not be interchanged.
	partnerDisplay := body.ScreenName
	partnerAimID := NewIdentScreenName(partnerDisplay).String()
	nowSec := time.Now().Unix()
	s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, nowSec)

	// Create IM event
	imEvent := types.IMEvent{
		Source: types.UserInfo{
			AimID:     partnerAimID,
			DisplayID: partnerDisplay,
			Friendly:  s.aliasFor(NewIdentScreenName(partnerAimID)),
			UserType:  "aim",
			State:     "online",
		},
		Message:   messageText,
		MsgID:     msgID,
		Timestamp: float64(time.Now().Unix()),
		AutoResp:  autoResponse,
	}

	s.EventQueue.Push(types.EventTypeIM, imEvent)
	s.logger.Debug("delivered instant message",
		"from", partnerDisplay,
		"to", s.ScreenName)

	if s.IsSubscribedTo("conversation") {
		// unread is 0 here, not 1, because the "im" event pushed above already
		// causes the client to increment its own persisted per-buddy unread
		// tally. The "Recent chats" badge is the sum of that persisted tally and
		// this conversation's unreadCount, so sending 1 here would double-count
		// the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
		// which also passes 0.
		s.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
			types.ConversationEntry(
				partnerAimID,
				partnerDisplay,
				messageText,
				msgID,
				partnerAimID,
				false,
				0,
			),
		}))
	}
}

// handleTypingNotification handles typing notifications.
func (s *WebAPISession) handleTypingNotification(msg wire.SNACMessage) {
	if !s.IsSubscribedTo("typing") {
		return
	}

	body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
	if !ok {
		return
	}

	// Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
	var typingStatus string
	switch body.Event {
	case 0x0002:
		typingStatus = "typing"
	case 0x0001:
		typingStatus = "typed"
	default:
		typingStatus = "none"
	}

	typingEvent := types.TypingEvent{
		AimID:        NewIdentScreenName(body.ScreenName).String(),
		TypingStatus: typingStatus,
	}

	s.EventQueue.Push(types.EventTypeTyping, typingEvent)
}

// handleBuddyMessage handles buddy/presence SNAC messages.
func (s *WebAPISession) handleBuddyMessage(msg wire.SNACMessage) {
	switch msg.Frame.SubGroup {
	case wire.BuddyArrived:
		s.handleBuddyArrived(msg)
	case wire.BuddyDeparted:
		s.handleBuddyDeparted(msg)
	}
}

// handleBuddyArrived handles when a buddy comes online.
func (s *WebAPISession) handleBuddyArrived(msg wire.SNACMessage) {
	if !s.IsSubscribedTo("presence") {
		return
	}

	body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
	if !ok {
		return
	}

	stateStr := "online"
	// For BuddyArrived updates, infer presence state from the TLVUserInfo.
	// Away and invisible transitions are typically broadcast using BuddyArrived
	// with updated user flags/status bits, not BuddyDeparted.
	if body.IsInvisible() {
		stateStr = "offline"
	} else if body.IsAway() {
		stateStr = "away"
	} else if mask, ok := body.Uint32BE(wire.OServiceUserInfoStatus); ok {
		if mask&wire.OServiceUserStatusDND == wire.OServiceUserStatusDND {
			stateStr = "dnd"
		} else if mask&wire.OServiceUserStatusAway == wire.OServiceUserStatusAway {
			stateStr = "away"
		}
	}

	buddy := NewIdentScreenName(body.ScreenName)
	presenceEvent := types.PresenceEvent{
		AimID:    buddy.String(),
		Friendly: s.aliasFor(buddy),
		State:    stateStr,
		UserType: "aim",
	}

	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
}

// handleBuddyDeparted handles when a buddy goes offline.
func (s *WebAPISession) handleBuddyDeparted(msg wire.SNACMessage) {
	if !s.IsSubscribedTo("presence") {
		return
	}

	body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
	if !ok {
		return
	}

	buddy := NewIdentScreenName(body.ScreenName)
	presenceEvent := types.PresenceEvent{
		AimID:    buddy.String(),
		Friendly: s.aliasFor(buddy),
		State:    "offline",
		UserType: "aim",
	}

	s.EventQueue.Push(types.EventTypePresence, presenceEvent)
}

func (s *WebAPISession) handleFeedbagMessage(msg wire.SNACMessage) {
	switch msg.Frame.SubGroup {
	case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
		// A buddy item carries its alias, so any feedbag write can change the map.
		s.InvalidateAliases()

		if s.BuddyListRefresher != nil {
			groups, err := s.BuddyListRefresher(context.Background())
			if err != nil {
				s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
			} else {
				s.EventQueue.Push(types.EventTypeBuddyList, map[string]interface{}{"groups": groups})
			}
		}
		if msg.Frame.SubGroup == wire.FeedbagUpdateItem && s.PermitDenyRefresher != nil {
			body, ok := msg.Body.(wire.SNAC_0x13_0x09_FeedbagUpdateItem)
			if ok {
				for _, item := range body.Items {
					if item.ClassID == wire.FeedbagClassIDPermit ||
						item.ClassID == wire.FeedbagClassIDDeny ||
						item.ClassID == wire.FeedbagClassIdPdinfo {
						pdd, err := s.PermitDenyRefresher(context.Background())
						if err != nil {
							s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
						} else {
							s.EventQueue.Push(types.EventTypePermitDeny, pdd)
						}
						break
					}
				}
			}
		}
	}
}

// WebAPISessionManager manages Web API sessions with thread-safe operations.
// Construct it with NewWebAPISessionManager and drive its reaper with Run.
type WebAPISessionManager struct {
	sessions map[string]*WebAPISession // Keyed by aimsid
	mu       sync.RWMutex
	closed   bool // set by Shutdown; rejects new sessions and makes drain idempotent
}

// NewWebAPISessionManager creates a new WebAPI session manager. It does not start
// any goroutines; call Run to start reaping expired sessions.
func NewWebAPISessionManager() *WebAPISessionManager {
	return &WebAPISessionManager{
		sessions: make(map[string]*WebAPISession),
	}
}

// CreateSession creates a new WebAPI session.
func (m *WebAPISessionManager) CreateSession(ctx context.Context, screenName DisplayScreenName, devID string, events []string, oscarSession *SessionInstance, logger *slog.Logger) (*WebAPISession, error) {
	m.mu.Lock()
	defer m.mu.Unlock()

	// Refuse to create sessions once shut down: the reaper is stopped, so a
	// session added now would never be closed or reaped.
	if m.closed {
		return nil, ErrWebAPISessionManagerClosed
	}

	// Generate unique session ID
	aimsid, err := generateSessionID()
	if err != nil {
		return nil, err
	}

	now := time.Now()
	session := &WebAPISession{
		AimSID:          aimsid,
		ScreenName:      screenName,
		OSCARSession:    oscarSession,
		Events:          events,
		EventQueue:      types.NewEventQueue(1000), // Max 1000 events per session
		DevID:           devID,
		CreatedAt:       now,
		LastAccessed:    now,
		ExpiresAt:       now.Add(webAPISessionTTL),
		FetchTimeout:    60000, // 60 seconds default for better stability
		TimeToNextFetch: 500,   // 500ms suggested delay
		logger:          logger,
	}

	m.sessions[aimsid] = session

	// Start listening to OSCAR session message channel
	session.StartListeningToOSCARSession()

	return session, nil
}

// GetSession retrieves a session by aimsid.
func (m *WebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*WebAPISession, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()

	session, exists := m.sessions[aimsid]
	if !exists {
		return nil, ErrNoWebAPISession
	}

	if session.IsExpired() {
		return nil, ErrWebAPISessionExpired
	}

	return session, nil
}

// RemoveSession removes a session by aimsid.
func (m *WebAPISessionManager) RemoveSession(ctx context.Context, aimsid string) error {
	m.mu.Lock()

	session, exists := m.sessions[aimsid]
	if !exists {
		m.mu.Unlock()
		return ErrNoWebAPISession
	}

	delete(m.sessions, aimsid)
	m.mu.Unlock()

	// Tear down outside the lock: CloseInstance fans out to buddy-departed
	// broadcasts and signout, which we don't want to run under m.mu.
	session.EventQueue.Close()
	session.OSCARSession.CloseInstance()

	return nil
}

// TouchSession updates the last accessed time for a session.
func (m *WebAPISessionManager) TouchSession(ctx context.Context, aimsid string) error {
	m.mu.Lock()
	defer m.mu.Unlock()

	session, exists := m.sessions[aimsid]
	if !exists {
		return ErrNoWebAPISession
	}

	session.Touch()
	return nil
}

// Run reaps expired sessions on a fixed interval until ctx is cancelled. The
// caller owns the goroutine's lifecycle; typically launch it under the server's
// errgroup:
//
//	g.Go(func() error { mgr.Run(ctx); return nil })
func (m *WebAPISessionManager) Run(ctx context.Context) {
	ticker := time.NewTicker(webAPISessionReapInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			m.reapExpired()
		case <-ctx.Done():
			return
		}
	}
}

// reapExpired removes every expired session and tears it down.
func (m *WebAPISessionManager) reapExpired() {
	m.mu.Lock()
	now := time.Now()
	var expired []*WebAPISession
	for aimsid, session := range m.sessions {
		if now.After(session.ExpiresAt) {
			delete(m.sessions, aimsid)
			expired = append(expired, session)
		}
	}
	m.mu.Unlock()

	// Tear down outside the lock: CloseInstance fans out to buddy-departed
	// broadcasts and signout, which we don't want to run under m.mu.
	for _, session := range expired {
		session.EventQueue.Close()
		session.OSCARSession.CloseInstance()
	}
}

// Shutdown drains and closes all sessions and blocks further CreateSession
// calls. The reaper goroutine is stopped separately by cancelling the context
// passed to Run. Safe to call more than once.
func (m *WebAPISessionManager) Shutdown() {
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return
	}
	m.closed = true

	sessions := make([]*WebAPISession, 0, len(m.sessions))
	for _, session := range m.sessions {
		sessions = append(sessions, session)
	}
	// Clear all sessions
	m.sessions = make(map[string]*WebAPISession)
	m.mu.Unlock()

	// Tear down outside the lock: CloseInstance fans out to buddy-departed
	// broadcasts and signout, which we don't want to run under m.mu.
	for _, session := range sessions {
		session.EventQueue.Close()
		session.OSCARSession.CloseInstance()
	}
}

// generateSessionID creates a cryptographically secure session ID.
func generateSessionID() (string, error) {
	bytes := make([]byte, 32) // 256 bits
	if _, err := rand.Read(bytes); err != nil {
		return "", err
	}
	return hex.EncodeToString(bytes), nil
}

Youez - 2016 - github.com/yon3zu
LinuXploit