First pass at adding a waiting room.
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user