meetroom.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. package main
  2. /*
  3. MeetRoom - Video conferencing backend endpoints
  4. author: tobychui / AI assisted
  5. HTTP + WebSocket wiring for the MeetRoom WebApp (web/MeetRoom). Room
  6. state lives in mod/meetroom; this file exposes it to logged-in users:
  7. POST /system/meetroom/create title=&password= --> {"roomid":"...","title":"..."}
  8. POST /system/meetroom/join roomid=&password= --> room info (pre-flight check)
  9. GET /system/meetroom/info?roomid=XXXXXXXXX --> {"exists":..,"protected":..}
  10. GET /system/meetroom/ws?roomid=&password= --> WebSocket signaling upgrade
  11. POST /system/meetroom/upload multipart (file) --> {"fileid":...}
  12. POST /system/meetroom/attachfile roomid=&password=&path= --> {"fileid":...}
  13. GET /system/meetroom/download?roomid=&password=&fileid=[&inline=1]
  14. GET /system/meetroom/end?roomid= --> host ends the meeting
  15. GET /system/meetroom/iceservers --> WebRTC ICE config
  16. Signaling protocol (JSON frames over the WebSocket):
  17. client -> server: {"type":"signal","to":peerid,"data":{...}} SDP/ICE relay
  18. {"type":"chat","text":"..."}
  19. {"type":"file","fileid":"..."} announce uploaded file
  20. {"type":"state","audio":b,"video":b,"screen":b,"hand":b}
  21. {"type":"attendance"} request the join/leave log
  22. {"type":"kick","to":peerid} host only
  23. {"type":"ping"} app-level heartbeat
  24. {"type":"end"} host only
  25. server -> client: {"type":"welcome",...}, {"type":"peer-join",...},
  26. {"type":"peer-leave",...,"kicked":b}, {"type":"signal","from":..},
  27. {"type":"chat",...}, {"type":"file",...},
  28. {"type":"state",...}, {"type":"attendance",...},
  29. {"type":"kicked"}, {"type":"pong"}, {"type":"room-closed"}
  30. Shared space integration: every room owns a mod/sharedspace space (its ID
  31. travels with the room). Chat and uploads mirror into the space with
  32. origin meetroom.OriginMeetRoom; items posted into the space by AGI
  33. scripts flow back through mrSpaceItemBroadcast as chat / file frames
  34. with peer ID 0 (a reserved ID no real participant ever gets).
  35. Liveness: the server sends a WebSocket protocol ping every
  36. mrPingInterval and enforces mrReadTimeout as a read deadline (refreshed
  37. by pongs and by any incoming frame), so a silently dead client is
  38. removed from the room after at most mrReadTimeout. The client keeps its
  39. own app-level ping/pong heartbeat and auto-reconnects with backoff when
  40. the socket drops (see web/MeetRoom/app.js).
  41. Media never touches the server: clients negotiate WebRTC peer-to-peer
  42. connections through this relay, using the same STUN/TURN configuration
  43. as Arozcast screen share (see cast.go acBuildICEConfig).
  44. */
  45. import (
  46. "encoding/json"
  47. "mime"
  48. "net/http"
  49. "net/url"
  50. "os"
  51. "path"
  52. "strings"
  53. "time"
  54. "github.com/gorilla/websocket"
  55. "imuslab.com/arozos/mod/meetroom"
  56. prout "imuslab.com/arozos/mod/prouter"
  57. "imuslab.com/arozos/mod/sharedspace"
  58. "imuslab.com/arozos/mod/utils"
  59. )
  60. const (
  61. mrMaxChatLength = 4000 // runes per chat message
  62. mrMaxSocketFrame = 512 << 10 // 512KB per signaling frame (SDP blobs included)
  63. mrSweepInterval = 30 * time.Second // idle room sweep cadence
  64. mrPingInterval = 20 * time.Second // server keepalive ping cadence on signaling sockets
  65. mrReadTimeout = 60 * time.Second // drop a participant whose socket goes silent this long
  66. mrWriteTimeout = 10 * time.Second // per-frame write deadline on signaling sockets
  67. )
  68. var (
  69. meetRoomManager *meetroom.Manager
  70. meetRoomUpgrader = websocket.Upgrader{
  71. ReadBufferSize: 4096,
  72. WriteBufferSize: 4096,
  73. CheckOrigin: func(r *http.Request) bool { return true },
  74. }
  75. )
  76. // mrPeerInfo is the participant descriptor shared with clients.
  77. type mrPeerInfo struct {
  78. PeerID int `json:"peerid"`
  79. Username string `json:"username"`
  80. IsHost bool `json:"isHost"`
  81. }
  82. // mrRoomInfo is the room descriptor shared with clients.
  83. type mrRoomInfo struct {
  84. ID string `json:"id"`
  85. DisplayID string `json:"displayid"`
  86. Title string `json:"title"`
  87. Host string `json:"host"`
  88. Protected bool `json:"protected"`
  89. CreatedAt int64 `json:"createdat"` // unix seconds; lets the client show the meeting duration
  90. }
  91. func mrDescribeRoom(room *meetroom.Room) mrRoomInfo {
  92. return mrRoomInfo{
  93. ID: room.ID,
  94. DisplayID: meetroom.FormatRoomID(room.ID),
  95. Title: room.Title,
  96. Host: room.Host,
  97. Protected: room.HasPassword(),
  98. CreatedAt: room.CreatedAt.Unix(),
  99. }
  100. }
  101. // mrMarshalOrDrop marshals v, returning nil when marshalling fails (the
  102. // frame is simply not sent - all inputs are server-constructed).
  103. func mrMarshalOrDrop(v interface{}) []byte {
  104. js, err := json.Marshal(v)
  105. if err != nil {
  106. return nil
  107. }
  108. return js
  109. }
  110. // mrEndMeeting broadcasts room-closed and tears the room down.
  111. func mrEndMeeting(roomID string) {
  112. room, ok := meetRoomManager.GetRoom(roomID)
  113. if !ok {
  114. return
  115. }
  116. room.Broadcast([]byte(`{"type":"room-closed"}`), -1)
  117. meetRoomManager.CloseRoom(roomID)
  118. }
  119. // mrAttendanceList renders a room's join/leave log for the client. leftat is
  120. // 0 while the participant is still connected.
  121. func mrAttendanceList(room *meetroom.Room) []map[string]interface{} {
  122. records := room.Attendance()
  123. list := make([]map[string]interface{}, 0, len(records))
  124. for _, record := range records {
  125. entry := map[string]interface{}{
  126. "username": record.Username,
  127. "peerid": record.PeerID, // lets the host target this attendee for a kick
  128. "joinedat": record.JoinedAt.Unix(),
  129. "present": record.Present(),
  130. "leftat": int64(0),
  131. }
  132. if !record.Present() {
  133. entry["leftat"] = record.LeftAt.Unix()
  134. }
  135. list = append(list, entry)
  136. }
  137. return list
  138. }
  139. // mrSpaceItemBroadcast pushes items posted into a room's shared space from
  140. // outside the room (e.g. by AGI scripts) into the meeting as live chat /
  141. // file frames. Peer ID 0 marks server-side senders: real participants are
  142. // numbered from 1.
  143. func mrSpaceItemBroadcast(room *meetroom.Room, item *sharedspace.Item) {
  144. if item.Type == sharedspace.ItemTypeText {
  145. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  146. "type": "chat",
  147. "from": 0,
  148. "username": item.Uploader,
  149. "text": item.Text,
  150. "time": item.CreatedAt.Unix(),
  151. }), -1)
  152. return
  153. }
  154. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  155. "type": "file",
  156. "from": 0,
  157. "username": item.Uploader,
  158. "fileid": item.ID,
  159. "name": item.Name,
  160. "size": item.Size,
  161. "time": item.CreatedAt.Unix(),
  162. }), -1)
  163. }
  164. // MeetRoomInit wires up the MeetRoom video conferencing endpoints.
  165. func MeetRoomInit() {
  166. meetRoomManager = meetroom.NewManager("")
  167. //Give every room a shared space (chat + files mirror into it, AGI
  168. //scripts post into it) and bridge external posts back into the meeting.
  169. if sharedSpaceManager != nil {
  170. meetRoomManager.BindSpaceManager(sharedSpaceManager)
  171. meetRoomManager.SetSpaceItemHandler(mrSpaceItemBroadcast)
  172. }
  173. //Sweep abandoned rooms so forgotten meetings do not accumulate
  174. go func() {
  175. ticker := time.NewTicker(mrSweepInterval)
  176. defer ticker.Stop()
  177. for range ticker.C {
  178. meetRoomManager.SweepIdleRooms(meetroom.DefaultEmptyIdle)
  179. }
  180. }()
  181. router := prout.NewModuleRouter(prout.RouterOption{
  182. ModuleName: "MeetRoom",
  183. AdminOnly: false,
  184. UserHandler: userHandler,
  185. DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
  186. errorHandlePermissionDenied(w, r)
  187. },
  188. })
  189. //Create a new meeting room; the creator becomes the host.
  190. router.HandleFunc("/system/meetroom/create", func(w http.ResponseWriter, r *http.Request) {
  191. userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
  192. if err != nil {
  193. utils.SendErrorResponse(w, "Not logged in")
  194. return
  195. }
  196. title, _ := utils.PostPara(r, "title")
  197. password, _ := utils.PostPara(r, "password")
  198. room := meetRoomManager.CreateRoom(userinfo.Username, title, password)
  199. js := mrMarshalOrDrop(map[string]interface{}{
  200. "roomid": room.ID,
  201. "displayid": meetroom.FormatRoomID(room.ID),
  202. "title": room.Title,
  203. })
  204. utils.SendJSONResponse(w, string(js))
  205. })
  206. //Pre-flight join check: validates room ID + password before the client
  207. //acquires media devices and opens the signaling socket.
  208. router.HandleFunc("/system/meetroom/join", func(w http.ResponseWriter, r *http.Request) {
  209. roomID, err := utils.PostPara(r, "roomid")
  210. if err != nil {
  211. utils.SendErrorResponse(w, "Missing room ID")
  212. return
  213. }
  214. password, _ := utils.PostPara(r, "password")
  215. room, err := meetRoomManager.ValidateJoin(meetroom.NormalizeRoomID(roomID), password)
  216. if err != nil {
  217. utils.SendErrorResponse(w, err.Error())
  218. return
  219. }
  220. js := mrMarshalOrDrop(map[string]interface{}{
  221. "room": mrDescribeRoom(room),
  222. "participants": room.ParticipantCount(),
  223. })
  224. utils.SendJSONResponse(w, string(js))
  225. })
  226. //Room existence probe for the lobby (no password required; reveals only
  227. //whether the room exists and needs a password).
  228. router.HandleFunc("/system/meetroom/info", func(w http.ResponseWriter, r *http.Request) {
  229. roomID, err := utils.GetPara(r, "roomid")
  230. if err != nil {
  231. utils.SendErrorResponse(w, "Missing room ID")
  232. return
  233. }
  234. room, ok := meetRoomManager.GetRoom(meetroom.NormalizeRoomID(roomID))
  235. if !ok {
  236. utils.SendJSONResponse(w, `{"exists":false}`)
  237. return
  238. }
  239. js := mrMarshalOrDrop(map[string]interface{}{
  240. "exists": true,
  241. "protected": room.HasPassword(),
  242. "title": room.Title,
  243. "participants": room.ParticipantCount(),
  244. })
  245. utils.SendJSONResponse(w, string(js))
  246. })
  247. //Host ends the meeting for everyone.
  248. router.HandleFunc("/system/meetroom/end", func(w http.ResponseWriter, r *http.Request) {
  249. userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
  250. if err != nil {
  251. utils.SendErrorResponse(w, "Not logged in")
  252. return
  253. }
  254. roomID, err := utils.GetPara(r, "roomid")
  255. if err != nil {
  256. utils.SendErrorResponse(w, "Missing room ID")
  257. return
  258. }
  259. room, ok := meetRoomManager.GetRoom(meetroom.NormalizeRoomID(roomID))
  260. if !ok {
  261. utils.SendErrorResponse(w, "Room not found")
  262. return
  263. }
  264. if room.Host != userinfo.Username {
  265. utils.SendErrorResponse(w, "Only the host can end the meeting")
  266. return
  267. }
  268. mrEndMeeting(room.ID)
  269. utils.SendOK(w)
  270. })
  271. //ICE servers for the WebRTC mesh - shares Arozcast's STUN/TURN setup so
  272. //the built-in TURN relay (System Settings > Screen Share Relay) also
  273. //carries MeetRoom calls across NAT.
  274. router.HandleFunc("/system/meetroom/iceservers", func(w http.ResponseWriter, r *http.Request) {
  275. identity := ""
  276. if userinfo, err := userHandler.GetUserInfoFromRequest(w, r); err == nil {
  277. identity = userinfo.Username
  278. }
  279. js := mrMarshalOrDrop(acBuildICEConfig(r, identity))
  280. if js == nil {
  281. utils.SendErrorResponse(w, "Failed to build ICE config")
  282. return
  283. }
  284. utils.SendJSONResponse(w, string(js))
  285. })
  286. //Attachment upload: multipart form with roomid, password and file.
  287. router.HandleFunc("/system/meetroom/upload", func(w http.ResponseWriter, r *http.Request) {
  288. userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
  289. if err != nil {
  290. utils.SendErrorResponse(w, "Not logged in")
  291. return
  292. }
  293. if err := r.ParseMultipartForm(32 << 20); err != nil {
  294. utils.SendErrorResponse(w, "Invalid upload")
  295. return
  296. }
  297. roomID := meetroom.NormalizeRoomID(r.FormValue("roomid"))
  298. password := r.FormValue("password")
  299. if _, err := meetRoomManager.ValidateJoin(roomID, password); err != nil {
  300. utils.SendErrorResponse(w, err.Error())
  301. return
  302. }
  303. file, header, err := r.FormFile("file")
  304. if err != nil {
  305. utils.SendErrorResponse(w, "Missing file")
  306. return
  307. }
  308. defer file.Close()
  309. attachment, err := meetRoomManager.SaveAttachment(roomID, header.Filename, userinfo.Username, file, meetroom.DefaultMaxUpload)
  310. if err != nil {
  311. utils.SendErrorResponse(w, err.Error())
  312. return
  313. }
  314. js := mrMarshalOrDrop(map[string]interface{}{
  315. "fileid": attachment.ID,
  316. "name": attachment.Name,
  317. "size": attachment.Size,
  318. })
  319. utils.SendJSONResponse(w, string(js))
  320. })
  321. //Attach a file the user already owns in their ArozOS storage, addressed by
  322. //virtual path (e.g. user:/Desktop/report.pdf), without a round-trip
  323. //download+reupload. The file is streamed straight from the user's file
  324. //system into the room's attachment store. Access is inherently scoped:
  325. //GetFileSystemHandlerFromVirtualPath only resolves storages the user can
  326. //reach, and the real path is translated for that user.
  327. router.HandleFunc("/system/meetroom/attachfile", func(w http.ResponseWriter, r *http.Request) {
  328. userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
  329. if err != nil {
  330. utils.SendErrorResponse(w, "Not logged in")
  331. return
  332. }
  333. roomID := meetroom.NormalizeRoomID(r.FormValue("roomid"))
  334. password := r.FormValue("password")
  335. if _, err := meetRoomManager.ValidateJoin(roomID, password); err != nil {
  336. utils.SendErrorResponse(w, err.Error())
  337. return
  338. }
  339. vpath, err := utils.PostPara(r, "path")
  340. if err != nil {
  341. utils.SendErrorResponse(w, "Missing file path")
  342. return
  343. }
  344. //Resolve the virtual path within the requesting user's file system
  345. fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath)
  346. if err != nil {
  347. utils.SendErrorResponse(w, "File not accessible")
  348. return
  349. }
  350. fshAbs := fsh.FileSystemAbstraction
  351. rpath, err := fshAbs.VirtualPathToRealPath(vpath, userinfo.Username)
  352. if err != nil {
  353. utils.SendErrorResponse(w, "Invalid file path")
  354. return
  355. }
  356. fileStat, err := fshAbs.Stat(rpath)
  357. if err != nil {
  358. utils.SendErrorResponse(w, "File not found")
  359. return
  360. }
  361. if fileStat.IsDir() {
  362. utils.SendErrorResponse(w, "Cannot attach a folder")
  363. return
  364. }
  365. stream, err := fshAbs.ReadStream(rpath)
  366. if err != nil {
  367. utils.SendErrorResponse(w, "Could not open the file")
  368. return
  369. }
  370. defer stream.Close()
  371. attachment, err := meetRoomManager.SaveAttachment(roomID, path.Base(vpath), userinfo.Username, stream, meetroom.DefaultMaxUpload)
  372. if err != nil {
  373. utils.SendErrorResponse(w, err.Error())
  374. return
  375. }
  376. js := mrMarshalOrDrop(map[string]interface{}{
  377. "fileid": attachment.ID,
  378. "name": attachment.Name,
  379. "size": attachment.Size,
  380. })
  381. utils.SendJSONResponse(w, string(js))
  382. })
  383. //Attachment download for room members.
  384. router.HandleFunc("/system/meetroom/download", func(w http.ResponseWriter, r *http.Request) {
  385. roomID := meetroom.NormalizeRoomID(r.URL.Query().Get("roomid"))
  386. password := r.URL.Query().Get("password")
  387. fileID, err := utils.GetPara(r, "fileid")
  388. if err != nil {
  389. utils.SendErrorResponse(w, "Missing file ID")
  390. return
  391. }
  392. if _, err := meetRoomManager.ValidateJoin(roomID, password); err != nil {
  393. utils.SendErrorResponse(w, err.Error())
  394. return
  395. }
  396. attachment, ok := meetRoomManager.GetAttachment(roomID, fileID)
  397. if !ok {
  398. http.NotFound(w, r)
  399. return
  400. }
  401. f, err := os.Open(attachment.DiskPath)
  402. if err != nil {
  403. http.NotFound(w, r)
  404. return
  405. }
  406. defer f.Close()
  407. //Serve with the original name; the ASCII fallback strips anything
  408. //that could break the header, the RFC 5987 form keeps unicode names.
  409. fallback := strings.Map(func(c rune) rune {
  410. if c < 32 || c == '"' || c == '\\' || c > 126 {
  411. return '_'
  412. }
  413. return c
  414. }, attachment.Name)
  415. //inline=1 lets the chat render images directly in the browser. Only
  416. //raster images may be served inline: anything else (notably SVG,
  417. //which can carry scripts) keeps the attachment disposition.
  418. disposition := "attachment"
  419. if r.URL.Query().Get("inline") == "1" && sharedspace.IsImageName(attachment.Name) {
  420. disposition = "inline"
  421. }
  422. w.Header().Set("Content-Disposition", disposition+"; filename=\""+fallback+"\"; filename*=UTF-8''"+url.PathEscape(attachment.Name))
  423. w.Header().Set("X-Content-Type-Options", "nosniff")
  424. if ctype := mime.TypeByExtension(strings.ToLower(filepathExt(attachment.Name))); ctype != "" {
  425. w.Header().Set("Content-Type", ctype)
  426. } else {
  427. w.Header().Set("Content-Type", "application/octet-stream")
  428. }
  429. http.ServeContent(w, r, "", time.Now(), f)
  430. })
  431. //WebSocket signaling relay.
  432. router.HandleFunc("/system/meetroom/ws", func(w http.ResponseWriter, r *http.Request) {
  433. userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
  434. if err != nil {
  435. http.Error(w, "Not logged in", http.StatusUnauthorized)
  436. return
  437. }
  438. roomID := meetroom.NormalizeRoomID(r.URL.Query().Get("roomid"))
  439. password := r.URL.Query().Get("password")
  440. room, err := meetRoomManager.ValidateJoin(roomID, password)
  441. if err != nil {
  442. http.Error(w, err.Error(), http.StatusForbidden)
  443. return
  444. }
  445. conn, err := meetRoomUpgrader.Upgrade(w, r, nil)
  446. if err != nil {
  447. return
  448. }
  449. conn.SetReadLimit(mrMaxSocketFrame)
  450. //Liveness: a client that goes silent (no frames and no pong
  451. //replies) past mrReadTimeout is dropped so it does not linger as a
  452. //ghost participant after a network failure.
  453. conn.SetReadDeadline(time.Now().Add(mrReadTimeout))
  454. conn.SetPongHandler(func(string) error {
  455. conn.SetReadDeadline(time.Now().Add(mrReadTimeout))
  456. return nil
  457. })
  458. participant, err := room.AddParticipant(userinfo.Username)
  459. if err != nil {
  460. conn.Close()
  461. return
  462. }
  463. //Writer: drain the send channel until it is closed, interleaving
  464. //keepalive pings, then hang up.
  465. go func() {
  466. pinger := time.NewTicker(mrPingInterval)
  467. defer pinger.Stop()
  468. defer conn.Close()
  469. for {
  470. select {
  471. case msg, ok := <-participant.Send:
  472. if !ok {
  473. return
  474. }
  475. conn.SetWriteDeadline(time.Now().Add(mrWriteTimeout))
  476. if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
  477. return
  478. }
  479. case <-pinger.C:
  480. conn.SetWriteDeadline(time.Now().Add(mrWriteTimeout))
  481. if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
  482. return
  483. }
  484. }
  485. }
  486. }()
  487. //Welcome frame: own identity, room descriptor and current peers.
  488. peers := []mrPeerInfo{}
  489. for _, p := range room.Participants() {
  490. if p.PeerID == participant.PeerID {
  491. continue
  492. }
  493. peers = append(peers, mrPeerInfo{PeerID: p.PeerID, Username: p.Username, IsHost: p.IsHost})
  494. }
  495. room.SendTo(participant.PeerID, mrMarshalOrDrop(map[string]interface{}{
  496. "type": "welcome",
  497. "peerid": participant.PeerID,
  498. "username": participant.Username,
  499. "isHost": participant.IsHost,
  500. "room": mrDescribeRoom(room),
  501. "peers": peers,
  502. }))
  503. //Announce the newcomer to everyone else.
  504. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  505. "type": "peer-join",
  506. "peer": mrPeerInfo{PeerID: participant.PeerID, Username: participant.Username, IsHost: participant.IsHost},
  507. }), participant.PeerID)
  508. defer func() {
  509. //The participant may already be gone if the room was closed.
  510. if _, stillHere := room.GetParticipant(participant.PeerID); stillHere {
  511. room.RemoveParticipant(participant.PeerID)
  512. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  513. "type": "peer-leave",
  514. "peerid": participant.PeerID,
  515. "username": participant.Username,
  516. }), -1)
  517. }
  518. participant.CloseSend()
  519. }()
  520. for {
  521. _, raw, err := conn.ReadMessage()
  522. if err != nil {
  523. return
  524. }
  525. conn.SetReadDeadline(time.Now().Add(mrReadTimeout))
  526. var frame struct {
  527. Type string `json:"type"`
  528. To int `json:"to"`
  529. Data json.RawMessage `json:"data"`
  530. Text string `json:"text"`
  531. FileID string `json:"fileid"`
  532. Audio bool `json:"audio"`
  533. Video bool `json:"video"`
  534. Screen bool `json:"screen"`
  535. Hand bool `json:"hand"`
  536. }
  537. if json.Unmarshal(raw, &frame) != nil {
  538. continue
  539. }
  540. room.Touch()
  541. switch frame.Type {
  542. case "signal":
  543. //SDP / ICE relay to a single peer
  544. room.SendTo(frame.To, mrMarshalOrDrop(map[string]interface{}{
  545. "type": "signal",
  546. "from": participant.PeerID,
  547. "data": frame.Data,
  548. }))
  549. case "chat":
  550. text := frame.Text
  551. if strings.TrimSpace(text) == "" {
  552. continue
  553. }
  554. if runes := []rune(text); len(runes) > mrMaxChatLength {
  555. text = string(runes[:mrMaxChatLength])
  556. }
  557. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  558. "type": "chat",
  559. "from": participant.PeerID,
  560. "username": participant.Username,
  561. "text": text,
  562. "time": time.Now().Unix(),
  563. }), -1)
  564. //Mirror into the room's shared space so AGI scripts can
  565. //read the conversation
  566. meetRoomManager.LogChat(room.ID, participant.Username, text)
  567. case "file":
  568. attachment, ok := meetRoomManager.GetAttachment(room.ID, frame.FileID)
  569. if !ok {
  570. continue
  571. }
  572. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  573. "type": "file",
  574. "from": participant.PeerID,
  575. "username": participant.Username,
  576. "fileid": attachment.ID,
  577. "name": attachment.Name,
  578. "size": attachment.Size,
  579. "time": time.Now().Unix(),
  580. }), -1)
  581. case "state":
  582. //Mic / camera / screen share / raised-hand indicator update
  583. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  584. "type": "state",
  585. "from": participant.PeerID,
  586. "audio": frame.Audio,
  587. "video": frame.Video,
  588. "screen": frame.Screen,
  589. "hand": frame.Hand,
  590. }), participant.PeerID)
  591. case "attendance":
  592. //Send the requester the room's join/leave log (the
  593. //participants side panel polls this on open and on
  594. //peer-join / peer-leave)
  595. room.SendTo(participant.PeerID, mrMarshalOrDrop(map[string]interface{}{
  596. "type": "attendance",
  597. "records": mrAttendanceList(room),
  598. }))
  599. case "ping":
  600. //App-level heartbeat: lets the client detect a half-dead
  601. //connection and trigger its auto-reconnect logic.
  602. room.SendTo(participant.PeerID, []byte(`{"type":"pong"}`))
  603. case "kick":
  604. //Host removes another participant. Order matters: the target
  605. //is told it was kicked (so its client stops auto-reconnecting)
  606. //while it is still subscribed, so the frame is queued and
  607. //drained before its socket drops; everyone is then told it
  608. //left; finally KickParticipant unregisters it and closes its
  609. //send channel, which ends its writer goroutine and hangs up.
  610. if !participant.IsHost || frame.To == participant.PeerID {
  611. continue
  612. }
  613. target, ok := room.GetParticipant(frame.To)
  614. if !ok || target.IsHost {
  615. continue
  616. }
  617. room.SendTo(target.PeerID, []byte(`{"type":"kicked"}`))
  618. //Everyone but the target hears the removal; the target gets the
  619. //dedicated "kicked" frame above instead of its own leave.
  620. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{
  621. "type": "peer-leave",
  622. "peerid": target.PeerID,
  623. "username": target.Username,
  624. "kicked": true,
  625. }), target.PeerID)
  626. room.KickParticipant(target.PeerID)
  627. case "end":
  628. if participant.IsHost {
  629. mrEndMeeting(room.ID)
  630. return
  631. }
  632. }
  633. }
  634. })
  635. }
  636. // filepathExt returns the extension of a display file name (which never
  637. // contains a path separator by the time it reaches the server).
  638. func filepathExt(name string) string {
  639. if idx := strings.LastIndex(name, "."); idx >= 0 {
  640. return name[idx:]
  641. }
  642. return ""
  643. }