package main /* MeetRoom - Video conferencing backend endpoints author: tobychui / AI assisted HTTP + WebSocket wiring for the MeetRoom WebApp (web/MeetRoom). Room state lives in mod/meetroom; this file exposes it to logged-in users: POST /system/meetroom/create title=&password= --> {"roomid":"...","title":"..."} POST /system/meetroom/join roomid=&password= --> room info (pre-flight check) GET /system/meetroom/info?roomid=XXXXXXXXX --> {"exists":..,"protected":..} GET /system/meetroom/ws?roomid=&password= --> WebSocket signaling upgrade POST /system/meetroom/upload multipart (file) --> {"fileid":...} POST /system/meetroom/attachfile roomid=&password=&path= --> {"fileid":...} GET /system/meetroom/download?roomid=&password=&fileid=[&inline=1] GET /system/meetroom/end?roomid= --> host ends the meeting GET /system/meetroom/iceservers --> WebRTC ICE config Signaling protocol (JSON frames over the WebSocket): client -> server: {"type":"signal","to":peerid,"data":{...}} SDP/ICE relay {"type":"chat","text":"..."} {"type":"file","fileid":"..."} announce uploaded file {"type":"state","audio":b,"video":b,"screen":b,"hand":b} {"type":"attendance"} request the join/leave log {"type":"kick","to":peerid} host only {"type":"ping"} app-level heartbeat {"type":"end"} host only server -> client: {"type":"welcome",...}, {"type":"peer-join",...}, {"type":"peer-leave",...,"kicked":b}, {"type":"signal","from":..}, {"type":"chat",...}, {"type":"file",...}, {"type":"state",...}, {"type":"attendance",...}, {"type":"kicked"}, {"type":"pong"}, {"type":"room-closed"} Shared space integration: every room owns a mod/sharedspace space (its ID travels with the room). Chat and uploads mirror into the space with origin meetroom.OriginMeetRoom; items posted into the space by AGI scripts flow back through mrSpaceItemBroadcast as chat / file frames with peer ID 0 (a reserved ID no real participant ever gets). Liveness: the server sends a WebSocket protocol ping every mrPingInterval and enforces mrReadTimeout as a read deadline (refreshed by pongs and by any incoming frame), so a silently dead client is removed from the room after at most mrReadTimeout. The client keeps its own app-level ping/pong heartbeat and auto-reconnects with backoff when the socket drops (see web/MeetRoom/app.js). Media never touches the server: clients negotiate WebRTC peer-to-peer connections through this relay, using the same STUN/TURN configuration as Arozcast screen share (see cast.go acBuildICEConfig). */ import ( "encoding/json" "mime" "net/http" "net/url" "os" "path" "strings" "time" "github.com/gorilla/websocket" "imuslab.com/arozos/mod/meetroom" prout "imuslab.com/arozos/mod/prouter" "imuslab.com/arozos/mod/sharedspace" "imuslab.com/arozos/mod/utils" ) const ( mrMaxChatLength = 4000 // runes per chat message mrMaxSocketFrame = 512 << 10 // 512KB per signaling frame (SDP blobs included) mrSweepInterval = 30 * time.Second // idle room sweep cadence mrPingInterval = 20 * time.Second // server keepalive ping cadence on signaling sockets mrReadTimeout = 60 * time.Second // drop a participant whose socket goes silent this long mrWriteTimeout = 10 * time.Second // per-frame write deadline on signaling sockets ) var ( meetRoomManager *meetroom.Manager meetRoomUpgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { return true }, } ) // mrPeerInfo is the participant descriptor shared with clients. type mrPeerInfo struct { PeerID int `json:"peerid"` Username string `json:"username"` IsHost bool `json:"isHost"` } // mrRoomInfo is the room descriptor shared with clients. type mrRoomInfo struct { ID string `json:"id"` DisplayID string `json:"displayid"` Title string `json:"title"` Host string `json:"host"` Protected bool `json:"protected"` CreatedAt int64 `json:"createdat"` // unix seconds; lets the client show the meeting duration } func mrDescribeRoom(room *meetroom.Room) mrRoomInfo { return mrRoomInfo{ ID: room.ID, DisplayID: meetroom.FormatRoomID(room.ID), Title: room.Title, Host: room.Host, Protected: room.HasPassword(), CreatedAt: room.CreatedAt.Unix(), } } // mrMarshalOrDrop marshals v, returning nil when marshalling fails (the // frame is simply not sent - all inputs are server-constructed). func mrMarshalOrDrop(v interface{}) []byte { js, err := json.Marshal(v) if err != nil { return nil } return js } // mrEndMeeting broadcasts room-closed and tears the room down. func mrEndMeeting(roomID string) { room, ok := meetRoomManager.GetRoom(roomID) if !ok { return } room.Broadcast([]byte(`{"type":"room-closed"}`), -1) meetRoomManager.CloseRoom(roomID) } // mrAttendanceList renders a room's join/leave log for the client. leftat is // 0 while the participant is still connected. func mrAttendanceList(room *meetroom.Room) []map[string]interface{} { records := room.Attendance() list := make([]map[string]interface{}, 0, len(records)) for _, record := range records { entry := map[string]interface{}{ "username": record.Username, "peerid": record.PeerID, // lets the host target this attendee for a kick "joinedat": record.JoinedAt.Unix(), "present": record.Present(), "leftat": int64(0), } if !record.Present() { entry["leftat"] = record.LeftAt.Unix() } list = append(list, entry) } return list } // mrSpaceItemBroadcast pushes items posted into a room's shared space from // outside the room (e.g. by AGI scripts) into the meeting as live chat / // file frames. Peer ID 0 marks server-side senders: real participants are // numbered from 1. func mrSpaceItemBroadcast(room *meetroom.Room, item *sharedspace.Item) { if item.Type == sharedspace.ItemTypeText { room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "chat", "from": 0, "username": item.Uploader, "text": item.Text, "time": item.CreatedAt.Unix(), }), -1) return } room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "file", "from": 0, "username": item.Uploader, "fileid": item.ID, "name": item.Name, "size": item.Size, "time": item.CreatedAt.Unix(), }), -1) } // MeetRoomInit wires up the MeetRoom video conferencing endpoints. func MeetRoomInit() { meetRoomManager = meetroom.NewManager("") //Give every room a shared space (chat + files mirror into it, AGI //scripts post into it) and bridge external posts back into the meeting. if sharedSpaceManager != nil { meetRoomManager.BindSpaceManager(sharedSpaceManager) meetRoomManager.SetSpaceItemHandler(mrSpaceItemBroadcast) } //Sweep abandoned rooms so forgotten meetings do not accumulate go func() { ticker := time.NewTicker(mrSweepInterval) defer ticker.Stop() for range ticker.C { meetRoomManager.SweepIdleRooms(meetroom.DefaultEmptyIdle) } }() router := prout.NewModuleRouter(prout.RouterOption{ ModuleName: "MeetRoom", AdminOnly: false, UserHandler: userHandler, DeniedHandler: func(w http.ResponseWriter, r *http.Request) { errorHandlePermissionDenied(w, r) }, }) //Create a new meeting room; the creator becomes the host. router.HandleFunc("/system/meetroom/create", func(w http.ResponseWriter, r *http.Request) { userinfo, err := userHandler.GetUserInfoFromRequest(w, r) if err != nil { utils.SendErrorResponse(w, "Not logged in") return } title, _ := utils.PostPara(r, "title") password, _ := utils.PostPara(r, "password") room := meetRoomManager.CreateRoom(userinfo.Username, title, password) js := mrMarshalOrDrop(map[string]interface{}{ "roomid": room.ID, "displayid": meetroom.FormatRoomID(room.ID), "title": room.Title, }) utils.SendJSONResponse(w, string(js)) }) //Pre-flight join check: validates room ID + password before the client //acquires media devices and opens the signaling socket. router.HandleFunc("/system/meetroom/join", func(w http.ResponseWriter, r *http.Request) { roomID, err := utils.PostPara(r, "roomid") if err != nil { utils.SendErrorResponse(w, "Missing room ID") return } password, _ := utils.PostPara(r, "password") room, err := meetRoomManager.ValidateJoin(meetroom.NormalizeRoomID(roomID), password) if err != nil { utils.SendErrorResponse(w, err.Error()) return } js := mrMarshalOrDrop(map[string]interface{}{ "room": mrDescribeRoom(room), "participants": room.ParticipantCount(), }) utils.SendJSONResponse(w, string(js)) }) //Room existence probe for the lobby (no password required; reveals only //whether the room exists and needs a password). router.HandleFunc("/system/meetroom/info", func(w http.ResponseWriter, r *http.Request) { roomID, err := utils.GetPara(r, "roomid") if err != nil { utils.SendErrorResponse(w, "Missing room ID") return } room, ok := meetRoomManager.GetRoom(meetroom.NormalizeRoomID(roomID)) if !ok { utils.SendJSONResponse(w, `{"exists":false}`) return } js := mrMarshalOrDrop(map[string]interface{}{ "exists": true, "protected": room.HasPassword(), "title": room.Title, "participants": room.ParticipantCount(), }) utils.SendJSONResponse(w, string(js)) }) //Host ends the meeting for everyone. router.HandleFunc("/system/meetroom/end", func(w http.ResponseWriter, r *http.Request) { userinfo, err := userHandler.GetUserInfoFromRequest(w, r) if err != nil { utils.SendErrorResponse(w, "Not logged in") return } roomID, err := utils.GetPara(r, "roomid") if err != nil { utils.SendErrorResponse(w, "Missing room ID") return } room, ok := meetRoomManager.GetRoom(meetroom.NormalizeRoomID(roomID)) if !ok { utils.SendErrorResponse(w, "Room not found") return } if room.Host != userinfo.Username { utils.SendErrorResponse(w, "Only the host can end the meeting") return } mrEndMeeting(room.ID) utils.SendOK(w) }) //ICE servers for the WebRTC mesh - shares Arozcast's STUN/TURN setup so //the built-in TURN relay (System Settings > Screen Share Relay) also //carries MeetRoom calls across NAT. router.HandleFunc("/system/meetroom/iceservers", func(w http.ResponseWriter, r *http.Request) { identity := "" if userinfo, err := userHandler.GetUserInfoFromRequest(w, r); err == nil { identity = userinfo.Username } js := mrMarshalOrDrop(acBuildICEConfig(r, identity)) if js == nil { utils.SendErrorResponse(w, "Failed to build ICE config") return } utils.SendJSONResponse(w, string(js)) }) //Attachment upload: multipart form with roomid, password and file. router.HandleFunc("/system/meetroom/upload", func(w http.ResponseWriter, r *http.Request) { userinfo, err := userHandler.GetUserInfoFromRequest(w, r) if err != nil { utils.SendErrorResponse(w, "Not logged in") return } if err := r.ParseMultipartForm(32 << 20); err != nil { utils.SendErrorResponse(w, "Invalid upload") return } roomID := meetroom.NormalizeRoomID(r.FormValue("roomid")) password := r.FormValue("password") if _, err := meetRoomManager.ValidateJoin(roomID, password); err != nil { utils.SendErrorResponse(w, err.Error()) return } file, header, err := r.FormFile("file") if err != nil { utils.SendErrorResponse(w, "Missing file") return } defer file.Close() attachment, err := meetRoomManager.SaveAttachment(roomID, header.Filename, userinfo.Username, file, meetroom.DefaultMaxUpload) if err != nil { utils.SendErrorResponse(w, err.Error()) return } js := mrMarshalOrDrop(map[string]interface{}{ "fileid": attachment.ID, "name": attachment.Name, "size": attachment.Size, }) utils.SendJSONResponse(w, string(js)) }) //Attach a file the user already owns in their ArozOS storage, addressed by //virtual path (e.g. user:/Desktop/report.pdf), without a round-trip //download+reupload. The file is streamed straight from the user's file //system into the room's attachment store. Access is inherently scoped: //GetFileSystemHandlerFromVirtualPath only resolves storages the user can //reach, and the real path is translated for that user. router.HandleFunc("/system/meetroom/attachfile", func(w http.ResponseWriter, r *http.Request) { userinfo, err := userHandler.GetUserInfoFromRequest(w, r) if err != nil { utils.SendErrorResponse(w, "Not logged in") return } roomID := meetroom.NormalizeRoomID(r.FormValue("roomid")) password := r.FormValue("password") if _, err := meetRoomManager.ValidateJoin(roomID, password); err != nil { utils.SendErrorResponse(w, err.Error()) return } vpath, err := utils.PostPara(r, "path") if err != nil { utils.SendErrorResponse(w, "Missing file path") return } //Resolve the virtual path within the requesting user's file system fsh, err := userinfo.GetFileSystemHandlerFromVirtualPath(vpath) if err != nil { utils.SendErrorResponse(w, "File not accessible") return } fshAbs := fsh.FileSystemAbstraction rpath, err := fshAbs.VirtualPathToRealPath(vpath, userinfo.Username) if err != nil { utils.SendErrorResponse(w, "Invalid file path") return } fileStat, err := fshAbs.Stat(rpath) if err != nil { utils.SendErrorResponse(w, "File not found") return } if fileStat.IsDir() { utils.SendErrorResponse(w, "Cannot attach a folder") return } stream, err := fshAbs.ReadStream(rpath) if err != nil { utils.SendErrorResponse(w, "Could not open the file") return } defer stream.Close() attachment, err := meetRoomManager.SaveAttachment(roomID, path.Base(vpath), userinfo.Username, stream, meetroom.DefaultMaxUpload) if err != nil { utils.SendErrorResponse(w, err.Error()) return } js := mrMarshalOrDrop(map[string]interface{}{ "fileid": attachment.ID, "name": attachment.Name, "size": attachment.Size, }) utils.SendJSONResponse(w, string(js)) }) //Attachment download for room members. router.HandleFunc("/system/meetroom/download", func(w http.ResponseWriter, r *http.Request) { roomID := meetroom.NormalizeRoomID(r.URL.Query().Get("roomid")) password := r.URL.Query().Get("password") fileID, err := utils.GetPara(r, "fileid") if err != nil { utils.SendErrorResponse(w, "Missing file ID") return } if _, err := meetRoomManager.ValidateJoin(roomID, password); err != nil { utils.SendErrorResponse(w, err.Error()) return } attachment, ok := meetRoomManager.GetAttachment(roomID, fileID) if !ok { http.NotFound(w, r) return } f, err := os.Open(attachment.DiskPath) if err != nil { http.NotFound(w, r) return } defer f.Close() //Serve with the original name; the ASCII fallback strips anything //that could break the header, the RFC 5987 form keeps unicode names. fallback := strings.Map(func(c rune) rune { if c < 32 || c == '"' || c == '\\' || c > 126 { return '_' } return c }, attachment.Name) //inline=1 lets the chat render images directly in the browser. Only //raster images may be served inline: anything else (notably SVG, //which can carry scripts) keeps the attachment disposition. disposition := "attachment" if r.URL.Query().Get("inline") == "1" && sharedspace.IsImageName(attachment.Name) { disposition = "inline" } w.Header().Set("Content-Disposition", disposition+"; filename=\""+fallback+"\"; filename*=UTF-8''"+url.PathEscape(attachment.Name)) w.Header().Set("X-Content-Type-Options", "nosniff") if ctype := mime.TypeByExtension(strings.ToLower(filepathExt(attachment.Name))); ctype != "" { w.Header().Set("Content-Type", ctype) } else { w.Header().Set("Content-Type", "application/octet-stream") } http.ServeContent(w, r, "", time.Now(), f) }) //WebSocket signaling relay. router.HandleFunc("/system/meetroom/ws", func(w http.ResponseWriter, r *http.Request) { userinfo, err := userHandler.GetUserInfoFromRequest(w, r) if err != nil { http.Error(w, "Not logged in", http.StatusUnauthorized) return } roomID := meetroom.NormalizeRoomID(r.URL.Query().Get("roomid")) password := r.URL.Query().Get("password") room, err := meetRoomManager.ValidateJoin(roomID, password) if err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } conn, err := meetRoomUpgrader.Upgrade(w, r, nil) if err != nil { return } conn.SetReadLimit(mrMaxSocketFrame) //Liveness: a client that goes silent (no frames and no pong //replies) past mrReadTimeout is dropped so it does not linger as a //ghost participant after a network failure. conn.SetReadDeadline(time.Now().Add(mrReadTimeout)) conn.SetPongHandler(func(string) error { conn.SetReadDeadline(time.Now().Add(mrReadTimeout)) return nil }) participant, err := room.AddParticipant(userinfo.Username) if err != nil { conn.Close() return } //Writer: drain the send channel until it is closed, interleaving //keepalive pings, then hang up. go func() { pinger := time.NewTicker(mrPingInterval) defer pinger.Stop() defer conn.Close() for { select { case msg, ok := <-participant.Send: if !ok { return } conn.SetWriteDeadline(time.Now().Add(mrWriteTimeout)) if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { return } case <-pinger.C: conn.SetWriteDeadline(time.Now().Add(mrWriteTimeout)) if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }() //Welcome frame: own identity, room descriptor and current peers. peers := []mrPeerInfo{} for _, p := range room.Participants() { if p.PeerID == participant.PeerID { continue } peers = append(peers, mrPeerInfo{PeerID: p.PeerID, Username: p.Username, IsHost: p.IsHost}) } room.SendTo(participant.PeerID, mrMarshalOrDrop(map[string]interface{}{ "type": "welcome", "peerid": participant.PeerID, "username": participant.Username, "isHost": participant.IsHost, "room": mrDescribeRoom(room), "peers": peers, })) //Announce the newcomer to everyone else. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "peer-join", "peer": mrPeerInfo{PeerID: participant.PeerID, Username: participant.Username, IsHost: participant.IsHost}, }), participant.PeerID) defer func() { //The participant may already be gone if the room was closed. if _, stillHere := room.GetParticipant(participant.PeerID); stillHere { room.RemoveParticipant(participant.PeerID) room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "peer-leave", "peerid": participant.PeerID, "username": participant.Username, }), -1) } participant.CloseSend() }() for { _, raw, err := conn.ReadMessage() if err != nil { return } conn.SetReadDeadline(time.Now().Add(mrReadTimeout)) var frame struct { Type string `json:"type"` To int `json:"to"` Data json.RawMessage `json:"data"` Text string `json:"text"` FileID string `json:"fileid"` Audio bool `json:"audio"` Video bool `json:"video"` Screen bool `json:"screen"` Hand bool `json:"hand"` } if json.Unmarshal(raw, &frame) != nil { continue } room.Touch() switch frame.Type { case "signal": //SDP / ICE relay to a single peer room.SendTo(frame.To, mrMarshalOrDrop(map[string]interface{}{ "type": "signal", "from": participant.PeerID, "data": frame.Data, })) case "chat": text := frame.Text if strings.TrimSpace(text) == "" { continue } if runes := []rune(text); len(runes) > mrMaxChatLength { text = string(runes[:mrMaxChatLength]) } room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "chat", "from": participant.PeerID, "username": participant.Username, "text": text, "time": time.Now().Unix(), }), -1) //Mirror into the room's shared space so AGI scripts can //read the conversation meetRoomManager.LogChat(room.ID, participant.Username, text) case "file": attachment, ok := meetRoomManager.GetAttachment(room.ID, frame.FileID) if !ok { continue } room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "file", "from": participant.PeerID, "username": participant.Username, "fileid": attachment.ID, "name": attachment.Name, "size": attachment.Size, "time": time.Now().Unix(), }), -1) case "state": //Mic / camera / screen share / raised-hand indicator update room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "state", "from": participant.PeerID, "audio": frame.Audio, "video": frame.Video, "screen": frame.Screen, "hand": frame.Hand, }), participant.PeerID) case "attendance": //Send the requester the room's join/leave log (the //participants side panel polls this on open and on //peer-join / peer-leave) room.SendTo(participant.PeerID, mrMarshalOrDrop(map[string]interface{}{ "type": "attendance", "records": mrAttendanceList(room), })) case "ping": //App-level heartbeat: lets the client detect a half-dead //connection and trigger its auto-reconnect logic. room.SendTo(participant.PeerID, []byte(`{"type":"pong"}`)) case "kick": //Host removes another participant. Order matters: the target //is told it was kicked (so its client stops auto-reconnecting) //while it is still subscribed, so the frame is queued and //drained before its socket drops; everyone is then told it //left; finally KickParticipant unregisters it and closes its //send channel, which ends its writer goroutine and hangs up. if !participant.IsHost || frame.To == participant.PeerID { continue } target, ok := room.GetParticipant(frame.To) if !ok || target.IsHost { continue } room.SendTo(target.PeerID, []byte(`{"type":"kicked"}`)) //Everyone but the target hears the removal; the target gets the //dedicated "kicked" frame above instead of its own leave. room.Broadcast(mrMarshalOrDrop(map[string]interface{}{ "type": "peer-leave", "peerid": target.PeerID, "username": target.Username, "kicked": true, }), target.PeerID) room.KickParticipant(target.PeerID) case "end": if participant.IsHost { mrEndMeeting(room.ID) return } } } }) } // filepathExt returns the extension of a display file name (which never // contains a path separator by the time it reaches the server). func filepathExt(name string) string { if idx := strings.LastIndex(name, "."); idx >= 0 { return name[idx:] } return "" }