| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484 |
- package handlers
- import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "log/slog"
- "net/http"
- "strconv"
- "strings"
- "time"
- "github.com/mk6i/open-oscar-server/config"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- // roomNameMaxLen bounds the room name, matching the client's 45-char truncation
- // of the friendly field on imserv/create.
- const roomNameMaxLen = 45
- // ChatNavService creates chat rooms and serves room metadata for the imserv
- // endpoints. Backed by foodgroup.ChatNavService (the same instance BOS and TOC
- // use), so web-created rooms are real OSCAR rooms.
- type ChatNavService interface {
- CreateRoom(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)
- RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)
- }
- // ChatBridgeOServiceService issues the OSCAR service handoff that joining a chat
- // room requires: ServiceRequest yields the chat login cookie, and ClientOnline
- // brings the chat session online (which triggers the member list and arrival
- // announcement).
- type ChatBridgeOServiceService interface {
- ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)
- ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
- }
- // ChatParticipants lists the current participants of a chat room, keyed by the
- // room cookie. Backed by the shared chat session manager (the ChatMessageRelayer).
- type ChatParticipants interface {
- AllSessions(chatCookie string) []*state.Session
- }
- // ChatAuthService registers the dedicated chat SessionInstance a room join needs.
- // CrackCookie decodes the chat login cookie from ServiceRequest; RegisterChatSession
- // creates the chat session; SignoutChat is its close hook.
- type ChatAuthService interface {
- CrackCookie(authCookie []byte) (state.ServerCookie, error)
- RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, cfg func(sess *state.Session)) (*state.SessionInstance, error)
- SignoutChat(ctx context.Context, sess *state.Session)
- }
- // ImservHandler serves the Web AIM group-chat (imserv) endpoints by bridging them
- // onto the OSCAR chat food group. It mirrors the TOC chat bridge
- // (server/toc/cmd_client.go ChatJoin/ChatLeave): each joined room gets a
- // dedicated chat SessionInstance registered on the WebAPISession, and relayed
- // chat SNACs surface through that session's event queue.
- type ImservHandler struct {
- SessionManager *state.WebAPISessionManager
- ChatNavService ChatNavService
- OServiceService ChatBridgeOServiceService
- AuthService ChatAuthService
- ICBMService ICBMService
- Participants ChatParticipants
- Logger *slog.Logger
- }
- // Create handles GET/POST imserv/create. It creates (exchange 4) a user room
- // named by the friendly param and returns the room id (the ChatRoom cookie) the
- // client will use to join and to address messages. It does not auto-join; the
- // client calls imserv/join separately.
- func (h *ImservHandler) Create(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- ctx := r.Context()
- name := queryOrFormParam(r, "friendly")
- if name == "" {
- SendError(w, http.StatusBadRequest, "missing required parameter: friendly")
- return
- }
- if len(name) > roomNameMaxLen {
- name = name[:roomNameMaxLen]
- }
- inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
- Exchange: state.PrivateExchange,
- Cookie: "create",
- TLVBlock: wire.TLVBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.ChatRoomTLVRoomName, name),
- },
- },
- }
- reply, err := h.ChatNavService.CreateRoom(ctx, sess.OSCARSession, wire.SNACFrame{}, inBody)
- if err != nil {
- h.Logger.ErrorContext(ctx, "failed to create chat room", "err", err.Error())
- SendError(w, http.StatusInternalServerError, "failed to create chat room")
- return
- }
- room, ok := roomInfoFromNavReply(reply)
- if !ok {
- h.Logger.ErrorContext(ctx, "create room reply missing room info")
- SendError(w, http.StatusInternalServerError, "failed to create chat room")
- return
- }
- // Auto-join the creator: the web client's "start group chat" flow creates a
- // room and immediately sends to it without a separate join, so the creator
- // must already be a participant or im/sendIM would fall through to the 1:1
- // path. joinRoom is idempotent, so a client that does call join later is fine.
- if err := h.joinRoom(ctx, sess, room.Cookie); err != nil {
- h.Logger.ErrorContext(ctx, "failed to auto-join created room", "room", room.Cookie, "err", err.Error())
- SendError(w, http.StatusInternalServerError, "failed to join created chat room")
- return
- }
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- resp.Response.Data = roomData(room.Cookie, name)
- SendResponse(w, r, resp, h.Logger)
- h.Logger.InfoContext(ctx, "chat room created and joined",
- "screenName", sess.ScreenName.String(),
- "room", room.Cookie,
- "name", name,
- )
- }
- // Join handles GET/POST imserv/join. It runs the OSCAR chat-join handoff for the
- // room named by the imserv param, registers the resulting chat session on the web
- // session, and starts a listener so relayed room messages surface via fetchEvents.
- func (h *ImservHandler) Join(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- ctx := r.Context()
- roomID := queryOrFormParam(r, "imserv")
- if roomID == "" {
- SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
- return
- }
- // Already joined: return the room info without a second handoff.
- if _, joined := sess.ChatRoom(roomID); joined {
- h.sendJoinedResponse(w, r, roomID, "")
- return
- }
- // Resolve the room by cookie to confirm it exists and read its name. MVP
- // supports user-created rooms only (exchange 4).
- roomReply, err := h.ChatNavService.RequestRoomInfo(ctx, wire.SNACFrame{},
- wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
- Cookie: roomID,
- Exchange: state.PrivateExchange,
- })
- if err != nil {
- h.Logger.ErrorContext(ctx, "failed to look up chat room", "room", roomID, "err", err.Error())
- SendError(w, http.StatusNotFound, "chat room not found")
- return
- }
- room, ok := roomInfoFromNavReply(roomReply)
- if !ok {
- SendError(w, http.StatusNotFound, "chat room not found")
- return
- }
- if err := h.joinRoom(ctx, sess, room.Cookie); err != nil {
- h.Logger.ErrorContext(ctx, "failed to join chat room", "room", roomID, "err", err.Error())
- SendError(w, http.StatusInternalServerError, "failed to join chat room")
- return
- }
- h.sendJoinedResponse(w, r, room.Cookie, room.Name)
- h.Logger.InfoContext(ctx, "chat room joined",
- "screenName", sess.ScreenName.String(),
- "room", room.Cookie,
- )
- }
- // joinRoom runs the OSCAR chat handoff for a room cookie, registers the room's
- // chat session on the web session, and starts its listener so relayed room
- // messages surface via fetchEvents. It is idempotent: joining an
- // already-joined room is a no-op. Shared by Create (auto-join the creator) and
- // Join. Mirrors the tail of TOC's ChatJoin (server/toc/cmd_client.go).
- func (h *ImservHandler) joinRoom(ctx context.Context, sess *state.WebAPISession, cookie string) error {
- if _, joined := sess.ChatRoom(cookie); joined {
- return nil
- }
- chatSess, err := h.registerChatSession(ctx, sess, cookie)
- if err != nil {
- return err
- }
- // Register before ClientOnline so the arrival announcement and member list the
- // server sends in response are captured by the listener.
- if !sess.AddChatRoom(cookie, chatSess) {
- // Session is tearing down; undo the chat session we just created.
- chatSess.CloseInstance()
- return errors.New("session ended")
- }
- sess.StartListeningToChatSession(cookie, chatSess)
- if err := h.OServiceService.ClientOnline(ctx, wire.Chat, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
- if inst, removed := sess.RemoveChatRoom(cookie); removed {
- inst.CloseInstance()
- }
- return err
- }
- return nil
- }
- // Leave handles imserv/delete (and any explicit leave): it closes the room's chat
- // session, which announces the departure and unwinds its listener.
- func (h *ImservHandler) Leave(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- roomID := queryOrFormParam(r, "imserv")
- if roomID == "" {
- SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
- return
- }
- if inst, removed := sess.RemoveChatRoom(roomID); removed {
- inst.CloseInstance()
- }
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- SendResponse(w, r, resp, h.Logger)
- }
- // Invite handles imserv/invite: it sends a chat-room invitation to the buddy
- // named by t. The invite is an ICBM channel-2 CapChat rendezvous carrying the
- // room info, exactly like TOC's ChatInvite (server/toc/cmd_client.go). The
- // recipient's client surfaces it as an `im` event with specialData.invitation.
- func (h *ImservHandler) Invite(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- ctx := r.Context()
- roomID := queryOrFormParam(r, "imserv")
- invitee := queryOrFormParam(r, "t")
- if roomID == "" || invitee == "" {
- SendError(w, http.StatusBadRequest, "missing required parameter: imserv and t")
- return
- }
- exchange, instance, name, ok := parseRoomCookie(roomID)
- if !ok {
- SendError(w, http.StatusBadRequest, "invalid imserv room id")
- return
- }
- roomInfo := wire.ICBMRoomInfo{Exchange: exchange, Cookie: roomID, Instance: instance}
- prompt := fmt.Sprintf("%s would like you to join the chat room %q", sess.ScreenName.String(), name)
- snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
- ChannelID: wire.ICBMChannelRendezvous,
- ScreenName: invitee,
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
- Type: wire.ICBMRdvMessagePropose,
- Capability: wire.CapChat,
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(wire.ICBMRdvTLVTagsSeqNum, uint16(1)),
- wire.NewTLVBE(wire.ICBMRdvTLVTagsInvitation, prompt),
- wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMECharset, "us-ascii"),
- wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMELang, "en"),
- wire.NewTLVBE(wire.ICBMRdvTLVTagsSvcData, roomInfo),
- },
- },
- }),
- },
- },
- }
- if _, err := h.ICBMService.ChannelMsgToHost(ctx, sess.OSCARSession, wire.SNACFrame{}, snac); err != nil {
- h.Logger.ErrorContext(ctx, "failed to send chat invite", "room", roomID, "invitee", invitee, "err", err.Error())
- SendError(w, http.StatusInternalServerError, "failed to send invite")
- return
- }
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- SendResponse(w, r, resp, h.Logger)
- h.Logger.InfoContext(ctx, "chat invite sent",
- "screenName", sess.ScreenName.String(), "room", roomID, "invitee", invitee)
- }
- // GetMembers handles imserv/getMembers: it returns the room's current
- // participants. The client reads data.members (each {member, memberType}) to
- // populate the roster, excluding memberType "invite".
- func (h *ImservHandler) GetMembers(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- roomID := queryOrFormParam(r, "imserv")
- if roomID == "" {
- SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
- return
- }
- members := []map[string]interface{}{}
- for _, participant := range h.Participants.AllSessions(roomID) {
- members = append(members, map[string]interface{}{
- "member": participant.IdentScreenName().String(),
- "memberType": "member",
- })
- }
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- resp.Response.Data = map[string]interface{}{"members": members}
- SendResponse(w, r, resp, h.Logger)
- }
- // GetSettings handles imserv/getSettings: it returns a room's metadata. The
- // client blocks a group-chat entry's rendering (a permanent loading spinner)
- // until this returns 200 — its loader only flips "loaded" on a successful
- // getSettings. The client reads data.friendly (name; falls back to the raw id)
- // and data.memberCounts (used when the live roster is empty).
- func (h *ImservHandler) GetSettings(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- roomID := queryOrFormParam(r, "imserv")
- if roomID == "" {
- SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
- return
- }
- name := roomID
- if _, _, parsed, ok := parseRoomCookie(roomID); ok {
- name = parsed
- }
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- resp.Response.Data = map[string]interface{}{
- "imserv": roomID,
- "friendly": name,
- "memberCounts": len(h.Participants.AllSessions(roomID)),
- }
- SendResponse(w, r, resp, h.Logger)
- }
- // GetRecentActivity handles imserv/getRecentActivity: room message history. Not
- // on the render-blocking path (a 404 is tolerated), but implemented to avoid
- // noise. Room lines are not persisted yet, so this returns an empty activity list.
- func (h *ImservHandler) GetRecentActivity(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- roomID := queryOrFormParam(r, "imserv")
- if roomID == "" {
- SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
- return
- }
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- resp.Response.Data = map[string]interface{}{
- "imservActivities": []map[string]interface{}{
- {"imserv": roomID, "activities": []map[string]interface{}{}},
- },
- }
- SendResponse(w, r, resp, h.Logger)
- }
- // Reject handles imserv/reject: the invitee declines an invitation. For MVP this
- // is acknowledged without notifying the inviter.
- func (h *ImservHandler) Reject(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- SendResponse(w, r, resp, h.Logger)
- }
- // parseRoomCookie splits a chat room cookie of the form
- // "<exchange>-<instance>-<name>" into its parts. The name may contain "-", so
- // only the first two segments are parsed off.
- func parseRoomCookie(cookie string) (exchange, instance uint16, name string, ok bool) {
- parts := strings.SplitN(cookie, "-", 3)
- if len(parts) < 3 {
- return 0, 0, "", false
- }
- ex, err := strconv.ParseUint(parts[0], 10, 16)
- if err != nil {
- return 0, 0, "", false
- }
- inst, err := strconv.ParseUint(parts[1], 10, 16)
- if err != nil {
- return 0, 0, "", false
- }
- return uint16(ex), uint16(inst), parts[2], true
- }
- // registerChatSession runs the OSCAR chat handoff for a room cookie and returns
- // the newly created chat SessionInstance. Mirrors the tail of TOC's ChatJoin
- // (server/toc/cmd_client.go): ServiceRequest -> CrackCookie -> RegisterChatSession.
- func (h *ImservHandler) registerChatSession(ctx context.Context, sess *state.WebAPISession, cookie string) (*state.SessionInstance, error) {
- svcReq := wire.SNAC_0x01_0x04_OServiceServiceRequest{
- FoodGroup: wire.Chat,
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLVBE(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{Cookie: cookie}),
- },
- },
- }
- svcReply, err := h.OServiceService.ServiceRequest(ctx, wire.BOS, sess.OSCARSession, wire.SNACFrame{}, svcReq, config.Listener{})
- if err != nil {
- return nil, err
- }
- svcBody, ok := svcReply.Body.(wire.SNAC_0x01_0x05_OServiceServiceResponse)
- if !ok {
- return nil, errors.New("unexpected ServiceRequest response type")
- }
- loginCookie, ok := svcBody.Bytes(wire.OServiceTLVTagsLoginCookie)
- if !ok {
- return nil, errors.New("ServiceRequest response missing login cookie")
- }
- serverCookie, err := h.AuthService.CrackCookie(loginCookie)
- if err != nil {
- return nil, err
- }
- sessCfg := func(s *state.Session) {
- s.OnSessionClose(func() {
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
- h.AuthService.SignoutChat(ctx, s)
- })
- }
- return h.AuthService.RegisterChatSession(ctx, serverCookie, sessCfg)
- }
- // sendJoinedResponse writes a successful join envelope carrying the room id.
- func (h *ImservHandler) sendJoinedResponse(w http.ResponseWriter, r *http.Request, roomID, name string) {
- resp := BaseResponse{}
- resp.Response.StatusCode = 200
- resp.Response.StatusText = "OK"
- resp.Response.Data = roomData(roomID, name)
- SendResponse(w, r, resp, h.Logger)
- }
- // roomInfo carries the fields the imserv bridge needs from a chat room.
- type roomInfo struct {
- Cookie string
- Name string
- }
- // roomInfoFromNavReply extracts the room cookie and name from a ChatNav NavInfo
- // reply (the room info TLV wraps a ChatRoomInfoUpdate).
- func roomInfoFromNavReply(reply wire.SNACMessage) (roomInfo, bool) {
- body, ok := reply.Body.(wire.SNAC_0x0D_0x09_ChatNavNavInfo)
- if !ok {
- return roomInfo{}, false
- }
- buf, ok := body.Bytes(wire.ChatNavTLVRoomInfo)
- if !ok {
- return roomInfo{}, false
- }
- var room wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate
- if err := wire.UnmarshalBE(&room, bytes.NewReader(buf)); err != nil {
- return roomInfo{}, false
- }
- name, _ := room.String(wire.ChatRoomTLVRoomName)
- return roomInfo{Cookie: room.Cookie, Name: name}, true
- }
- // roomData builds the room object the client reads from create/join responses.
- // imserv is the id it uses as the im/sendIM target. The client reads the room's
- // display name from `group` (create callback `HG`); friendly is included as a
- // harmless alias.
- func roomData(roomID, name string) map[string]interface{} {
- data := map[string]interface{}{
- "imserv": roomID,
- }
- if name != "" {
- data["group"] = name
- data["friendly"] = name
- }
- return data
- }
|