First pass at adding a waiting room.

This commit is contained in:
Storm Dragon
2026-08-30 16:56:26 -04:00
parent 9125e76d43
commit ae55982a40
17 changed files with 1254 additions and 100 deletions
@@ -2,7 +2,7 @@
# shellcheck shell=bash disable=SC2034,SC2154 # shellcheck shell=bash disable=SC2034,SC2154
pkgname=skald-git pkgname=skald-git
pkgver=2026.07.08.r2.g6a94f89 pkgver=2026.07.08.r3.g9125e76
pkgrel=1 pkgrel=1
pkgdesc='Audio-only hall-based conferencing server' pkgdesc='Audio-only hall-based conferencing server'
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+6
View File
@@ -133,6 +133,12 @@ type ClientCredentials struct {
Token string Token string
} }
// AuthenticatedClient is the credential-free result of authentication.
type AuthenticatedClient struct {
Username string
Permissions []string
}
type Client interface { type Client interface {
Hall() *Hall Hall() *Hall
Addr() net.Addr Addr() net.Addr
+142 -83
View File
@@ -105,6 +105,7 @@ type Hall struct {
description *Description description *Description
locked *string locked *string
clients map[string]Client clients map[string]Client
reserved map[string]Client
history []ChatHistoryEntry history []ChatHistoryEntry
timestamp time.Time timestamp time.Time
data map[string]interface{} data map[string]interface{}
@@ -232,6 +233,37 @@ func (g *Hall) ClientCount() int {
return g.clientCountUnlocked() 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 { func (g *Hall) clientCountUnlocked() int {
count := 0 count := 0
for _, c := range g.clients { for _, c := range g.clients {
@@ -252,6 +284,9 @@ func (g *Hall) mayExpire() bool {
if len(g.clients) > 0 { if len(g.clients) > 0 {
return false return false
} }
if len(g.reserved) > 0 {
return false
}
return time.Since(g.timestamp) > maxHistoryAge(g.description) 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. // Called with both halls.mu and g.mu taken.
func deleteUnlocked(g *Hall) bool { func deleteUnlocked(g *Hall) bool {
if len(g.clients) != 0 { if len(g.clients) != 0 || len(g.reserved) != 0 {
return false return false
} }
@@ -599,112 +634,136 @@ func isSystemClient(c Client) bool {
return member("system", c.Permissions()) return member("system", c.Permissions())
} }
func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) { func AuthenticateClient(name string, creds ClientCredentials) (*Hall, AuthenticatedClient, error) {
g, err := Add(hall, nil) g, err := Add(name, nil)
if err != nil { if err != nil {
return nil, err return nil, AuthenticatedClient{}, err
} }
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() 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) clients := g.getClientsUnlocked(nil)
systemClient := isSystemClient(c) if holder := g.reserved[auth.Username]; holder != nil && holder != c {
return UserError("username already in use")
if !systemClient { }
username, perms, err := g.getPermission(creds) c.SetUsername(auth.Username)
if err != nil { c.SetPermissions(auth.Permissions)
return nil, err for _, cc := range clients {
if !isSystemClient(cc) && cc.Username() == auth.Username {
return UserError("username already in use")
} }
}
c.SetUsername(username) if !member("op", auth.Permissions) {
c.SetPermissions(perms) if !bypassLock && g.locked != nil {
m := *g.locked
for _, cc := range clients { if m == "" {
if !isSystemClient(cc) && cc.Username() == username { m = "Hall is locked."
return nil, UserError("username already in use") }
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 g.description.MaxClients > 0 && g.clientCountUnlocked() >= g.description.MaxClients {
if !member("op", perms) { return UserError("too many users")
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")
}
} }
} }
id := c.Id() id := c.Id()
if id == "" { if id == "" {
return nil, errors.New("client has empty id") return errors.New("client has empty id")
} }
if g.clients[id] != nil { if g.clients[id] != nil {
return nil, ProtocolError("duplicate client id") return ProtocolError("duplicate client id")
} }
g.clients[id] = c g.clients[id] = c
delete(g.reserved, auth.Username)
g.timestamp = time.Now() g.timestamp = time.Now()
c.Joined(g.Name(), "join") c.Joined(g.Name(), "join")
u, p, s := c.Username(), c.Permissions(), c.Data()
u := c.Username() c.PushClient(g.Name(), "add", id, u, p, s)
p := c.Permissions()
s := c.Data()
if !systemClient {
c.PushClient(g.Name(), "add", c.Id(), u, p, s)
}
for _, cc := range clients { for _, cc := range clients {
pp := cc.Permissions()
uu := cc.Username()
if !isSystemClient(cc) { if !isSystemClient(cc) {
c.PushClient( c.PushClient(g.Name(), "add", cc.Id(), cc.Username(), cc.Permissions(), cc.Data())
g.Name(), "add", cc.Id(), uu, pp, 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 false
return g, nil
} }
// called locked // called locked
+32
View File
@@ -42,6 +42,20 @@ var hallUnlockSchedules = struct {
entries: make(map[*hall.Hall]*scheduledHallUnlock), 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) { func scheduledUnlockRequestFromValue(value interface{}) (scheduledUnlockRequest, error) {
v, ok := value.(map[string]interface{}) v, ok := value.(map[string]interface{})
if !ok { if !ok {
@@ -265,6 +279,7 @@ func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time
broadcastHallInfo(g, message, true) broadcastHallInfo(g, message, true)
} }
entry.mu.Unlock() entry.mu.Unlock()
waitingHallChanged(g)
return nil return nil
} }
@@ -310,6 +325,7 @@ func finishScheduledUnlock(entry *scheduledHallUnlock) {
} }
entry.mu.Unlock() entry.mu.Unlock()
hallUnlockSchedules.Unlock() hallUnlockSchedules.Unlock()
waitingHallChanged(entry.hall)
} }
func setHallLockState(g *hall.Hall, locked bool, message string) bool { 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) broadcastHallInfo(g, "Hall unlocked", false)
} }
hallUnlockSchedules.Unlock() hallUnlockSchedules.Unlock()
waitingHallChanged(g)
return entry != nil return entry != nil
} }
@@ -343,6 +360,21 @@ func cancelScheduledHallUnlock(g *hall.Hall) bool {
return entry != nil 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) { func deactivateScheduledUnlock(entry *scheduledHallUnlock) {
entry.mu.Lock() entry.mu.Lock()
entry.active = false entry.active = false
+330
View File
@@ -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
}
+315
View File
@@ -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")
}
}
+96 -8
View File
@@ -127,6 +127,8 @@ type webClient struct {
writerDone chan struct{} writerDone chan struct{}
actions *unbounded.Channel[any] actions *unbounded.Channel[any]
permissionsMu sync.RWMutex permissionsMu sync.RWMutex
identityMu sync.RWMutex
waiting *waitingEntry
mu sync.Mutex mu sync.Mutex
down map[string]*rtpDownConnection down map[string]*rtpDownConnection
@@ -146,11 +148,15 @@ func (c *webClient) Id() string {
} }
func (c *webClient) Username() string { func (c *webClient) Username() string {
c.identityMu.RLock()
defer c.identityMu.RUnlock()
return c.username return c.username
} }
func (c *webClient) SetUsername(username string) { func (c *webClient) SetUsername(username string) {
c.identityMu.Lock()
c.username = username c.username = username
c.identityMu.Unlock()
} }
func (c *webClient) Permissions() []string { func (c *webClient) Permissions() []string {
@@ -857,7 +863,7 @@ func readMessage(conn *websocket.Conn, m *clientMessage) error {
} }
const maxWSMessageSize = 1024 * 1024 const maxWSMessageSize = 1024 * 1024
const protocolVersion = "2" const protocolVersion = "3"
func StartClient(conn *websocket.Conn, addr net.Addr) (err error) { func StartClient(conn *websocket.Conn, addr net.Addr) (err error) {
var m clientMessage var m clientMessage
@@ -1187,6 +1193,9 @@ func handleAction(c *webClient, a any) error {
} }
case pushClientAction: 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() { if a.hall != c.hall.Name() {
log.Printf("got client for wrong hall") log.Printf("got client for wrong hall")
return nil return nil
@@ -1202,6 +1211,7 @@ func handleAction(c *webClient, a any) error {
Data: a.data, Data: a.data,
}) })
case joinedAction: case joinedAction:
waitingClientHall(c)
var status *hall.Status var status *hall.Status
var data map[string]interface{} var data map[string]interface{}
var g *hall.Hall var g *hall.Hall
@@ -1215,7 +1225,7 @@ func handleAction(c *webClient, a any) error {
} }
} }
perms := append([]string(nil), c.permissions...) perms := append([]string(nil), c.permissions...)
username := c.username username := c.Username()
err := c.write(clientMessage{ err := c.write(clientMessage{
Type: "joined", Type: "joined",
Kind: a.kind, Kind: a.kind,
@@ -1295,9 +1305,14 @@ func handleAction(c *webClient, a any) error {
return errors.New("Permissions changed in no hall") return errors.New("Permissions changed in no hall")
} }
perms := c.Permissions() perms := c.Permissions()
if !member("op", perms) {
cancelScheduledUnlockByIssuer(g, c)
} else {
waitingOperatorJoined(g, c)
}
status := g.Status(true, nil) status := g.Status(true, nil)
status.Recording = hallRecording(g) status.Recording = hallRecording(g)
username := c.username username := c.Username()
c.write(clientMessage{ c.write(clientMessage{
Type: "joined", Type: "joined",
Kind: "change", Kind: "change",
@@ -1332,12 +1347,15 @@ func handleAction(c *webClient, a any) error {
) )
} }
}(clients) }(clients)
waitingHallChanged(g)
case kickAction: case kickAction:
return hall.KickError{ return hall.KickError{
Id: a.id, Id: a.id,
Username: a.username, Username: a.username,
Message: a.message, Message: a.message,
} }
case waitingEventAction:
return c.write(a.message)
default: default:
log.Printf("unexpected action %T", a) log.Printf("unexpected action %T", a)
return errors.New("unexpected action") return errors.New("unexpected action")
@@ -1365,6 +1383,11 @@ func failUpConnection(c *webClient, id string, message string) error {
} }
func leaveHall(c *webClient) { func leaveHall(c *webClient) {
if removeWaitingClient(c, true) {
c.SetPermissions(nil)
c.SetUsername("")
return
}
if c.hall == nil { if c.hall == nil {
return return
} }
@@ -1380,11 +1403,14 @@ func leaveHall(c *webClient) {
} }
} }
g := c.hall
cancelScheduledUnlockByIssuer(g, c)
hall.DelClient(c) hall.DelClient(c)
c.SetPermissions(nil) c.SetPermissions(nil)
c.data = nil c.data = nil
c.requested = make(map[string][]string) c.requested = make(map[string][]string)
c.hall = nil c.hall = nil
waitingHallChanged(g)
} }
func closeDownConn(c *webClient, id string, message string) error { 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 { switch m.Type {
case "join": case "join":
if m.Kind == "leave" { 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 { if c.hall == nil || c.hall.Name() != m.Hall {
return hall.UserError("you are not joined") return hall.UserError("you are not joined")
} }
@@ -1456,13 +1491,13 @@ func handleClientMessage(c *webClient, m clientMessage) error {
return hall.ProtocolError("unknown kind") return hall.ProtocolError("unknown kind")
} }
if c.hall != nil { if c.hall != nil || waitingClientHall(c) != nil {
return hall.ProtocolError( return hall.ProtocolError(
"cannot join multiple halls", "cannot join multiple halls",
) )
} }
c.data = m.Data c.data = m.Data
g, err := hall.AddClient(m.Hall, c, g, auth, err := hall.AuthenticateClient(m.Hall,
hall.ClientCredentials{ hall.ClientCredentials{
Username: m.Username, Username: m.Username,
Password: m.Password, Password: m.Password,
@@ -1490,7 +1525,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
s = "internal server error" s = "internal server error"
log.Printf("Join hall: %v", err) log.Printf("Join hall: %v", err)
} }
username := c.username username := c.Username()
return c.write(clientMessage{ return c.write(clientMessage{
Type: "joined", Type: "joined",
Kind: "fail", Kind: "fail",
@@ -1503,7 +1538,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
if redirect := g.Description().Redirect; redirect != "" { if redirect := g.Description().Redirect; redirect != "" {
// We normally redirect at the HTTP level, but the hall // We normally redirect at the HTTP level, but the hall
// description could have been edited in the meantime. // description could have been edited in the meantime.
username := c.username username := c.Username()
return c.write(clientMessage{ return c.write(clientMessage{
Type: "joined", Type: "joined",
Kind: "redirect", Kind: "redirect",
@@ -1512,7 +1547,60 @@ func handleClientMessage(c *webClient, m clientMessage) error {
Value: redirect, 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 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": case "request":
requested, err := parseRequested(m.Request) requested, err := parseRequested(m.Request)
if err != nil { if err != nil {
@@ -1877,7 +1965,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
} }
} }
user := c.username user := c.Username()
if user != "" { if user != "" {
tok.IssuedBy = &user tok.IssuedBy = &user
} }
+1
View File
@@ -137,6 +137,7 @@ func (c *WhipClient) Close() error {
c.connection = nil c.connection = nil
} }
hall.DelClient(c) hall.DelClient(c)
WaitingCapacityChanged(g)
c.hall = nil c.hall = nil
return nil return nil
} }
+9
View File
@@ -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` 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. 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 ```javascript
serverConnection.chat(username, '', id, 'Hi!'); serverConnection.chat(username, '', id, 'Hi!');
``` ```
+51 -2
View File
@@ -95,7 +95,7 @@ start pipelining messages to the server.
```javascript ```javascript
{ {
type: 'handshake', type: 'handshake',
version: ["2"], version: ["3"],
id: id 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` but the server will always reply with a single version. If the field `id`
is absent, then the peer doesn't originate streams. 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. A peer may, at any time, send a `ping` message.
```javascript ```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. `password` fields are omitted, and a `token` field is included instead.
When the sender has effectively joined the hall, the peer will send 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 that contains status information about the hall, and updates the data
obtained from the `.status` URL described above. 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 ## Maintaining hall membership
Whenever a user joins or leaves a hall, the server will send all other Whenever a user joins or leaves a hall, the server will send all other
+20 -1
View File
@@ -30,7 +30,26 @@ of a user list, chat, and connection controls:
hall; every username doubles as a menu of hall and user actions; hall; every username doubles as a menu of hall and user actions;
- the chat pane contains the messages published to the hall; - the chat pane contains the messages published to the hall;
- the controls area contains login, connection, microphone, and settings - 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 On mobile, the interface is collapsed to fit the smaller screen. The top
controls expose the user list, chat, and settings. controls expose the user list, chat, and settings.
+29 -3
View File
@@ -201,6 +201,10 @@ function ServerConnection() {
* @type{(this: ServerConnection, kind: string, hall: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, error: string, message: string) => void} * @type{(this: ServerConnection, kind: string, hall: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, error: string, message: string) => void}
*/ */
this.onjoined = null; 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 * ondownstream is called whenever a new down stream is added. It
* should set up the stream's callbacks; actually setting up the UI * should set up the stream's callbacks; actually setting up the UI
@@ -342,7 +346,7 @@ ServerConnection.prototype.connect = function(url) {
try { try {
sc.send({ sc.send({
type: 'handshake', type: 'handshake',
version: ['2'], version: ['3'],
id: sc.id, id: sc.id,
}); });
} catch(e) { } catch(e) {
@@ -391,8 +395,8 @@ ServerConnection.prototype.connect = function(url) {
sc.lastServerMessage = new Date().valueOf(); sc.lastServerMessage = new Date().valueOf();
switch(m.type) { switch(m.type) {
case 'handshake': { case 'handshake': {
if((m.version instanceof Array) && m.version.includes('2')) { if((m.version instanceof Array) && m.version.includes('3')) {
sc.version = '2'; sc.version = '3';
} else { } else {
sc.version = null; sc.version = null;
sc.error(new Error(`Unknown protocol version ${m.version}`)); sc.error(new Error(`Unknown protocol version ${m.version}`));
@@ -449,6 +453,21 @@ ServerConnection.prototype.connect = function(url) {
m.status, m.data, m.status, m.data,
m.error || null, m.value || null); m.error || null, m.value || null);
break; 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': case 'user':
let user = null; let user = null;
switch(m.kind) { 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 * request sets the list of requested tracks
* *
+26 -1
View File
@@ -982,6 +982,8 @@ legend {
#left-sidebar { #left-sidebar {
min-width: 200px; min-width: 200px;
max-width: 200px; max-width: 200px;
display: flex;
flex-direction: column;
transition: all 0.3s; transition: all 0.3s;
background: #ffffff; background: #ffffff;
border-right: 1px solid #dcdcdc; border-right: 1px solid #dcdcdc;
@@ -1035,7 +1037,8 @@ header .collapse:hover {
#users { #users {
padding: 0; padding: 0;
margin: 0; margin: 0;
height: calc(100% - 84px); min-height: 0;
flex: 1 1 auto;
width: 100%; width: 100%;
position: relative; position: relative;
display: block; display: block;
@@ -1044,6 +1047,28 @@ header .collapse:hover {
border: 1px solid #f7f7f7; 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 { #users .user-p {
display: block; display: block;
position: relative; position: relative;
+16
View File
@@ -23,6 +23,10 @@
</div> </div>
<div class="header-sep"></div> <div class="header-sep"></div>
<div id="users"></div> <div id="users"></div>
<section id="waiting-list-panel" class="invisible" aria-labelledby="waiting-list-heading">
<h2 id="waiting-list-heading">Waiting room (0)</h2>
<div id="waiting-list"></div>
</section>
</nav> </nav>
<div class="container"> <div class="container">
<header class="connected-only invisible" aria-hidden="true"> <header class="connected-only invisible" aria-hidden="true">
@@ -105,6 +109,18 @@
<div class="clear"></div> <div class="clear"></div>
</div> </div>
</div> </div>
<section id="waiting-room" class="login-container invisible" aria-labelledby="waiting-heading">
<div class="login-box">
<h1 id="waiting-heading" tabindex="-1">Waiting room</h1>
<p id="waiting-status"></p>
<p>
<input id="waiting-auto-join" type="checkbox"/>
<label for="waiting-auto-join">Join hall automatically when it unlocks</label>
</p>
<p><button id="waiting-join" type="button" class="btn btn-blue" disabled>Join hall</button></p>
<p><button id="waiting-leave" type="button" class="btn btn-default">Leave waiting room</button></p>
</div>
</section>
</div> </div>
</div> </div>
</div> </div>
+158 -1
View File
@@ -71,6 +71,10 @@ let token = null;
*/ */
let probingState = null; let probingState = null;
let waitingState = null;
let waitingCountdownTimer = null;
let waitingUsers = new Map();
/** /**
* @typedef {Object} settings - the type of stored settings * @typedef {Object} settings - the type of stored settings
* @property {boolean} [localMute] * @property {boolean} [localMute]
@@ -516,12 +520,138 @@ function setConnected(connected) {
} else { } else {
closeNav(); closeNav();
userbox.classList.add('invisible'); userbox.classList.add('invisible');
connectionbox.classList.remove('invisible'); if(!waitingState)
connectionbox.classList.remove('invisible');
; ;
window.onresize = null; 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. * Called when we connect to the server.
* *
@@ -635,6 +765,7 @@ function onPeerConnection() {
function gotClose(code, reason) { function gotClose(code, reason) {
closeUpMedia(); closeUpMedia();
closeSafariStream(); closeSafariStream();
hideWaitingRoom();
setConnected(false); setConnected(false);
resetChalkboard(); resetChalkboard();
if(code != 1000) { if(code != 1000) {
@@ -2211,6 +2342,9 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
} else { } else {
token = null; token = null;
} }
if(kind === 'join' && waitingState)
announceChat(`Joining the ${waitingHallName()} hall.`);
hideWaitingRoom();
// don't discard endPoint and friends // don't discard endPoint and friends
hallStatus.locked = false; hallStatus.locked = false;
hallStatus.recording = false; hallStatus.recording = false;
@@ -2225,6 +2359,7 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
setChangePassword(pwAuth && !!hallStatus.canChangePassword && setChangePassword(pwAuth && !!hallStatus.canChangePassword &&
serverConnection.username serverConnection.username
); );
renderWaitingList();
openSafariStream(); openSafariStream();
window.setTimeout(() => { window.setTimeout(() => {
userNotificationSoundsReady = true; 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(); 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 {HTMLElement} elt
* @param {boolean} hidden * @param {boolean} hidden
@@ -4186,6 +4341,8 @@ async function serverConnect() {
serverConnection.ondownstream = gotDownStream; serverConnection.ondownstream = gotDownStream;
serverConnection.onuser = gotUser; serverConnection.onuser = gotUser;
serverConnection.onjoined = gotJoined; serverConnection.onjoined = gotJoined;
serverConnection.onwaiting = gotWaiting;
serverConnection.onwaitinglist = gotWaitingList;
serverConnection.onchat = addToChatbox; serverConnection.onchat = addToChatbox;
serverConnection.onusermessage = gotUserMessage; serverConnection.onusermessage = gotUserMessage;
serverConnection.onfiletransfer = gotFileTransfer; serverConnection.onfiletransfer = gotFileTransfer;
+20
View File
@@ -145,6 +145,26 @@ func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) {
requireFunctionContains(t, js, "userMenu", "Restart audio connection") 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) { func TestRecordingControlLivesInSelfMenu(t *testing.T) {
js := readStaticFile(t, "skald.js") js := readStaticFile(t, "skald.js")
+2
View File
@@ -230,6 +230,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) {
if !canPresent(c.Permissions()) { if !canPresent(c.Permissions()) {
hall.DelClient(c) hall.DelClient(c)
rtpconn.WaitingCapacityChanged(g)
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
@@ -239,6 +240,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) {
answer, err := c.NewConnection(r.Context(), body) answer, err := c.NewConnection(r.Context(), body)
if err != nil { if err != nil {
hall.DelClient(c) hall.DelClient(c)
rtpconn.WaitingCapacityChanged(g)
log.Printf("WHIP offer: %v", err) log.Printf("WHIP offer: %v", err)
httpError(w, err) httpError(w, err)
return return