From ae55982a409cc3f502ded2852edfe27be1252133 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 30 Aug 2026 16:56:26 -0400 Subject: [PATCH] First pass at adding a waiting room. --- distro-packages/Arch-Linux/skald-git/PKGBUILD | 2 +- hall/client.go | 6 + hall/hall.go | 225 +++++++----- rtpconn/unlock.go | 32 ++ rtpconn/waiting.go | 330 ++++++++++++++++++ rtpconn/waiting_test.go | 315 +++++++++++++++++ rtpconn/webclient.go | 104 +++++- rtpconn/whipclient.go | 1 + skald-client.md | 9 + skald-protocol.md | 53 ++- skald.md | 21 +- static/protocol.js | 32 +- static/skald.css | 27 +- static/skald.html | 16 + static/skald.js | 159 ++++++++- webserver/static_accessibility_test.go | 20 ++ webserver/whip.go | 2 + 17 files changed, 1254 insertions(+), 100 deletions(-) create mode 100644 rtpconn/waiting.go create mode 100644 rtpconn/waiting_test.go diff --git a/distro-packages/Arch-Linux/skald-git/PKGBUILD b/distro-packages/Arch-Linux/skald-git/PKGBUILD index aa557b4..fcfe335 100644 --- a/distro-packages/Arch-Linux/skald-git/PKGBUILD +++ b/distro-packages/Arch-Linux/skald-git/PKGBUILD @@ -2,7 +2,7 @@ # shellcheck shell=bash disable=SC2034,SC2154 pkgname=skald-git -pkgver=2026.07.08.r2.g6a94f89 +pkgver=2026.07.08.r3.g9125e76 pkgrel=1 pkgdesc='Audio-only hall-based conferencing server' arch=('x86_64' 'aarch64') diff --git a/hall/client.go b/hall/client.go index ca2cbf4..fcf1e78 100644 --- a/hall/client.go +++ b/hall/client.go @@ -133,6 +133,12 @@ type ClientCredentials struct { Token string } +// AuthenticatedClient is the credential-free result of authentication. +type AuthenticatedClient struct { + Username string + Permissions []string +} + type Client interface { Hall() *Hall Addr() net.Addr diff --git a/hall/hall.go b/hall/hall.go index 2d626d6..494d293 100644 --- a/hall/hall.go +++ b/hall/hall.go @@ -105,6 +105,7 @@ type Hall struct { description *Description locked *string clients map[string]Client + reserved map[string]Client history []ChatHistoryEntry timestamp time.Time data map[string]interface{} @@ -232,6 +233,37 @@ func (g *Hall) ClientCount() int { return g.clientCountUnlocked() } +// ReserveUsernameWhileWaiting atomically checks whether waiting is available +// and, if so, reserves a username for an authenticated non-member. +func (g *Hall) ReserveUsernameWhileWaiting(username string, client Client) (bool, error) { + g.mu.Lock() + defer g.mu.Unlock() + if g.locked == nil || !g.hasOperatorUnlocked() { + return false, nil + } + for _, c := range g.clients { + if !isSystemClient(c) && c.Username() == username { + return true, UserError("username already in use") + } + } + if holder := g.reserved[username]; holder != nil && holder != client { + return true, UserError("username already in use") + } + if g.reserved == nil { + g.reserved = make(map[string]Client) + } + g.reserved[username] = client + return true, nil +} + +func (g *Hall) ReleaseUsername(username string, client Client) { + g.mu.Lock() + defer g.mu.Unlock() + if g.reserved[username] == client { + delete(g.reserved, username) + } +} + func (g *Hall) clientCountUnlocked() int { count := 0 for _, c := range g.clients { @@ -252,6 +284,9 @@ func (g *Hall) mayExpire() bool { if len(g.clients) > 0 { return false } + if len(g.reserved) > 0 { + return false + } return time.Since(g.timestamp) > maxHistoryAge(g.description) } @@ -578,7 +613,7 @@ func Delete(name string) bool { // Called with both halls.mu and g.mu taken. func deleteUnlocked(g *Hall) bool { - if len(g.clients) != 0 { + if len(g.clients) != 0 || len(g.reserved) != 0 { return false } @@ -599,112 +634,136 @@ func isSystemClient(c Client) bool { return member("system", c.Permissions()) } -func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) { - g, err := Add(hall, nil) +func AuthenticateClient(name string, creds ClientCredentials) (*Hall, AuthenticatedClient, error) { + g, err := Add(name, nil) if err != nil { - return nil, err + return nil, AuthenticatedClient{}, err } - g.mu.Lock() defer g.mu.Unlock() + username, perms, err := g.getPermission(creds) + if err != nil { + return nil, AuthenticatedClient{}, err + } + return g, AuthenticatedClient{username, append([]string(nil), perms...)}, nil +} +// AddAuthenticatedClient admits a previously authenticated non-system client. +// A lock bypass does not bypass time restrictions or capacity. +func AddAuthenticatedClient(g *Hall, c Client, auth AuthenticatedClient, bypassLock bool) error { + if g == nil { + return os.ErrNotExist + } + g.mu.Lock() + defer g.mu.Unlock() clients := g.getClientsUnlocked(nil) - systemClient := isSystemClient(c) - - if !systemClient { - username, perms, err := g.getPermission(creds) - if err != nil { - return nil, err + if holder := g.reserved[auth.Username]; holder != nil && holder != c { + return UserError("username already in use") + } + c.SetUsername(auth.Username) + c.SetPermissions(auth.Permissions) + for _, cc := range clients { + if !isSystemClient(cc) && cc.Username() == auth.Username { + return UserError("username already in use") } - - c.SetUsername(username) - c.SetPermissions(perms) - - for _, cc := range clients { - if !isSystemClient(cc) && cc.Username() == username { - return nil, UserError("username already in use") + } + if !member("op", auth.Permissions) { + if !bypassLock && g.locked != nil { + m := *g.locked + if m == "" { + m = "Hall is locked." + } + return UserError(m) + } + now := time.Now() + if g.description.NotBefore != nil && g.description.NotBefore.After(now) { + return UserError("this hall is not open yet") + } + if g.description.Expires != nil && g.description.Expires.Before(now) { + return UserError("this hall is closed") + } + if g.description.Autokick { + ops := false + for _, existing := range clients { + if member("op", existing.Permissions()) { + ops = true + break + } + } + if !ops { + return UserError("there are no operators in this hall") } } - - if !member("op", perms) { - if g.locked != nil { - m := *g.locked - if m == "" { - m = "Hall is locked." - } - return nil, UserError(m) - } - if g.description.NotBefore != nil || - g.description.Expires != nil { - now := time.Now() - if g.description.NotBefore != nil && - g.description.NotBefore.After(now) { - return nil, UserError( - "this hall is not open yet", - ) - } - if g.description.Expires != nil && - g.description.Expires.Before(now) { - return nil, UserError( - "this hall is closed", - ) - } - } - if g.description.Autokick { - ops := false - for _, c := range clients { - if member("op", c.Permissions()) { - ops = true - break - } - } - if !ops { - return nil, UserError( - "there are no operators " + - "in this hall", - ) - } - } - } - - if !member("op", perms) && g.description.MaxClients > 0 { - if g.clientCountUnlocked() >= g.description.MaxClients { - return nil, UserError("too many users") - } + if g.description.MaxClients > 0 && g.clientCountUnlocked() >= g.description.MaxClients { + return UserError("too many users") } } id := c.Id() if id == "" { - return nil, errors.New("client has empty id") + return errors.New("client has empty id") } if g.clients[id] != nil { - return nil, ProtocolError("duplicate client id") + return ProtocolError("duplicate client id") } g.clients[id] = c + delete(g.reserved, auth.Username) g.timestamp = time.Now() - c.Joined(g.Name(), "join") - - u := c.Username() - p := c.Permissions() - s := c.Data() - if !systemClient { - c.PushClient(g.Name(), "add", c.Id(), u, p, s) - } + u, p, s := c.Username(), c.Permissions(), c.Data() + c.PushClient(g.Name(), "add", id, u, p, s) for _, cc := range clients { - pp := cc.Permissions() - uu := cc.Username() if !isSystemClient(cc) { - c.PushClient( - g.Name(), "add", cc.Id(), uu, pp, cc.Data(), - ) + c.PushClient(g.Name(), "add", cc.Id(), cc.Username(), cc.Permissions(), cc.Data()) } - if !systemClient { - cc.PushClient(g.Name(), "add", id, u, p, s) + cc.PushClient(g.Name(), "add", id, u, p, s) + } + return nil +} + +func AddClient(name string, c Client, creds ClientCredentials) (*Hall, error) { + if creds.System || isSystemClient(c) { + g, err := Add(name, nil) + if err != nil { + return nil, err + } + g.mu.Lock() + defer g.mu.Unlock() + if c.Id() == "" { + return nil, errors.New("client has empty id") + } + if g.clients[c.Id()] != nil { + return nil, ProtocolError("duplicate client id") + } + g.clients[c.Id()] = c + g.timestamp = time.Now() + c.Joined(g.Name(), "join") + return g, nil + } + g, auth, err := AuthenticateClient(name, creds) + if err != nil { + return nil, err + } + if err := AddAuthenticatedClient(g, c, auth, false); err != nil { + return nil, err + } + return g, nil +} + +// WaitingStatus atomically reports the lock and active-operator state. +func (g *Hall) WaitingStatus() (bool, bool) { + g.mu.Lock() + defer g.mu.Unlock() + return g.locked != nil, g.hasOperatorUnlocked() +} + +// called locked +func (g *Hall) hasOperatorUnlocked() bool { + for _, c := range g.clients { + if !isSystemClient(c) && member("op", c.Permissions()) { + return true } } - - return g, nil + return false } // called locked diff --git a/rtpconn/unlock.go b/rtpconn/unlock.go index 9e16c81..c8b4429 100644 --- a/rtpconn/unlock.go +++ b/rtpconn/unlock.go @@ -42,6 +42,20 @@ var hallUnlockSchedules = struct { entries: make(map[*hall.Hall]*scheduledHallUnlock), } +func scheduledUnlockDeadline(g *hall.Hall) (time.Time, bool) { + hallUnlockSchedules.Lock() + entry := hallUnlockSchedules.entries[g] + if entry == nil { + hallUnlockSchedules.Unlock() + return time.Time{}, false + } + entry.mu.Lock() + deadline, active := entry.deadline, entry.active + entry.mu.Unlock() + hallUnlockSchedules.Unlock() + return deadline, active +} + func scheduledUnlockRequestFromValue(value interface{}) (scheduledUnlockRequest, error) { v, ok := value.(map[string]interface{}) if !ok { @@ -265,6 +279,7 @@ func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time broadcastHallInfo(g, message, true) } entry.mu.Unlock() + waitingHallChanged(g) return nil } @@ -310,6 +325,7 @@ func finishScheduledUnlock(entry *scheduledHallUnlock) { } entry.mu.Unlock() hallUnlockSchedules.Unlock() + waitingHallChanged(entry.hall) } func setHallLockState(g *hall.Hall, locked bool, message string) bool { @@ -329,6 +345,7 @@ func setHallLockState(g *hall.Hall, locked bool, message string) bool { broadcastHallInfo(g, "Hall unlocked", false) } hallUnlockSchedules.Unlock() + waitingHallChanged(g) return entry != nil } @@ -343,6 +360,21 @@ func cancelScheduledHallUnlock(g *hall.Hall) bool { return entry != nil } +func cancelScheduledUnlockByIssuer(g *hall.Hall, issuer *webClient) bool { + hallUnlockSchedules.Lock() + entry := hallUnlockSchedules.entries[g] + if entry == nil || entry.issuer != issuer { + hallUnlockSchedules.Unlock() + return false + } + delete(hallUnlockSchedules.entries, g) + deactivateScheduledUnlock(entry) + hallUnlockSchedules.Unlock() + broadcastHallInfo(g, "Scheduled hall unlock cancelled because the issuing operator is no longer present as an operator.", true) + waitingHallChanged(g) + return true +} + func deactivateScheduledUnlock(entry *scheduledHallUnlock) { entry.mu.Lock() entry.active = false diff --git a/rtpconn/waiting.go b/rtpconn/waiting.go new file mode 100644 index 0000000..518ec8f --- /dev/null +++ b/rtpconn/waiting.go @@ -0,0 +1,330 @@ +package rtpconn + +import ( + "errors" + "sync" + "time" + + "git.stormux.org/storm/skald/hall" +) + +type waitingEntry struct { + client *webClient + hall *hall.Hall + auth hall.AuthenticatedClient + autoJoin bool + approved bool + manualJoin bool +} + +type waitingEventAction struct { + message clientMessage +} + +type waitingState struct { + Hall string `json:"hall"` + DisplayName string `json:"displayName"` + Username string `json:"username"` + Locked bool `json:"locked"` + Deadline string `json:"deadline,omitempty"` +} + +type waitingUser struct { + Username string `json:"username"` + Approved bool `json:"approved,omitempty"` +} + +var waitingRooms = struct { + sync.Mutex + entries map[*hall.Hall][]*waitingEntry +}{ + entries: make(map[*hall.Hall][]*waitingEntry), +} + +var errWaitingUnavailable = errors.New("waiting room unavailable") + +func waitingDisplayName(g *hall.Hall) string { + name := g.Status(true, nil).DisplayName + if name == "" { + name = g.Name() + } + return name +} + +func currentWaitingState(g *hall.Hall, username string) waitingState { + locked, _ := g.Locked() + state := waitingState{ + Hall: g.Name(), DisplayName: waitingDisplayName(g), + Username: username, Locked: locked, + } + if deadline, ok := scheduledUnlockDeadline(g); ok { + state.Deadline = deadline.Format(time.RFC3339) + } + return state +} + +func sendWaiting(c *webClient, kind string, value interface{}) { + c.action(waitingEventAction{clientMessage{ + Type: "waiting", Kind: kind, Value: value, + }}) +} + +func sendWaitingList(c *webClient, kind string, value interface{}) { + c.action(waitingEventAction{clientMessage{ + Type: "waiting-list", Kind: kind, Value: value, + }}) +} + +func waitingSnapshotLocked(g *hall.Hall) []waitingUser { + entries := waitingRooms.entries[g] + users := make([]waitingUser, 0, len(entries)) + for _, entry := range entries { + users = append(users, waitingUser{entry.auth.Username, entry.approved}) + } + return users +} + +func waitingClientHall(c *webClient) *hall.Hall { + waitingRooms.Lock() + defer waitingRooms.Unlock() + if c.waiting == nil { + return nil + } + return c.waiting.hall +} + +func notifyWaitingOperators(g *hall.Hall, kind string, value interface{}) { + for _, client := range g.GetClients(nil) { + c, ok := client.(*webClient) + if ok && member("op", c.Permissions()) { + sendWaitingList(c, kind, value) + } + } +} + +func addWaitingClient(c *webClient, g *hall.Hall, auth hall.AuthenticatedClient) error { + waitingRooms.Lock() + offered, err := g.ReserveUsernameWhileWaiting(auth.Username, c) + if err != nil { + waitingRooms.Unlock() + return err + } + if !offered { + waitingRooms.Unlock() + return errWaitingUnavailable + } + entry := &waitingEntry{client: c, hall: g, auth: auth} + waitingRooms.entries[g] = append(waitingRooms.entries[g], entry) + c.waiting = entry + c.SetUsername(auth.Username) + sendWaiting(c, "enter", currentWaitingState(g, auth.Username)) + notifyWaitingOperators(g, "add", waitingUser{Username: auth.Username}) + waitingRooms.Unlock() + return nil +} + +func removeWaitingClient(c *webClient, notify bool) bool { + waitingRooms.Lock() + entry := c.waiting + if entry == nil { + waitingRooms.Unlock() + return false + } + removeWaitingEntryLocked(entry) + if notify { + notifyWaitingOperators(entry.hall, "remove", waitingUser{Username: entry.auth.Username}) + } + waitingRooms.Unlock() + return true +} + +func removeWaitingEntryLocked(entry *waitingEntry) { + entries := waitingRooms.entries[entry.hall] + for i, candidate := range entries { + if candidate == entry { + entries = append(entries[:i], entries[i+1:]...) + break + } + } + if len(entries) == 0 { + delete(waitingRooms.entries, entry.hall) + } else { + waitingRooms.entries[entry.hall] = entries + } + entry.client.waiting = nil + entry.hall.ReleaseUsername(entry.auth.Username, entry.client) +} + +func cancelWaiting(g *hall.Hall, message string) { + waitingRooms.Lock() + cancelWaitingLocked(g, message) + waitingRooms.Unlock() +} + +func cancelWaitingLocked(g *hall.Hall, message string) { + entries := append([]*waitingEntry(nil), waitingRooms.entries[g]...) + delete(waitingRooms.entries, g) + for _, entry := range entries { + entry.client.waiting = nil + entry.hall.ReleaseUsername(entry.auth.Username, entry.client) + entry.client.SetPermissions(nil) + entry.client.SetUsername("") + sendWaiting(entry.client, "cancel", message) + } +} + +func waitingOperatorJoined(g *hall.Hall, c *webClient) { + if !member("op", c.Permissions()) { + return + } + waitingRooms.Lock() + snapshot := waitingSnapshotLocked(g) + sendWaitingList(c, "snapshot", snapshot) + waitingRooms.Unlock() +} + +func waitingHallChanged(g *hall.Hall) { + waitingRooms.Lock() + locked, hasOperator := g.WaitingStatus() + if locked && !hasOperator { + cancelWaitingLocked(g, "Waiting ended because the last operator left the hall.") + waitingRooms.Unlock() + return + } + entries := waitingRooms.entries[g] + if locked { + for _, entry := range entries { + entry.manualJoin = false + } + } + kind := "update" + if !locked { + kind = "open" + } + for _, entry := range entries { + sendWaiting(entry.client, kind, currentWaitingState(g, entry.auth.Username)) + } + waitingRooms.Unlock() + processWaiting(g) +} + +func processWaiting(g *hall.Hall) { + for { + waitingRooms.Lock() + entries := waitingRooms.entries[g] + locked, hasOperator := g.WaitingStatus() + if locked && !hasOperator { + cancelWaitingLocked(g, "Waiting ended because the last operator left the hall.") + waitingRooms.Unlock() + return + } + var entry *waitingEntry + for _, candidate := range entries { + if candidate.approved || (!locked && (candidate.autoJoin || candidate.manualJoin)) { + entry = candidate + break + } + } + if entry == nil { + waitingRooms.Unlock() + return + } + // Publish the target hall before AddAuthenticatedClient enqueues its + // joined callback; admission may run on a different client's goroutine. + entry.client.hall = g + err := hall.AddAuthenticatedClient(g, entry.client, entry.auth, entry.approved) + if err != nil { + entry.client.hall = nil + if lockedNow, _ := g.Locked(); lockedNow && !entry.approved { + entry.manualJoin = false + waitingRooms.Unlock() + sendWaiting(entry.client, "update", currentWaitingState(g, entry.auth.Username)) + return + } + if errors.Is(err, hall.UserError("too many users")) || err.Error() == "too many users" { + waitingRooms.Unlock() + return + } + removeWaitingEntryLocked(entry) + entry.client.SetPermissions(nil) + entry.client.SetUsername("") + notifyWaitingOperators(g, "remove", waitingUser{Username: entry.auth.Username}) + waitingRooms.Unlock() + sendWaiting(entry.client, "cancel", "Admission cannot continue: "+err.Error()) + continue + } + removeWaitingEntryLocked(entry) + notifyWaitingOperators(g, "remove", waitingUser{Username: entry.auth.Username}) + waitingRooms.Unlock() + waitingOperatorJoined(g, entry.client) + } +} + +// WaitingCapacityChanged retries pending admissions after a non-WebSocket +// client leaves the hall. +func WaitingCapacityChanged(g *hall.Hall) { + processWaiting(g) +} + +func updateWaitingPreference(c *webClient, autoJoin bool) error { + waitingRooms.Lock() + if c.waiting == nil { + waitingRooms.Unlock() + return hall.UserError("you are not in a waiting room") + } + c.waiting.autoJoin = autoJoin + g := c.waiting.hall + waitingRooms.Unlock() + if autoJoin { + processWaiting(g) + } + return nil +} + +func requestWaitingAdmission(c *webClient) error { + waitingRooms.Lock() + if c.waiting == nil { + waitingRooms.Unlock() + return hall.UserError("you are not in a waiting room") + } + g := c.waiting.hall + waitingRooms.Unlock() + locked, _ := g.Locked() + if locked { + return hall.UserError("the hall is still locked") + } + waitingRooms.Lock() + if c.waiting != nil { + c.waiting.manualJoin = true + } + waitingRooms.Unlock() + processWaiting(g) + return nil +} + +func admitWaitingClient(operator *webClient, username string) error { + if operator.hall == nil || !member("op", operator.Permissions()) { + return hall.UserError("not authorised") + } + g := operator.hall + waitingRooms.Lock() + var found *waitingEntry + for _, entry := range waitingRooms.entries[g] { + if entry.auth.Username == username { + entry.approved = true + found = entry + break + } + } + snapshot := waitingSnapshotLocked(g) + if found != nil { + notifyWaitingOperators(g, "snapshot", snapshot) + } + waitingRooms.Unlock() + if found == nil { + return hall.UserError("no such waiting user") + } + sendWaiting(found.client, "approved", currentWaitingState(g, username)) + processWaiting(g) + return nil +} diff --git a/rtpconn/waiting_test.go b/rtpconn/waiting_test.go new file mode 100644 index 0000000..6935cb1 --- /dev/null +++ b/rtpconn/waiting_test.go @@ -0,0 +1,315 @@ +package rtpconn + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.stormux.org/storm/skald/hall" +) + +func newWaitingTestHall(t *testing.T, maxClients int) (*hall.Hall, string) { + t.Helper() + name := "waiting-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name()) + oldDirectory := hall.Directory + hall.Directory = t.TempDir() + t.Cleanup(func() { hall.Directory = oldDirectory }) + password := testPassword() + desc := &hall.Description{ + DisplayName: "Stormux", MaxClients: maxClients, + Users: map[string]hall.UserDescription{ + "Operator": {Password: password, Permissions: makePermission(t, "op")}, + "Operator2": {Password: password, Permissions: makePermission(t, "op")}, + "Alice": {Password: password, Permissions: makePermission(t, "present")}, + "Bob": {Password: password, Permissions: makePermission(t, "present")}, + }, + } + data, err := json.Marshal(desc) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hall.Directory, name+".json"), data, 0o600); err != nil { + t.Fatal(err) + } + g, err := hall.Add(name, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cancelScheduledHallUnlock(g) + cancelWaiting(g, "test cleanup") + for _, client := range g.GetClients(nil) { + hall.DelClient(client) + } + hall.Delete(name) + }) + return g, name +} + +func joinWaitingTestClient(t *testing.T, name, username string) *webClient { + t.Helper() + c := testWebClient(username + "-waiting-id") + err := handleClientMessage(c, clientMessage{ + Type: "join", Kind: "join", Hall: name, + Username: &username, Password: "pw", + }) + if err != nil { + t.Fatalf("waiting join: %v", err) + } + drainActions(t, c) + return c +} + +func TestWaitingClientAuthenticatedWithoutMembership(t *testing.T) { + g, name := newWaitingTestHall(t, 0) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + + waiter := joinWaitingTestClient(t, name, "Alice") + if waiter.hall != nil || waiter.waiting == nil { + t.Fatalf("waiting client membership: hall=%v waiting=%v", waiter.hall, waiter.waiting) + } + if waiter.Username() != "Alice" || waiter.waiting.auth.Username != "Alice" { + t.Fatalf("authenticated identity was not retained: %#v", waiter.waiting.auth) + } + if g.GetClient(waiter.Id()) != nil { + t.Fatal("waiting client was visible as a hall member") + } + if err := handleClientMessage(waiter, clientMessage{Type: "chat", Value: "private"}); err != nil { + t.Fatalf("isolated chat attempt returned fatal error: %v", err) + } + if len(g.GetChatHistory()) != 0 { + t.Fatal("waiting client wrote to hall chat") + } + duplicate := testWebClient("direct-duplicate-id") + username := "Alice" + if _, err := hall.AddClient(name, duplicate, hall.ClientCredentials{ + Username: &username, Password: "pw", + }); err == nil { + t.Fatal("waiting username was not reserved against direct admission") + } + messages := drainMessages(waiter) + foundEnter := false + for _, message := range messages { + if message.Type == "waiting" && message.Kind == "enter" { + foundEnter = true + } + if message.Type == "user" || message.Type == "chat" || message.Type == "chathistory" || message.Type == "offer" || + (message.Type == "usermessage" && (message.Kind == "recording" || message.Kind == "chalkboard")) { + t.Fatalf("waiting client received hall-only message: %#v", message) + } + } + if !foundEnter { + t.Fatalf("missing waiting enter event: %#v", messages) + } +} + +func TestWaitingManualAdmissionAndLastOperatorCancellation(t *testing.T) { + g, name := newWaitingTestHall(t, 0) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + waiter := joinWaitingTestClient(t, name, "Alice") + + if err := admitWaitingClient(operator, "Alice"); err != nil { + t.Fatal(err) + } + drainActions(t, waiter) + if waiter.hall != g || waiter.waiting != nil { + t.Fatalf("operator admission did not join: hall=%v waiting=%v", waiter.hall, waiter.waiting) + } + + second := joinWaitingTestClient(t, name, "Bob") + leaveHall(operator) + drainActions(t, second) + if second.waiting != nil { + t.Fatal("waiting entry remained after last operator left") + } + if second.Username() != "" || len(second.Permissions()) != 0 { + t.Fatal("cancelled waiting client retained its authenticated identity") + } + foundCancel := false + for _, message := range drainMessages(second) { + if message.Type == "waiting" && message.Kind == "cancel" { + foundCancel = true + } + } + if !foundCancel { + t.Fatal("waiting client did not receive cancellation") + } +} + +func TestWaitingAutomaticAdmissionRetriesInOrderAtCapacity(t *testing.T) { + g, name := newWaitingTestHall(t, 2) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + alice := joinWaitingTestClient(t, name, "Alice") + bob := joinWaitingTestClient(t, name, "Bob") + if err := updateWaitingPreference(alice, true); err != nil { + t.Fatal(err) + } + if err := updateWaitingPreference(bob, true); err != nil { + t.Fatal(err) + } + setHallLockState(g, false, "") + drainActions(t, alice) + drainActions(t, bob) + if alice.hall != g || bob.hall != nil || bob.waiting == nil { + t.Fatalf("unexpected first admission: alice=%v bob=%v bobWaiting=%v", alice.hall, bob.hall, bob.waiting) + } + leaveHall(alice) + drainActions(t, bob) + if bob.hall != g || bob.waiting != nil { + t.Fatalf("capacity retry did not admit Bob: hall=%v waiting=%v", bob.hall, bob.waiting) + } +} + +func TestWaitingAdmissionRequiresOperator(t *testing.T) { + g, name := newWaitingTestHall(t, 0) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + alice := joinWaitingTestClient(t, name, "Alice") + if err := admitWaitingClient(alice, "Alice"); err == nil { + t.Fatal("waiting user admitted themself") + } +} + +func TestWaitingRequiresOperatorAndReservesUsername(t *testing.T) { + g, name := newWaitingTestHall(t, 0) + g.SetLocked(true, "") + alice := joinWaitingTestClient(t, name, "Alice") + if alice.waiting != nil { + t.Fatal("client waited without an active operator") + } + foundFailure := false + for _, message := range drainMessages(alice) { + if message.Type == "joined" && message.Kind == "fail" { + foundFailure = true + } + } + if !foundFailure { + t.Fatal("locked hall without an operator did not reject the join") + } + + operator := addTestWebClient(t, name, "Operator", "op") + drainActions(t, operator) + first := joinWaitingTestClient(t, name, "Alice") + second := joinWaitingTestClient(t, name, "Alice") + if first.waiting == nil || second.waiting != nil { + t.Fatalf("username reservation failed: first=%v second=%v", first.waiting, second.waiting) + } + removeWaitingClient(first, true) + third := joinWaitingTestClient(t, name, "Alice") + if third.waiting == nil { + t.Fatal("username was not released after waiting client departed") + } +} + +func TestWaitingRelockDisablesManualAdmission(t *testing.T) { + g, name := newWaitingTestHall(t, 1) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + alice := joinWaitingTestClient(t, name, "Alice") + setHallLockState(g, false, "") + setHallLockState(g, true, "") + if err := requestWaitingAdmission(alice); err == nil { + t.Fatal("manual admission succeeded after the hall was relocked") + } + if alice.hall != nil || alice.waiting == nil { + t.Fatalf("relocked client left waiting state: hall=%v waiting=%v", alice.hall, alice.waiting) + } +} + +func TestWaitingManualJoinAfterOpening(t *testing.T) { + g, name := newWaitingTestHall(t, 0) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + alice := joinWaitingTestClient(t, name, "Alice") + setHallLockState(g, false, "") + if err := requestWaitingAdmission(alice); err != nil { + t.Fatal(err) + } + drainActions(t, alice) + if alice.hall != g || alice.waiting != nil { + t.Fatalf("manual join did not complete: hall=%v waiting=%v", alice.hall, alice.waiting) + } +} + +func TestWaitingApprovalSurvivesCapacityAndRelock(t *testing.T) { + g, name := newWaitingTestHall(t, 2) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + alice := joinWaitingTestClient(t, name, "Alice") + bob := joinWaitingTestClient(t, name, "Bob") + if err := admitWaitingClient(operator, "Alice"); err != nil { + t.Fatal(err) + } + drainActions(t, alice) + if alice.hall != g { + t.Fatal("first approved user did not fill available capacity") + } + if err := admitWaitingClient(operator, "Bob"); err != nil { + t.Fatal(err) + } + if bob.waiting == nil || !bob.waiting.approved { + t.Fatal("approval was not retained while the hall was full") + } + setHallLockState(g, false, "") + setHallLockState(g, true, "") + leaveHall(alice) + drainActions(t, bob) + if bob.hall != g || bob.waiting != nil { + t.Fatalf("approved user was not admitted after capacity opened: hall=%v waiting=%v", bob.hall, bob.waiting) + } +} + +func TestWaitingCancelledWhenLastOperatorLosesPermission(t *testing.T) { + g, name := newWaitingTestHall(t, 0) + operator := addTestWebClient(t, name, "Operator", "op") + g.SetLocked(true, "") + drainActions(t, operator) + alice := joinWaitingTestClient(t, name, "Alice") + if err := handleAction(operator, changePermissionsAction{kind: "unop"}); err != nil { + t.Fatal(err) + } + // Exercise the window before permissionsChangedAction is processed. + WaitingCapacityChanged(g) + drainActions(t, operator) + drainActions(t, alice) + if alice.waiting != nil { + t.Fatal("waiting continued after the last operator lost permission") + } +} + +func TestWaitingScheduleRemovedWhenIssuerLeaves(t *testing.T) { + g, name := newWaitingTestHall(t, 0) + issuer := addTestWebClient(t, name, "Operator", "op") + other := addTestWebClient(t, name, "Operator2", "op") + g.SetLocked(true, "") + drainActions(t, issuer) + drainActions(t, other) + waiter := joinWaitingTestClient(t, name, "Alice") + now := unlockNow() + if err := scheduleHallUnlock(g, issuer, now.Add(10*time.Minute), now); err != nil { + t.Fatal(err) + } + if _, ok := scheduledUnlockDeadline(g); !ok { + t.Fatal("scheduled deadline was not recorded") + } + leaveHall(issuer) + if _, ok := scheduledUnlockDeadline(g); ok { + t.Fatal("scheduled deadline remained after issuer departure") + } + if waiter.waiting == nil { + t.Fatal("waiter was cancelled even though another operator remained") + } +} diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index f0c4c2a..dde8524 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -127,6 +127,8 @@ type webClient struct { writerDone chan struct{} actions *unbounded.Channel[any] permissionsMu sync.RWMutex + identityMu sync.RWMutex + waiting *waitingEntry mu sync.Mutex down map[string]*rtpDownConnection @@ -146,11 +148,15 @@ func (c *webClient) Id() string { } func (c *webClient) Username() string { + c.identityMu.RLock() + defer c.identityMu.RUnlock() return c.username } func (c *webClient) SetUsername(username string) { + c.identityMu.Lock() c.username = username + c.identityMu.Unlock() } func (c *webClient) Permissions() []string { @@ -857,7 +863,7 @@ func readMessage(conn *websocket.Conn, m *clientMessage) error { } const maxWSMessageSize = 1024 * 1024 -const protocolVersion = "2" +const protocolVersion = "3" func StartClient(conn *websocket.Conn, addr net.Addr) (err error) { var m clientMessage @@ -1187,6 +1193,9 @@ func handleAction(c *webClient, a any) error { } case pushClientAction: + // Admission may be running on another client's goroutine. Taking + // the waiting lock is a barrier for c.hall and registry publication. + waitingClientHall(c) if a.hall != c.hall.Name() { log.Printf("got client for wrong hall") return nil @@ -1202,6 +1211,7 @@ func handleAction(c *webClient, a any) error { Data: a.data, }) case joinedAction: + waitingClientHall(c) var status *hall.Status var data map[string]interface{} var g *hall.Hall @@ -1215,7 +1225,7 @@ func handleAction(c *webClient, a any) error { } } perms := append([]string(nil), c.permissions...) - username := c.username + username := c.Username() err := c.write(clientMessage{ Type: "joined", Kind: a.kind, @@ -1295,9 +1305,14 @@ func handleAction(c *webClient, a any) error { return errors.New("Permissions changed in no hall") } perms := c.Permissions() + if !member("op", perms) { + cancelScheduledUnlockByIssuer(g, c) + } else { + waitingOperatorJoined(g, c) + } status := g.Status(true, nil) status.Recording = hallRecording(g) - username := c.username + username := c.Username() c.write(clientMessage{ Type: "joined", Kind: "change", @@ -1332,12 +1347,15 @@ func handleAction(c *webClient, a any) error { ) } }(clients) + waitingHallChanged(g) case kickAction: return hall.KickError{ Id: a.id, Username: a.username, Message: a.message, } + case waitingEventAction: + return c.write(a.message) default: log.Printf("unexpected action %T", a) return errors.New("unexpected action") @@ -1365,6 +1383,11 @@ func failUpConnection(c *webClient, id string, message string) error { } func leaveHall(c *webClient) { + if removeWaitingClient(c, true) { + c.SetPermissions(nil) + c.SetUsername("") + return + } if c.hall == nil { return } @@ -1380,11 +1403,14 @@ func leaveHall(c *webClient) { } } + g := c.hall + cancelScheduledUnlockByIssuer(g, c) hall.DelClient(c) c.SetPermissions(nil) c.data = nil c.requested = make(map[string][]string) c.hall = nil + waitingHallChanged(g) } func closeDownConn(c *webClient, id string, message string) error { @@ -1441,10 +1467,19 @@ func handleClientMessage(c *webClient, m clientMessage) error { } } } + if waitingClientHall(c) != nil && m.Type != "waiting" && m.Type != "join" && m.Type != "pong" { + return c.error(hall.UserError("you have not been admitted to the hall")) + } switch m.Type { case "join": if m.Kind == "leave" { + if waitingHall := waitingClientHall(c); waitingHall != nil && waitingHall.Name() == m.Hall { + removeWaitingClient(c, true) + c.SetUsername("") + c.SetPermissions(nil) + return c.write(clientMessage{Type: "waiting", Kind: "leave", Hall: m.Hall}) + } if c.hall == nil || c.hall.Name() != m.Hall { return hall.UserError("you are not joined") } @@ -1456,13 +1491,13 @@ func handleClientMessage(c *webClient, m clientMessage) error { return hall.ProtocolError("unknown kind") } - if c.hall != nil { + if c.hall != nil || waitingClientHall(c) != nil { return hall.ProtocolError( "cannot join multiple halls", ) } c.data = m.Data - g, err := hall.AddClient(m.Hall, c, + g, auth, err := hall.AuthenticateClient(m.Hall, hall.ClientCredentials{ Username: m.Username, Password: m.Password, @@ -1490,7 +1525,7 @@ func handleClientMessage(c *webClient, m clientMessage) error { s = "internal server error" log.Printf("Join hall: %v", err) } - username := c.username + username := c.Username() return c.write(clientMessage{ Type: "joined", Kind: "fail", @@ -1503,7 +1538,7 @@ func handleClientMessage(c *webClient, m clientMessage) error { if redirect := g.Description().Redirect; redirect != "" { // We normally redirect at the HTTP level, but the hall // description could have been edited in the meantime. - username := c.username + username := c.Username() return c.write(clientMessage{ Type: "joined", Kind: "redirect", @@ -1512,7 +1547,60 @@ func handleClientMessage(c *webClient, m clientMessage) error { Value: redirect, }) } + if !member("op", auth.Permissions) { + if err := addWaitingClient(c, g, auth); err == nil { + return nil + } else if !errors.Is(err, errWaitingUnavailable) { + return c.write(clientMessage{ + Type: "joined", Kind: "fail", Hall: m.Hall, + Username: &auth.Username, Value: err.Error(), + }) + } + } + if err := hall.AddAuthenticatedClient(g, c, auth, false); err != nil { + username := auth.Username + return c.write(clientMessage{ + Type: "joined", Kind: "fail", Hall: m.Hall, + Username: &username, Value: err.Error(), + }) + } c.hall = g + waitingOperatorJoined(g, c) + case "waiting": + switch m.Kind { + case "auto": + autoJoin, ok := m.Value.(bool) + if !ok { + return c.error(hall.UserError("invalid automatic join preference")) + } + if err := updateWaitingPreference(c, autoJoin); err != nil { + return c.error(err) + } + return nil + case "join": + if err := requestWaitingAdmission(c); err != nil { + return c.error(err) + } + return nil + case "leave": + if !removeWaitingClient(c, true) { + return c.error(hall.UserError("you are not in a waiting room")) + } + c.SetUsername("") + c.SetPermissions(nil) + return c.write(clientMessage{Type: "waiting", Kind: "leave"}) + case "admit": + username, ok := m.Value.(string) + if !ok || username == "" { + return c.error(hall.UserError("invalid waiting username")) + } + if err := admitWaitingClient(c, username); err != nil { + return c.error(err) + } + return nil + default: + return hall.ProtocolError("unknown waiting action") + } case "request": requested, err := parseRequested(m.Request) if err != nil { @@ -1877,7 +1965,7 @@ func handleClientMessage(c *webClient, m clientMessage) error { } } - user := c.username + user := c.Username() if user != "" { tok.IssuedBy = &user } diff --git a/rtpconn/whipclient.go b/rtpconn/whipclient.go index 46ea2a1..c3724a8 100644 --- a/rtpconn/whipclient.go +++ b/rtpconn/whipclient.go @@ -137,6 +137,7 @@ func (c *WhipClient) Close() error { c.connection = nil } hall.DelClient(c) + WaitingCapacityChanged(g) c.hall = nil return nil } diff --git a/skald-client.md b/skald-client.md index d284321..8c1323d 100644 --- a/skald-client.md +++ b/skald-client.md @@ -115,6 +115,15 @@ serverConnection.onjoined = function(kind, hall, perms, status, data, error, mes Once you have joined a hall, you send chat messages with the `chat` method of the `ServerConnection` class. No permission is needed to do that. +If authentication succeeds but admission is delayed by a locked hall, +`onwaiting(kind, state)` reports waiting-room entry and state changes. Use +`waitingAction('auto', boolean)`, `waitingAction('join')`, or +`waitingAction('leave')` to change the automatic-entry preference, request +entry after opening, or leave. Operators receive ordered waiting-list changes +through `onwaitinglist(kind, value)` and admit a username with +`waitingAction('admit', username)`. Waiting connections are not hall members, +so hall user, chat, history, and media callbacks do not run for them. + ```javascript serverConnection.chat(username, '', id, 'Hi!'); ``` diff --git a/skald-protocol.md b/skald-protocol.md index 678ae0c..37142ac 100644 --- a/skald-protocol.md +++ b/skald-protocol.md @@ -95,7 +95,7 @@ start pipelining messages to the server. ```javascript { type: 'handshake', - version: ["2"], + version: ["3"], id: id } ``` @@ -105,6 +105,8 @@ decreasing preference order; the client may announce multiple versions, but the server will always reply with a single version. If the field `id` is absent, then the peer doesn't originate streams. +Protocol version 3 adds authenticated waiting-room messages and actions. + A peer may, at any time, send a `ping` message. ```javascript @@ -136,7 +138,7 @@ The `join` message requests that the sender join or leave a hall: } ``` -If token-based authorisation is beling used, then the `username` and +If token-based authorisation is being used, then the `username` and `password` fields are omitted, and a `token` field is included instead. When the sender has effectively joined the hall, the peer will send @@ -164,6 +166,53 @@ values `present`, `op` and `record`. The `status` field is a dictionary that contains status information about the hall, and updates the data obtained from the `.status` URL described above. +### Waiting-room authentication and admission + +When a non-operator authenticates while the hall is locked and an operator +is present, authentication succeeds without hall membership. The server +sends a `waiting` message of kind `enter`; subsequent locked-state changes use +kind `update`, and opening uses kind `open`. The value contains the internal hall name, display name, +authenticated username, lock state, and an optional RFC 3339 deadline: + +```javascript +{ + type: 'waiting', + kind: 'enter' or 'update' or 'open' or 'approved', + value: { + hall: hall, + displayName: displayName, + username: username, + locked: true, + deadline: optionalDeadline + } +} +``` + +`cancel` ends waiting with its reason in `value`; `leave` confirms a +voluntary departure. A waiting client changes its automatic-join preference, +requests entry after opening, or leaves with: + +```javascript +{type: 'waiting', kind: 'auto', value: trueOrFalse} +{type: 'waiting', kind: 'join'} +{type: 'waiting', kind: 'leave'} +``` + +Waiting clients are authenticated but are not hall members. They receive no +user list, chat or chat history, media, chalkboard, or recording state. + +Operators receive `waiting-list` messages. `snapshot` has an array of +`{username, approved}` values; `add` and `remove` contain one such value. +An operator admits one user with: + +```javascript +{type: 'waiting', kind: 'admit', value: username} +``` + +Admission approval bypasses only the hall lock. Authentication, hall time +restrictions, and capacity still apply. An approval that cannot fit remains +queued and is retried when capacity becomes available. + ## Maintaining hall membership Whenever a user joins or leaves a hall, the server will send all other diff --git a/skald.md b/skald.md index e8a6654..ca00db9 100644 --- a/skald.md +++ b/skald.md @@ -30,7 +30,26 @@ of a user list, chat, and connection controls: hall; every username doubles as a menu of hall and user actions; - the chat pane contains the messages published to the hall; - the controls area contains login, connection, microphone, and settings - controls. +controls. + +### Waiting for a locked hall + +If a non-operator signs in while a hall is locked and an operator is present, +Skald opens a waiting room. The waiting page names the hall and shows either +the scheduled unlock time remaining, rounded to minutes, or states that an +operator is present without an unlock time. Waiting users cannot hear hall +audio or access its participants, chat, history, or recording state. + +The *Join hall automatically when it unlocks* checkbox starts unchecked on +every visit. If selected, Skald joins in waiting-room arrival order when the +hall opens and space is available. Otherwise, the *Join hall* button becomes +available when the hall opens. *Leave waiting room* returns to sign-in. + +While a hall is locked, operators see a *Waiting room (count)* panel beneath +the participant list. Each waiting username has an *Admit* button. Admission +approval remains pending if the hall is full and completes when space becomes +available. Unlocking the hall allows all waiting users to enter according to +their automatic or manual choice. On mobile, the interface is collapsed to fit the smaller screen. The top controls expose the user list, chat, and settings. diff --git a/static/protocol.js b/static/protocol.js index 18170ae..8635d41 100644 --- a/static/protocol.js +++ b/static/protocol.js @@ -201,6 +201,10 @@ function ServerConnection() { * @type{(this: ServerConnection, kind: string, hall: string, permissions: Array, status: Object, data: Object, error: string, message: string) => void} */ this.onjoined = null; + /** Called for authenticated waiting-room state changes. */ + this.onwaiting = null; + /** Called for operator-only waiting-list changes. */ + this.onwaitinglist = null; /** * ondownstream is called whenever a new down stream is added. It * should set up the stream's callbacks; actually setting up the UI @@ -342,7 +346,7 @@ ServerConnection.prototype.connect = function(url) { try { sc.send({ type: 'handshake', - version: ['2'], + version: ['3'], id: sc.id, }); } catch(e) { @@ -391,8 +395,8 @@ ServerConnection.prototype.connect = function(url) { sc.lastServerMessage = new Date().valueOf(); switch(m.type) { case 'handshake': { - if((m.version instanceof Array) && m.version.includes('2')) { - sc.version = '2'; + if((m.version instanceof Array) && m.version.includes('3')) { + sc.version = '3'; } else { sc.version = null; sc.error(new Error(`Unknown protocol version ${m.version}`)); @@ -449,6 +453,21 @@ ServerConnection.prototype.connect = function(url) { m.status, m.data, m.error || null, m.value || null); break; + case 'waiting': + if(m.kind === 'enter' && m.value) { + sc.username = m.value.username; + sc.permissions = []; + } else if(m.kind === 'leave' || m.kind === 'cancel') { + sc.username = null; + sc.permissions = []; + } + if(sc.onwaiting) + sc.onwaiting.call(sc, m.kind, m.value || null); + break; + case 'waiting-list': + if(sc.onwaitinglist) + sc.onwaitinglist.call(sc, m.kind, m.value || null); + break; case 'user': let user = null; switch(m.kind) { @@ -651,6 +670,13 @@ ServerConnection.prototype.leave = function(hall) { }); }; +ServerConnection.prototype.waitingAction = function(kind, value) { + let message = {type: 'waiting', kind: kind}; + if(typeof value !== 'undefined') + message.value = value; + this.send(message); +}; + /** * request sets the list of requested tracks * diff --git a/static/skald.css b/static/skald.css index e51f16e..ba89109 100644 --- a/static/skald.css +++ b/static/skald.css @@ -982,6 +982,8 @@ legend { #left-sidebar { min-width: 200px; max-width: 200px; + display: flex; + flex-direction: column; transition: all 0.3s; background: #ffffff; border-right: 1px solid #dcdcdc; @@ -1035,7 +1037,8 @@ header .collapse:hover { #users { padding: 0; margin: 0; - height: calc(100% - 84px); + min-height: 0; + flex: 1 1 auto; width: 100%; position: relative; display: block; @@ -1044,6 +1047,28 @@ header .collapse:hover { border: 1px solid #f7f7f7; } +#waiting-list-panel { + padding: 0.75rem; + border-top: 1px solid #ddd; + background: #fff; + max-height: 40%; + overflow-y: auto; + flex: 0 1 auto; +} + +#waiting-list-panel h2 { + font-size: 1rem; + margin: 0 0 0.5rem; +} + +.waiting-user { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin: 0.4rem 0; +} + #users .user-p { display: block; position: relative; diff --git a/static/skald.html b/static/skald.html index 9f5b1ad..bd2d9ee 100644 --- a/static/skald.html +++ b/static/skald.html @@ -23,6 +23,10 @@
+
+ diff --git a/static/skald.js b/static/skald.js index 7415b17..2007257 100644 --- a/static/skald.js +++ b/static/skald.js @@ -71,6 +71,10 @@ let token = null; */ let probingState = null; +let waitingState = null; +let waitingCountdownTimer = null; +let waitingUsers = new Map(); + /** * @typedef {Object} settings - the type of stored settings * @property {boolean} [localMute] @@ -516,12 +520,138 @@ function setConnected(connected) { } else { closeNav(); userbox.classList.add('invisible'); - connectionbox.classList.remove('invisible'); + if(!waitingState) + connectionbox.classList.remove('invisible'); ; window.onresize = null; } } +function waitingHallName(state = waitingState) { + return state && state.displayName ? state.displayName : + (hallStatus.displayName || capitalise(hall)); +} + +function updateWaitingStatus(announceSchedule = false) { + if(!waitingState) + return; + let name = waitingHallName(); + let status; + if(!waitingState.locked) { + status = `The ${name} hall is open. You may join now.`; + } else if(waitingState.deadline) { + let remaining = Math.max(1, Math.ceil( + (new Date(waitingState.deadline).getTime() - Date.now()) / 60000, + )); + status = `The ${name} hall is locked. It is scheduled to unlock in ${remaining} minute${remaining === 1 ? '' : 's'}.`; + } else { + status = `The ${name} hall is locked. An operator is present; no unlock time is set.`; + } + document.getElementById('waiting-status').textContent = status; + getButtonElement('waiting-join').disabled = !!waitingState.locked; + if(announceSchedule) + announceChat(status); +} + +function showWaitingRoom(state) { + waitingState = state; + token = null; + getInputElement('waiting-auto-join').checked = false; + document.getElementById('login-container').classList.add('invisible'); + document.getElementById('waiting-room').classList.remove('invisible'); + document.getElementById('waiting-heading').textContent = + `Waiting for the ${waitingHallName(state)} hall`; + updateWaitingStatus(false); + if(waitingCountdownTimer) + clearInterval(waitingCountdownTimer); + waitingCountdownTimer = setInterval(() => updateWaitingStatus(false), 30000); + document.getElementById('waiting-heading').focus({preventScroll: true}); + announceChat(`You are in the waiting room for the ${waitingHallName(state)} hall.`); +} + +function hideWaitingRoom() { + if(waitingCountdownTimer) { + clearInterval(waitingCountdownTimer); + waitingCountdownTimer = null; + } + waitingState = null; + document.getElementById('waiting-room').classList.add('invisible'); +} + +function gotWaiting(kind, state) { + switch(kind) { + case 'enter': + showWaitingRoom(state); + break; + case 'update': + case 'open': { + let oldDeadline = waitingState && waitingState.deadline; + let wasLocked = waitingState && waitingState.locked; + waitingState = state; + updateWaitingStatus(oldDeadline !== state.deadline || wasLocked !== state.locked); + break; + } + case 'approved': + waitingState = state; + announceChat(`An operator admitted you to the ${waitingHallName(state)} hall.`); + break; + case 'cancel': + hideWaitingRoom(); + announceUrgent(state || 'Waiting ended.'); + this.close(); + break; + case 'leave': + hideWaitingRoom(); + this.close(); + break; + default: + console.warn(`Unknown waiting-room event ${kind}`); + } +} + +function renderWaitingList() { + let panel = document.getElementById('waiting-list-panel'); + let list = document.getElementById('waiting-list'); + let isOperator = serverConnection && + serverConnection.permissions.indexOf('op') >= 0; + let visible = isOperator && !!hallStatus.locked; + panel.classList.toggle('invisible', !visible); + document.getElementById('waiting-list-heading').textContent = + `Waiting room (${waitingUsers.size})`; + list.replaceChildren(); + if(!visible) + return; + for(let user of waitingUsers.values()) { + let row = document.createElement('div'); + row.className = 'waiting-user'; + let name = document.createElement('span'); + name.textContent = user.username + (user.approved ? ' (admission approved)' : ''); + let button = document.createElement('button'); + button.type = 'button'; + button.textContent = user.approved ? 'Approved' : 'Admit'; + button.disabled = !!user.approved; + button.setAttribute('aria-label', `Admit ${user.username}`); + button.onclick = () => serverConnection.waitingAction('admit', user.username); + row.append(name, button); + list.appendChild(row); + } +} + +function gotWaitingList(kind, value) { + if(kind === 'snapshot') { + waitingUsers.clear(); + for(let user of value || []) + waitingUsers.set(user.username, user); + } else if(kind === 'add' && value) { + waitingUsers.set(value.username, value); + announceChat(`${value.username} entered the waiting room.`); + } else if(kind === 'remove' && value) { + waitingUsers.delete(value.username); + announceChat(`${value.username} left the waiting room.`); + } + renderWaitingList(); +} + /** * Called when we connect to the server. * @@ -635,6 +765,7 @@ function onPeerConnection() { function gotClose(code, reason) { closeUpMedia(); closeSafariStream(); + hideWaitingRoom(); setConnected(false); resetChalkboard(); if(code != 1000) { @@ -2211,6 +2342,9 @@ async function gotJoined(kind, hall, perms, status, data, error, message) { } else { token = null; } + if(kind === 'join' && waitingState) + announceChat(`Joining the ${waitingHallName()} hall.`); + hideWaitingRoom(); // don't discard endPoint and friends hallStatus.locked = false; hallStatus.recording = false; @@ -2225,6 +2359,7 @@ async function gotJoined(kind, hall, perms, status, data, error, message) { setChangePassword(pwAuth && !!hallStatus.canChangePassword && serverConnection.username ); + renderWaitingList(); openSafariStream(); window.setTimeout(() => { userNotificationSoundsReady = true; @@ -2273,6 +2408,14 @@ async function gotJoined(kind, hall, perms, status, data, error, message) { ); } } + let displayName = hallStatus.displayName || capitalise(hall); + let microphone = findUpMedia('audio'); + if(!microphone) { + announceChat(`Joined the ${displayName} hall. Your microphone is not enabled.`); + } else { + let enabled = microphone.stream && microphone.stream.getAudioTracks().some(track => track.enabled); + announceChat(`Joined the ${displayName} hall. Your microphone is ${enabled ? 'unmuted' : 'muted'}.`); + } } /** @@ -4069,6 +4212,18 @@ document.getElementById('disconnectbutton').onclick = function(e) { closeNav(); }; +getInputElement('waiting-auto-join').onchange = function() { + serverConnection.waitingAction('auto', this.checked); +}; + +getButtonElement('waiting-join').onclick = function() { + serverConnection.waitingAction('join'); +}; + +getButtonElement('waiting-leave').onclick = function() { + serverConnection.waitingAction('leave'); +}; + /** * @param {HTMLElement} elt * @param {boolean} hidden @@ -4186,6 +4341,8 @@ async function serverConnect() { serverConnection.ondownstream = gotDownStream; serverConnection.onuser = gotUser; serverConnection.onjoined = gotJoined; + serverConnection.onwaiting = gotWaiting; + serverConnection.onwaitinglist = gotWaitingList; serverConnection.onchat = addToChatbox; serverConnection.onusermessage = gotUserMessage; serverConnection.onfiletransfer = gotFileTransfer; diff --git a/webserver/static_accessibility_test.go b/webserver/static_accessibility_test.go index 661f9a8..ea8d6ed 100644 --- a/webserver/static_accessibility_test.go +++ b/webserver/static_accessibility_test.go @@ -145,6 +145,26 @@ func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) { requireFunctionContains(t, js, "userMenu", "Restart audio connection") } +func TestWaitingRoomUsesAccessibleNativeControlsAndLiveRegions(t *testing.T) { + html := readStaticFile(t, "skald.html") + js := readStaticFile(t, "skald.js") + + requireContains(t, html, `aria-labelledby="waiting-heading"`, "waiting room") + requireContains(t, html, `id="waiting-heading" tabindex="-1"`, "waiting heading") + requireContains(t, html, `label for="waiting-auto-join"`, "automatic join label") + requireContains(t, html, `id="waiting-join" type="button"`, "join button") + requireContains(t, html, `id="waiting-leave" type="button"`, "leave button") + requireContains(t, html, `aria-labelledby="waiting-list-heading"`, "operator waiting list") + requireFunctionContains(t, js, "showWaitingRoom", "waiting-heading').focus") + requireFunctionContains(t, js, "updateWaitingStatus", "Math.ceil") + requireFunctionContains(t, js, "updateWaitingStatus", "announceChat(status)") + requireFunctionContains(t, js, "gotWaiting", "announceUrgent") + requireFunctionContains(t, js, "renderWaitingList", "document.createElement('button')") + requireFunctionContains(t, js, "renderWaitingList", "button.type = 'button'") + requireFunctionContains(t, js, "gotJoined", "Your microphone is not enabled") + requireFunctionContains(t, js, "gotJoined", "Your microphone is ${enabled ? 'unmuted' : 'muted'}") +} + func TestRecordingControlLivesInSelfMenu(t *testing.T) { js := readStaticFile(t, "skald.js") diff --git a/webserver/whip.go b/webserver/whip.go index e5467f8..d993031 100644 --- a/webserver/whip.go +++ b/webserver/whip.go @@ -230,6 +230,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) { if !canPresent(c.Permissions()) { hall.DelClient(c) + rtpconn.WaitingCapacityChanged(g) http.Error(w, "Forbidden", http.StatusForbidden) return } @@ -239,6 +240,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) { answer, err := c.NewConnection(r.Context(), body) if err != nil { hall.DelClient(c) + rtpconn.WaitingCapacityChanged(g) log.Printf("WHIP offer: %v", err) httpError(w, err) return