imserv.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. package handlers
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/mk6i/open-oscar-server/config"
  13. "github.com/mk6i/open-oscar-server/state"
  14. "github.com/mk6i/open-oscar-server/wire"
  15. )
  16. // roomNameMaxLen bounds the room name, matching the client's 45-char truncation
  17. // of the friendly field on imserv/create.
  18. const roomNameMaxLen = 45
  19. // ChatNavService creates chat rooms and serves room metadata for the imserv
  20. // endpoints. Backed by foodgroup.ChatNavService (the same instance BOS and TOC
  21. // use), so web-created rooms are real OSCAR rooms.
  22. type ChatNavService interface {
  23. CreateRoom(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error)
  24. RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error)
  25. }
  26. // ChatBridgeOServiceService issues the OSCAR service handoff that joining a chat
  27. // room requires: ServiceRequest yields the chat login cookie, and ClientOnline
  28. // brings the chat session online (which triggers the member list and arrival
  29. // announcement).
  30. type ChatBridgeOServiceService interface {
  31. ServiceRequest(ctx context.Context, service uint16, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x01_0x04_OServiceServiceRequest, listener config.Listener) (wire.SNACMessage, error)
  32. ClientOnline(ctx context.Context, service uint16, inBody wire.SNAC_0x01_0x02_OServiceClientOnline, instance *state.SessionInstance) error
  33. }
  34. // ChatParticipants lists the current participants of a chat room, keyed by the
  35. // room cookie. Backed by the shared chat session manager (the ChatMessageRelayer).
  36. type ChatParticipants interface {
  37. AllSessions(chatCookie string) []*state.Session
  38. }
  39. // ChatAuthService registers the dedicated chat SessionInstance a room join needs.
  40. // CrackCookie decodes the chat login cookie from ServiceRequest; RegisterChatSession
  41. // creates the chat session; SignoutChat is its close hook.
  42. type ChatAuthService interface {
  43. CrackCookie(authCookie []byte) (state.ServerCookie, error)
  44. RegisterChatSession(ctx context.Context, authCookie state.ServerCookie, cfg func(sess *state.Session)) (*state.SessionInstance, error)
  45. SignoutChat(ctx context.Context, sess *state.Session)
  46. }
  47. // ImservHandler serves the Web AIM group-chat (imserv) endpoints by bridging them
  48. // onto the OSCAR chat food group. It mirrors the TOC chat bridge
  49. // (server/toc/cmd_client.go ChatJoin/ChatLeave): each joined room gets a
  50. // dedicated chat SessionInstance registered on the WebAPISession, and relayed
  51. // chat SNACs surface through that session's event queue.
  52. type ImservHandler struct {
  53. SessionManager *state.WebAPISessionManager
  54. ChatNavService ChatNavService
  55. OServiceService ChatBridgeOServiceService
  56. AuthService ChatAuthService
  57. ICBMService ICBMService
  58. Participants ChatParticipants
  59. Logger *slog.Logger
  60. }
  61. // Create handles GET/POST imserv/create. It creates (exchange 4) a user room
  62. // named by the friendly param and returns the room id (the ChatRoom cookie) the
  63. // client will use to join and to address messages. It does not auto-join; the
  64. // client calls imserv/join separately.
  65. func (h *ImservHandler) Create(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  66. ctx := r.Context()
  67. name := queryOrFormParam(r, "friendly")
  68. if name == "" {
  69. SendError(w, http.StatusBadRequest, "missing required parameter: friendly")
  70. return
  71. }
  72. if len(name) > roomNameMaxLen {
  73. name = name[:roomNameMaxLen]
  74. }
  75. inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
  76. Exchange: state.PrivateExchange,
  77. Cookie: "create",
  78. TLVBlock: wire.TLVBlock{
  79. TLVList: wire.TLVList{
  80. wire.NewTLVBE(wire.ChatRoomTLVRoomName, name),
  81. },
  82. },
  83. }
  84. reply, err := h.ChatNavService.CreateRoom(ctx, sess.OSCARSession, wire.SNACFrame{}, inBody)
  85. if err != nil {
  86. h.Logger.ErrorContext(ctx, "failed to create chat room", "err", err.Error())
  87. SendError(w, http.StatusInternalServerError, "failed to create chat room")
  88. return
  89. }
  90. room, ok := roomInfoFromNavReply(reply)
  91. if !ok {
  92. h.Logger.ErrorContext(ctx, "create room reply missing room info")
  93. SendError(w, http.StatusInternalServerError, "failed to create chat room")
  94. return
  95. }
  96. // Auto-join the creator: the web client's "start group chat" flow creates a
  97. // room and immediately sends to it without a separate join, so the creator
  98. // must already be a participant or im/sendIM would fall through to the 1:1
  99. // path. joinRoom is idempotent, so a client that does call join later is fine.
  100. if err := h.joinRoom(ctx, sess, room.Cookie); err != nil {
  101. h.Logger.ErrorContext(ctx, "failed to auto-join created room", "room", room.Cookie, "err", err.Error())
  102. SendError(w, http.StatusInternalServerError, "failed to join created chat room")
  103. return
  104. }
  105. resp := BaseResponse{}
  106. resp.Response.StatusCode = 200
  107. resp.Response.StatusText = "OK"
  108. resp.Response.Data = roomData(room.Cookie, name)
  109. SendResponse(w, r, resp, h.Logger)
  110. h.Logger.InfoContext(ctx, "chat room created and joined",
  111. "screenName", sess.ScreenName.String(),
  112. "room", room.Cookie,
  113. "name", name,
  114. )
  115. }
  116. // Join handles GET/POST imserv/join. It runs the OSCAR chat-join handoff for the
  117. // room named by the imserv param, registers the resulting chat session on the web
  118. // session, and starts a listener so relayed room messages surface via fetchEvents.
  119. func (h *ImservHandler) Join(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  120. ctx := r.Context()
  121. roomID := queryOrFormParam(r, "imserv")
  122. if roomID == "" {
  123. SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
  124. return
  125. }
  126. // Already joined: return the room info without a second handoff.
  127. if _, joined := sess.ChatRoom(roomID); joined {
  128. h.sendJoinedResponse(w, r, roomID, "")
  129. return
  130. }
  131. // Resolve the room by cookie to confirm it exists and read its name. MVP
  132. // supports user-created rooms only (exchange 4).
  133. roomReply, err := h.ChatNavService.RequestRoomInfo(ctx, wire.SNACFrame{},
  134. wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{
  135. Cookie: roomID,
  136. Exchange: state.PrivateExchange,
  137. })
  138. if err != nil {
  139. h.Logger.ErrorContext(ctx, "failed to look up chat room", "room", roomID, "err", err.Error())
  140. SendError(w, http.StatusNotFound, "chat room not found")
  141. return
  142. }
  143. room, ok := roomInfoFromNavReply(roomReply)
  144. if !ok {
  145. SendError(w, http.StatusNotFound, "chat room not found")
  146. return
  147. }
  148. if err := h.joinRoom(ctx, sess, room.Cookie); err != nil {
  149. h.Logger.ErrorContext(ctx, "failed to join chat room", "room", roomID, "err", err.Error())
  150. SendError(w, http.StatusInternalServerError, "failed to join chat room")
  151. return
  152. }
  153. h.sendJoinedResponse(w, r, room.Cookie, room.Name)
  154. h.Logger.InfoContext(ctx, "chat room joined",
  155. "screenName", sess.ScreenName.String(),
  156. "room", room.Cookie,
  157. )
  158. }
  159. // joinRoom runs the OSCAR chat handoff for a room cookie, registers the room's
  160. // chat session on the web session, and starts its listener so relayed room
  161. // messages surface via fetchEvents. It is idempotent: joining an
  162. // already-joined room is a no-op. Shared by Create (auto-join the creator) and
  163. // Join. Mirrors the tail of TOC's ChatJoin (server/toc/cmd_client.go).
  164. func (h *ImservHandler) joinRoom(ctx context.Context, sess *state.WebAPISession, cookie string) error {
  165. if _, joined := sess.ChatRoom(cookie); joined {
  166. return nil
  167. }
  168. chatSess, err := h.registerChatSession(ctx, sess, cookie)
  169. if err != nil {
  170. return err
  171. }
  172. // Register before ClientOnline so the arrival announcement and member list the
  173. // server sends in response are captured by the listener.
  174. if !sess.AddChatRoom(cookie, chatSess) {
  175. // Session is tearing down; undo the chat session we just created.
  176. chatSess.CloseInstance()
  177. return errors.New("session ended")
  178. }
  179. sess.StartListeningToChatSession(cookie, chatSess)
  180. if err := h.OServiceService.ClientOnline(ctx, wire.Chat, wire.SNAC_0x01_0x02_OServiceClientOnline{}, chatSess); err != nil {
  181. if inst, removed := sess.RemoveChatRoom(cookie); removed {
  182. inst.CloseInstance()
  183. }
  184. return err
  185. }
  186. return nil
  187. }
  188. // Leave handles imserv/delete (and any explicit leave): it closes the room's chat
  189. // session, which announces the departure and unwinds its listener.
  190. func (h *ImservHandler) Leave(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  191. roomID := queryOrFormParam(r, "imserv")
  192. if roomID == "" {
  193. SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
  194. return
  195. }
  196. if inst, removed := sess.RemoveChatRoom(roomID); removed {
  197. inst.CloseInstance()
  198. }
  199. resp := BaseResponse{}
  200. resp.Response.StatusCode = 200
  201. resp.Response.StatusText = "OK"
  202. SendResponse(w, r, resp, h.Logger)
  203. }
  204. // Invite handles imserv/invite: it sends a chat-room invitation to the buddy
  205. // named by t. The invite is an ICBM channel-2 CapChat rendezvous carrying the
  206. // room info, exactly like TOC's ChatInvite (server/toc/cmd_client.go). The
  207. // recipient's client surfaces it as an `im` event with specialData.invitation.
  208. func (h *ImservHandler) Invite(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  209. ctx := r.Context()
  210. roomID := queryOrFormParam(r, "imserv")
  211. invitee := queryOrFormParam(r, "t")
  212. if roomID == "" || invitee == "" {
  213. SendError(w, http.StatusBadRequest, "missing required parameter: imserv and t")
  214. return
  215. }
  216. exchange, instance, name, ok := parseRoomCookie(roomID)
  217. if !ok {
  218. SendError(w, http.StatusBadRequest, "invalid imserv room id")
  219. return
  220. }
  221. roomInfo := wire.ICBMRoomInfo{Exchange: exchange, Cookie: roomID, Instance: instance}
  222. prompt := fmt.Sprintf("%s would like you to join the chat room %q", sess.ScreenName.String(), name)
  223. snac := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
  224. ChannelID: wire.ICBMChannelRendezvous,
  225. ScreenName: invitee,
  226. TLVRestBlock: wire.TLVRestBlock{
  227. TLVList: wire.TLVList{
  228. wire.NewTLVBE(wire.ICBMTLVData, wire.ICBMCh2Fragment{
  229. Type: wire.ICBMRdvMessagePropose,
  230. Capability: wire.CapChat,
  231. TLVRestBlock: wire.TLVRestBlock{
  232. TLVList: wire.TLVList{
  233. wire.NewTLVBE(wire.ICBMRdvTLVTagsSeqNum, uint16(1)),
  234. wire.NewTLVBE(wire.ICBMRdvTLVTagsInvitation, prompt),
  235. wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMECharset, "us-ascii"),
  236. wire.NewTLVBE(wire.ICBMRdvTLVTagsInviteMIMELang, "en"),
  237. wire.NewTLVBE(wire.ICBMRdvTLVTagsSvcData, roomInfo),
  238. },
  239. },
  240. }),
  241. },
  242. },
  243. }
  244. if _, err := h.ICBMService.ChannelMsgToHost(ctx, sess.OSCARSession, wire.SNACFrame{}, snac); err != nil {
  245. h.Logger.ErrorContext(ctx, "failed to send chat invite", "room", roomID, "invitee", invitee, "err", err.Error())
  246. SendError(w, http.StatusInternalServerError, "failed to send invite")
  247. return
  248. }
  249. resp := BaseResponse{}
  250. resp.Response.StatusCode = 200
  251. resp.Response.StatusText = "OK"
  252. SendResponse(w, r, resp, h.Logger)
  253. h.Logger.InfoContext(ctx, "chat invite sent",
  254. "screenName", sess.ScreenName.String(), "room", roomID, "invitee", invitee)
  255. }
  256. // GetMembers handles imserv/getMembers: it returns the room's current
  257. // participants. The client reads data.members (each {member, memberType}) to
  258. // populate the roster, excluding memberType "invite".
  259. func (h *ImservHandler) GetMembers(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  260. roomID := queryOrFormParam(r, "imserv")
  261. if roomID == "" {
  262. SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
  263. return
  264. }
  265. members := []map[string]interface{}{}
  266. for _, participant := range h.Participants.AllSessions(roomID) {
  267. members = append(members, map[string]interface{}{
  268. "member": participant.IdentScreenName().String(),
  269. "memberType": "member",
  270. })
  271. }
  272. resp := BaseResponse{}
  273. resp.Response.StatusCode = 200
  274. resp.Response.StatusText = "OK"
  275. resp.Response.Data = map[string]interface{}{"members": members}
  276. SendResponse(w, r, resp, h.Logger)
  277. }
  278. // GetSettings handles imserv/getSettings: it returns a room's metadata. The
  279. // client blocks a group-chat entry's rendering (a permanent loading spinner)
  280. // until this returns 200 — its loader only flips "loaded" on a successful
  281. // getSettings. The client reads data.friendly (name; falls back to the raw id)
  282. // and data.memberCounts (used when the live roster is empty).
  283. func (h *ImservHandler) GetSettings(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  284. roomID := queryOrFormParam(r, "imserv")
  285. if roomID == "" {
  286. SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
  287. return
  288. }
  289. name := roomID
  290. if _, _, parsed, ok := parseRoomCookie(roomID); ok {
  291. name = parsed
  292. }
  293. resp := BaseResponse{}
  294. resp.Response.StatusCode = 200
  295. resp.Response.StatusText = "OK"
  296. resp.Response.Data = map[string]interface{}{
  297. "imserv": roomID,
  298. "friendly": name,
  299. "memberCounts": len(h.Participants.AllSessions(roomID)),
  300. }
  301. SendResponse(w, r, resp, h.Logger)
  302. }
  303. // GetRecentActivity handles imserv/getRecentActivity: room message history. Not
  304. // on the render-blocking path (a 404 is tolerated), but implemented to avoid
  305. // noise. Room lines are not persisted yet, so this returns an empty activity list.
  306. func (h *ImservHandler) GetRecentActivity(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  307. roomID := queryOrFormParam(r, "imserv")
  308. if roomID == "" {
  309. SendError(w, http.StatusBadRequest, "missing required parameter: imserv")
  310. return
  311. }
  312. resp := BaseResponse{}
  313. resp.Response.StatusCode = 200
  314. resp.Response.StatusText = "OK"
  315. resp.Response.Data = map[string]interface{}{
  316. "imservActivities": []map[string]interface{}{
  317. {"imserv": roomID, "activities": []map[string]interface{}{}},
  318. },
  319. }
  320. SendResponse(w, r, resp, h.Logger)
  321. }
  322. // Reject handles imserv/reject: the invitee declines an invitation. For MVP this
  323. // is acknowledged without notifying the inviter.
  324. func (h *ImservHandler) Reject(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  325. resp := BaseResponse{}
  326. resp.Response.StatusCode = 200
  327. resp.Response.StatusText = "OK"
  328. SendResponse(w, r, resp, h.Logger)
  329. }
  330. // parseRoomCookie splits a chat room cookie of the form
  331. // "<exchange>-<instance>-<name>" into its parts. The name may contain "-", so
  332. // only the first two segments are parsed off.
  333. func parseRoomCookie(cookie string) (exchange, instance uint16, name string, ok bool) {
  334. parts := strings.SplitN(cookie, "-", 3)
  335. if len(parts) < 3 {
  336. return 0, 0, "", false
  337. }
  338. ex, err := strconv.ParseUint(parts[0], 10, 16)
  339. if err != nil {
  340. return 0, 0, "", false
  341. }
  342. inst, err := strconv.ParseUint(parts[1], 10, 16)
  343. if err != nil {
  344. return 0, 0, "", false
  345. }
  346. return uint16(ex), uint16(inst), parts[2], true
  347. }
  348. // registerChatSession runs the OSCAR chat handoff for a room cookie and returns
  349. // the newly created chat SessionInstance. Mirrors the tail of TOC's ChatJoin
  350. // (server/toc/cmd_client.go): ServiceRequest -> CrackCookie -> RegisterChatSession.
  351. func (h *ImservHandler) registerChatSession(ctx context.Context, sess *state.WebAPISession, cookie string) (*state.SessionInstance, error) {
  352. svcReq := wire.SNAC_0x01_0x04_OServiceServiceRequest{
  353. FoodGroup: wire.Chat,
  354. TLVRestBlock: wire.TLVRestBlock{
  355. TLVList: wire.TLVList{
  356. wire.NewTLVBE(0x01, wire.SNAC_0x01_0x04_TLVRoomInfo{Cookie: cookie}),
  357. },
  358. },
  359. }
  360. svcReply, err := h.OServiceService.ServiceRequest(ctx, wire.BOS, sess.OSCARSession, wire.SNACFrame{}, svcReq, config.Listener{})
  361. if err != nil {
  362. return nil, err
  363. }
  364. svcBody, ok := svcReply.Body.(wire.SNAC_0x01_0x05_OServiceServiceResponse)
  365. if !ok {
  366. return nil, errors.New("unexpected ServiceRequest response type")
  367. }
  368. loginCookie, ok := svcBody.Bytes(wire.OServiceTLVTagsLoginCookie)
  369. if !ok {
  370. return nil, errors.New("ServiceRequest response missing login cookie")
  371. }
  372. serverCookie, err := h.AuthService.CrackCookie(loginCookie)
  373. if err != nil {
  374. return nil, err
  375. }
  376. sessCfg := func(s *state.Session) {
  377. s.OnSessionClose(func() {
  378. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
  379. defer cancel()
  380. h.AuthService.SignoutChat(ctx, s)
  381. })
  382. }
  383. return h.AuthService.RegisterChatSession(ctx, serverCookie, sessCfg)
  384. }
  385. // sendJoinedResponse writes a successful join envelope carrying the room id.
  386. func (h *ImservHandler) sendJoinedResponse(w http.ResponseWriter, r *http.Request, roomID, name string) {
  387. resp := BaseResponse{}
  388. resp.Response.StatusCode = 200
  389. resp.Response.StatusText = "OK"
  390. resp.Response.Data = roomData(roomID, name)
  391. SendResponse(w, r, resp, h.Logger)
  392. }
  393. // roomInfo carries the fields the imserv bridge needs from a chat room.
  394. type roomInfo struct {
  395. Cookie string
  396. Name string
  397. }
  398. // roomInfoFromNavReply extracts the room cookie and name from a ChatNav NavInfo
  399. // reply (the room info TLV wraps a ChatRoomInfoUpdate).
  400. func roomInfoFromNavReply(reply wire.SNACMessage) (roomInfo, bool) {
  401. body, ok := reply.Body.(wire.SNAC_0x0D_0x09_ChatNavNavInfo)
  402. if !ok {
  403. return roomInfo{}, false
  404. }
  405. buf, ok := body.Bytes(wire.ChatNavTLVRoomInfo)
  406. if !ok {
  407. return roomInfo{}, false
  408. }
  409. var room wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate
  410. if err := wire.UnmarshalBE(&room, bytes.NewReader(buf)); err != nil {
  411. return roomInfo{}, false
  412. }
  413. name, _ := room.String(wire.ChatRoomTLVRoomName)
  414. return roomInfo{Cookie: room.Cookie, Name: name}, true
  415. }
  416. // roomData builds the room object the client reads from create/join responses.
  417. // imserv is the id it uses as the im/sendIM target. The client reads the room's
  418. // display name from `group` (create callback `HG`); friendly is included as a
  419. // harmless alias.
  420. func roomData(roomID, name string) map[string]interface{} {
  421. data := map[string]interface{}{
  422. "imserv": roomID,
  423. }
  424. if name != "" {
  425. data["group"] = name
  426. data["friendly"] = name
  427. }
  428. return data
  429. }