diff --git a/hall/hall.go b/hall/hall.go index 8d2fb71..b499cbd 100644 --- a/hall/hall.go +++ b/hall/hall.go @@ -108,6 +108,12 @@ type Hall struct { history []ChatHistoryEntry timestamp time.Time data map[string]interface{} + chalkboard ChalkboardState +} + +type ChalkboardState struct { + Text string `json:"text"` + Revision uint64 `json:"revision"` } func (g *Hall) Name() string { @@ -165,6 +171,20 @@ func (g *Hall) UpdateData(d map[string]interface{}) { } } +func (g *Hall) Chalkboard() ChalkboardState { + g.mu.Lock() + defer g.mu.Unlock() + return g.chalkboard +} + +func (g *Hall) UpdateChalkboard(text string) ChalkboardState { + g.mu.Lock() + defer g.mu.Unlock() + g.chalkboard.Text = text + g.chalkboard.Revision++ + return g.chalkboard +} + func (g *Hall) Description() *Description { g.mu.Lock() defer g.mu.Unlock() diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index 73a6069..35877ea 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -82,6 +82,28 @@ func broadcastHallInfo(g *hall.Hall, message string) { } } +func canEditChalkboard(perms []string) bool { + return member("op", perms) || + member("admin", perms) || + member("chalkboard", perms) +} + +func chalkboardMessage(state hall.ChalkboardState) clientMessage { + return clientMessage{ + Type: "usermessage", + Kind: "chalkboard", + Privileged: true, + Value: state, + } +} + +func broadcastChalkboard(g *hall.Hall, state hall.ChalkboardState) { + err := broadcast(g.GetClients(nil), chalkboardMessage(state)) + if err != nil { + log.Printf("broadcast(chalkboard): %v", err) + } +} + type webClient struct { hall *hall.Hall addr net.Addr @@ -1211,6 +1233,10 @@ func handleAction(c *webClient, a any) error { return err } } + err := c.write(chalkboardMessage(g.Chalkboard())) + if err != nil { + return err + } } case changePermissionsAction: switch a.kind { @@ -1231,6 +1257,10 @@ func handleAction(c *webClient, a any) error { c.permissions = remove("message", c.permissions) case "unshutup": c.permissions = addnew("message", c.permissions) + case "chalkboard": + c.permissions = addnew("chalkboard", c.permissions) + case "unchalkboard": + c.permissions = remove("chalkboard", c.permissions) default: return hall.UserError("unknown permission") } @@ -1660,6 +1690,24 @@ func handleClientMessage(c *webClient, m clientMessage) error { if err != nil { log.Printf("broadcast(clearchat): %v", err) } + case "chalkboard": + if !canEditChalkboard(c.permissions) { + return c.error(hall.UserError("not authorised")) + } + value, ok := m.Value.(map[string]any) + if !ok { + return c.error(hall.UserError( + "bad value in chalkboard", + )) + } + text, ok := value["text"].(string) + if !ok { + return c.error(hall.UserError( + "bad value in chalkboard", + )) + } + state := g.UpdateChalkboard(text) + broadcastChalkboard(g, state) case "lock", "unlock": if !member("op", c.permissions) { return c.error(hall.UserError("not authorised")) @@ -1903,8 +1951,10 @@ func handleClientMessage(c *webClient, m clientMessage) error { return c.error(hall.UserError("join a hall first")) } switch m.Kind { - case "op", "unop", "present", "unpresent", "shutup", "unshutup": - if !member("op", c.permissions) { + case "op", "unop", "present", "unpresent", "shutup", "unshutup", "chalkboard", "unchalkboard": + if !member("op", c.permissions) && + !(member("admin", c.permissions) && + (m.Kind == "chalkboard" || m.Kind == "unchalkboard")) { return c.error(hall.UserError("not authorised")) } t := g.GetClient(m.Dest) diff --git a/rtpconn/webclient_test.go b/rtpconn/webclient_test.go index 12b10d6..0eff482 100644 --- a/rtpconn/webclient_test.go +++ b/rtpconn/webclient_test.go @@ -2,10 +2,15 @@ package rtpconn import ( "encoding/json" + "os" + "path/filepath" "reflect" + "strings" "testing" + "git.stormux.org/storm/skald/hall" "git.stormux.org/storm/skald/token" + "git.stormux.org/storm/skald/unbounded" ) var tokens = []string{ @@ -50,3 +55,243 @@ func TestParseStatefulToken(t *testing.T) { } } } + +func testWebClient(id string) *webClient { + return &webClient{ + id: id, + actions: unbounded.New[any](), + done: make(chan struct{}), + writeCh: make(chan interface{}, 100), + writerDone: make(chan struct{}), + } +} + +func makePermission(t *testing.T, name string) hall.Permissions { + t.Helper() + p, err := hall.NewPermissions(name) + if err != nil { + t.Fatalf("NewPermissions(%q): %v", name, err) + } + return p +} + +func testPassword() hall.Password { + key := "pw" + return hall.Password{ + Type: "plain", + Key: &key, + } +} + +func addTestWebClient(t *testing.T, hallName string, username string, permission string) *webClient { + t.Helper() + c := testWebClient(username + "-id") + _, err := hall.AddClient(hallName, c, hall.ClientCredentials{ + Username: &username, + Password: "pw", + }) + if err != nil { + t.Fatalf("AddClient(%q): %v", username, err) + } + c.hall = hall.Get(hallName) + drainActions(t, c) + drainMessages(c) + if !member(permission, c.permissions) { + t.Fatalf("%q permissions %v, missing %q", username, c.permissions, permission) + } + return c +} + +func newChalkboardTestHall(t *testing.T) string { + t.Helper() + name := "chalkboard-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) + oldDirectory := hall.Directory + hall.Directory = t.TempDir() + t.Cleanup(func() { + hall.Directory = oldDirectory + }) + + password := testPassword() + desc := &hall.Description{ + Users: map[string]hall.UserDescription{ + "Operator": { + Password: password, + Permissions: makePermission(t, "op"), + }, + "Admin": { + Password: password, + Permissions: makePermission(t, "admin"), + }, + "Participant": { + Password: password, + Permissions: makePermission(t, "present"), + }, + }, + } + data, err := json.Marshal(desc) + if err != nil { + t.Fatalf("Marshal hall description: %v", err) + } + if err := os.WriteFile(filepath.Join(hall.Directory, name+".json"), data, 0o666); err != nil { + t.Fatalf("Write hall description: %v", err) + } + if _, err := hall.Add(name, nil); err != nil { + t.Fatalf("Add hall: %v", err) + } + t.Cleanup(func() { + if g := hall.Get(name); g != nil { + for _, c := range g.GetClients(nil) { + hall.DelClient(c) + } + } + hall.Delete(name) + }) + return name +} + +func drainActions(t *testing.T, c *webClient) { + t.Helper() + for { + select { + case <-c.actions.Ch: + for _, a := range c.actions.Get() { + if err := handleAction(c, a); err != nil { + t.Fatalf("handleAction: %v", err) + } + } + default: + return + } + } +} + +func drainMessages(c *webClient) []clientMessage { + var messages []clientMessage + for { + select { + case m := <-c.writeCh: + switch m := m.(type) { + case clientMessage: + messages = append(messages, m) + case []byte: + var cm clientMessage + if err := json.Unmarshal(m, &cm); err == nil { + messages = append(messages, cm) + } + } + default: + return messages + } + } +} + +func latestChalkboardMessage(messages []clientMessage) (hall.ChalkboardState, bool) { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Type != "usermessage" || messages[i].Kind != "chalkboard" { + continue + } + b, err := json.Marshal(messages[i].Value) + if err != nil { + return hall.ChalkboardState{}, false + } + var state hall.ChalkboardState + if err := json.Unmarshal(b, &state); err != nil { + return hall.ChalkboardState{}, false + } + return state, true + } + return hall.ChalkboardState{}, false +} + +func TestChalkboardEditRequiresPermission(t *testing.T) { + hallName := newChalkboardTestHall(t) + participant := addTestWebClient(t, hallName, "Participant", "present") + operator := addTestWebClient(t, hallName, "Operator", "op") + + err := handleClientMessage(participant, clientMessage{ + Type: "hallaction", + Kind: "chalkboard", + Value: map[string]any{ + "text": "should not save", + "revision": float64(0), + }, + }) + if err != nil { + t.Fatalf("unauthorised chalkboard edit returned error: %v", err) + } + if got := participant.hall.Chalkboard().Text; got != "" { + t.Fatalf("unauthorised edit changed text to %q", got) + } + + err = handleClientMessage(operator, clientMessage{ + Type: "hallaction", + Kind: "chalkboard", + Value: map[string]any{ + "text": "line 1\nline 2", + "revision": float64(0), + }, + }) + if err != nil { + t.Fatalf("operator chalkboard edit: %v", err) + } + state := operator.hall.Chalkboard() + if state.Text != "line 1\nline 2" || state.Revision != 1 { + t.Fatalf("chalkboard state = %#v", state) + } + for _, c := range []*webClient{participant, operator} { + got, ok := latestChalkboardMessage(drainMessages(c)) + if !ok { + t.Fatalf("%v did not receive chalkboard broadcast", c.id) + } + if got != state { + t.Fatalf("%v broadcast = %#v, want %#v", c.id, got, state) + } + } +} + +func TestChalkboardGrantIsSessionPermission(t *testing.T) { + hallName := newChalkboardTestHall(t) + admin := addTestWebClient(t, hallName, "Admin", "admin") + participant := addTestWebClient(t, hallName, "Participant", "present") + + err := handleClientMessage(admin, clientMessage{ + Type: "useraction", + Kind: "chalkboard", + Dest: participant.id, + }) + if err != nil { + t.Fatalf("admin chalkboard grant: %v", err) + } + drainActions(t, participant) + if !member("chalkboard", participant.permissions) { + t.Fatalf("grant did not add chalkboard permission: %v", participant.permissions) + } + + err = handleClientMessage(participant, clientMessage{ + Type: "hallaction", + Kind: "chalkboard", + Value: map[string]any{ + "text": "granted edit", + "revision": float64(0), + }, + }) + if err != nil { + t.Fatalf("granted chalkboard edit: %v", err) + } + if got := participant.hall.Chalkboard().Text; got != "granted edit" { + t.Fatalf("chalkboard text = %q", got) + } + + err = handleClientMessage(admin, clientMessage{ + Type: "useraction", + Kind: "unchalkboard", + Dest: participant.id, + }) + if err != nil { + t.Fatalf("admin chalkboard revoke: %v", err) + } + drainActions(t, participant) + if member("chalkboard", participant.permissions) { + t.Fatalf("revoke left chalkboard permission: %v", participant.permissions) + } +} diff --git a/skald-protocol.md b/skald-protocol.md index 55906b6..eb26da9 100644 --- a/skald-protocol.md +++ b/skald-protocol.md @@ -355,8 +355,21 @@ chat history, and is not expected to contain user-visible content. ``` Currently defined kinds include `error`, `warning`, `info`, `kicked`, -`clearchat` (not to be confused with the `clearchat` hall action), and -`mute`. +`clearchat` (not to be confused with the `clearchat` hall action), +`chalkboard`, and `mute`. A `chalkboard` user message carries the current +virtual chalkboard state: + +```javascript +{ + type: 'usermessage', + kind: 'chalkboard', + privileged: true, + value: { + text: text, + revision: revision + } +} +``` A user action requests that the server act upon a user. @@ -371,7 +384,7 @@ A user action requests that the server act upon a user. } ``` Currently defined kinds include `op`, `unop`, `present`, `unpresent`, -`kick` and `setdata`. +`chalkboard`, `unchalkboard`, `kick` and `setdata`. Finally, a hall action requests that the server act on the current hall. @@ -386,8 +399,21 @@ Finally, a hall action requests that the server act on the current hall. ``` Currently defined kinds include `clearchat` (not to be confused with the -`clearchat` user message), `lock`, `unlock`, `record`, `unrecord`, -`subhalls` and `setdata`. +`clearchat` user message), `chalkboard`, `lock`, `unlock`, `record`, +`unrecord`, `subhalls` and `setdata`. A `chalkboard` hall action updates +the session-only virtual chalkboard and requires `op`, `admin`, or +temporary `chalkboard` permission: + +```javascript +{ + type: 'hallaction', + kind: 'chalkboard', + value: { + text: text, + revision: revision + } +} +``` # Peer-to-peer file transfer protocol diff --git a/skald.md b/skald.md index 466c4e6..91e8414 100644 --- a/skald.md +++ b/skald.md @@ -75,6 +75,14 @@ than navigating the user interface. Commands start with a slash character message to a given user. Type `/help` to display the list of available commands. +Below the chat input is a session-only virtual chalkboard. It is a +multiline text area suitable for commands, code, and notes that should be +visible to everyone in the hall. Operators and administrators may edit +the chalkboard and may grant or revoke chalkboard editing for connected +users with `/chalkboard user` and `/unchalkboard user`. Users without +edit permission can read and focus the chalkboard, but it is marked +read-only and does not trap the Tab key. + ### Inviting users In order to generate an invitation link, choose the entry *Invite user* in diff --git a/static/protocol.js b/static/protocol.js index 11ebe7e..bbe35ea 100644 --- a/static/protocol.js +++ b/static/protocol.js @@ -781,7 +781,7 @@ ServerConnection.prototype.chat = function(kind, dest, value) { /** * userAction sends a request to act on a user. * - * @param {string} kind - One of "op", "unop", "kick", "present", "unpresent". + * @param {string} kind - One of "op", "unop", "kick", "present", "unpresent", "chalkboard", "unchalkboard". * @param {string} dest - The id of the user to act upon. * @param {any} [value] - An action-dependent parameter. */ diff --git a/static/skald.css b/static/skald.css index 032aabc..0325145 100644 --- a/static/skald.css +++ b/static/skald.css @@ -731,6 +731,8 @@ h1 { #chatbox { height: 100%; position: relative; + display: flex; + flex-direction: column; } #chat { @@ -754,7 +756,8 @@ h1 { #box { overflow: auto; - height: calc(100% - 53px); + flex: 1 1 auto; + min-height: 6rem; padding: 10px; } @@ -812,6 +815,36 @@ h1 { width: 100%; } +#chalkboard-area { + border-top: 1px solid #d7d7d7; + background: #fff; + padding: 0.35rem; +} + +#chalkboard-label { + display: block; + font-size: 0.9rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +#chalkboard { + box-sizing: border-box; + display: block; + width: 100%; + min-height: 8rem; + resize: vertical; +} + +#chalkboard[readonly] { + background: #f3f3f3; +} + +#chalkboard:focus { + outline: 2px solid #0066cc; + outline-offset: 2px; +} + .sidenav { background-color: #4d076b; box-shadow: 0 0 24px 0 rgba(71,77,86,.1), 0 1px 0 0 rgba(71,77,86,.08); diff --git a/static/skald.html b/static/skald.html index f5a56c3..3c06e87 100644 --- a/static/skald.html +++ b/static/skald.html @@ -76,6 +76,10 @@ +
+ + +
diff --git a/static/skald.js b/static/skald.js index 35f7c73..a8f6488 100644 --- a/static/skald.js +++ b/static/skald.js @@ -579,6 +579,7 @@ function gotClose(code, reason) { closeUpMedia(); closeSafariStream(); setConnected(false); + resetChalkboard(); if(code != 1000) { console.warn('Socket close', code, reason); } @@ -1802,6 +1803,19 @@ function userMenu(elt) { serverConnection.userAction('identify', id); }}); } + if(serverConnection.permissions.indexOf('op') >= 0 || + serverConnection.permissions.indexOf('admin') >= 0) { + if(serverConnection.permissions.indexOf('op') < 0) + items.push({type: 'seperator'}); // sic + if(user.permissions.indexOf('chalkboard') >= 0) + items.push({label: 'Forbid chalkboard editing', onClick: () => { + serverConnection.userAction('unchalkboard', id); + }}); + else + items.push({label: 'Allow chalkboard editing', onClick: () => { + serverConnection.userAction('chalkboard', id); + }}); + } } openActionMenu(items, elt, `Actions for ${user.username || 'participant'}`); } @@ -1981,6 +1995,7 @@ function displayUsername() { document.getElementById('userspan').textContent = serverConnection.username; let op = serverConnection.permissions.indexOf('op') >= 0; let present = serverConnection.permissions.indexOf('present') >= 0; + let chalkboard = canEditChalkboard(); let text = ''; if(op && present) text = '(op, presenter)'; @@ -1988,6 +2003,8 @@ function displayUsername() { text = 'operator'; else if(present) text = 'presenter'; + else if(chalkboard) + text = 'chalkboard editor'; document.getElementById('permspan').textContent = text; } @@ -2098,6 +2115,7 @@ async function gotJoined(kind, hall, perms, status, data, error, message) { setTitle((status && status.displayName) || capitalise(hall)); setConnected(true); displayUsername(); + updateChalkboardAccess(); setButtonsVisibility(); setChangePassword(pwAuth && !!hallStatus.canChangePassword && serverConnection.username @@ -2404,6 +2422,13 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa clearChat(id, userId); break; } + case 'chalkboard': + if(!privileged) { + console.error(`Got unprivileged message of kind ${kind}`); + return; + } + applyChalkboardUpdate(message); + break; case 'token': if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); @@ -2586,6 +2611,89 @@ function announceChat(text) { announceLiveRegion(text, 'chat-announcements'); } +let chalkboardRevision = 0; +let chalkboardSendTimer = null; +let applyingChalkboardUpdate = false; + +function canEditChalkboard() { + if(!serverConnection || !serverConnection.permissions) + return false; + return serverConnection.permissions.indexOf('op') >= 0 || + serverConnection.permissions.indexOf('admin') >= 0 || + serverConnection.permissions.indexOf('chalkboard') >= 0; +} + +function updateChalkboardAccess() { + let chalkboard = /** @type {HTMLTextAreaElement} */ + (document.getElementById('chalkboard')); + if(!chalkboard) + return; + let canEdit = canEditChalkboard(); + chalkboard.readOnly = !canEdit; + chalkboard.setAttribute('aria-readonly', canEdit ? 'false' : 'true'); + chalkboard.setAttribute( + 'aria-label', + canEdit ? 'Virtual chalkboard' : 'Virtual chalkboard, read only', + ); +} + +function resetChalkboard() { + chalkboardRevision = 0; + if(chalkboardSendTimer) { + clearTimeout(chalkboardSendTimer); + chalkboardSendTimer = null; + } + let chalkboard = /** @type {HTMLTextAreaElement} */ + (document.getElementById('chalkboard')); + if(chalkboard) + chalkboard.value = ''; + updateChalkboardAccess(); +} + +/** + * @param {any} message + */ +function applyChalkboardUpdate(message) { + if(!message || typeof message !== 'object') { + console.warn('Unexpected chalkboard message', message); + return; + } + let text = typeof message.text === 'string' ? message.text : ''; + let revision = Number(message.revision || 0); + if(revision < chalkboardRevision) + return; + + chalkboardRevision = revision; + let chalkboard = /** @type {HTMLTextAreaElement} */ + (document.getElementById('chalkboard')); + if(!chalkboard) + return; + if(chalkboard.value !== text) { + applyingChalkboardUpdate = true; + chalkboard.value = text; + applyingChalkboardUpdate = false; + } + updateChalkboardAccess(); +} + +function scheduleChalkboardSend() { + if(!canEditChalkboard()) + return; + if(chalkboardSendTimer) + clearTimeout(chalkboardSendTimer); + chalkboardSendTimer = setTimeout(() => { + chalkboardSendTimer = null; + let chalkboard = /** @type {HTMLTextAreaElement} */ + (document.getElementById('chalkboard')); + if(!chalkboard || !serverConnection || !serverConnection.socket) + return; + serverConnection.hallAction('chalkboard', { + text: chalkboard.value, + revision: chalkboardRevision, + }); + }, 350); +} + /** * @param {string} text */ @@ -2866,6 +2974,14 @@ function operatorPredicate() { return 'You are not an operator'; } +function chalkboardGrantPredicate() { + if(serverConnection && serverConnection.permissions && + (serverConnection.permissions.indexOf('op') >= 0 || + serverConnection.permissions.indexOf('admin') >= 0)) + return null; + return 'You are not allowed to grant chalkboard editing'; +} + function recordingPredicate() { if(serverConnection && serverConnection.permissions && serverConnection.permissions.indexOf('record') >= 0) @@ -3311,6 +3427,20 @@ commands.unshutup = { f: userCommand, }; +commands.chalkboard = { + parameters: 'user', + description: 'give the right to edit the virtual chalkboard', + predicate: chalkboardGrantPredicate, + f: userCommand, +}; + +commands.unchalkboard = { + parameters: 'user', + description: 'revoke the right to edit the virtual chalkboard', + predicate: chalkboardGrantPredicate, + f: userCommand, +}; + commands.mute = { parameters: 'user', description: 'mute a remote user', @@ -3544,6 +3674,17 @@ document.getElementById('input').onkeypress = function(e) { } }; +document.getElementById('chalkboard').addEventListener('input', function(e) { + if(applyingChalkboardUpdate) + return; + scheduleChalkboardSend(); +}); + +document.getElementById('chalkboard').addEventListener('keydown', function(e) { + if(e.key === 'Tab') + return; +}); + function updateChatResizerValue(leftPercent) { let resizer = document.getElementById('resizer'); if(!resizer) @@ -3981,4 +4122,5 @@ document.addEventListener('keydown', function(e) { } }); +resetChalkboard(); start();