| 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 : |
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
}