Unlock commands now support a given time, e.g 10 minutes.
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.r0.g9a9742e
|
pkgver=2026.07.08.r1.g704b1a9
|
||||||
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')
|
||||||
|
|||||||
@@ -145,6 +145,41 @@ func (g *Hall) SetLocked(locked bool, message string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConditionalUnlockResult uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConditionalUnlockSucceeded ConditionalUnlockResult = iota
|
||||||
|
ConditionalUnlockAlreadyUnlocked
|
||||||
|
ConditionalUnlockClientAbsent
|
||||||
|
ConditionalUnlockClientNotOperator
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnlockIfOperatorPresent atomically verifies that c is still in this hall
|
||||||
|
// with operator permission and unlocks the hall if so.
|
||||||
|
func (g *Hall) UnlockIfOperatorPresent(c Client) ConditionalUnlockResult {
|
||||||
|
g.mu.Lock()
|
||||||
|
if g.locked == nil {
|
||||||
|
g.mu.Unlock()
|
||||||
|
return ConditionalUnlockAlreadyUnlocked
|
||||||
|
}
|
||||||
|
if g.getClientUnlocked(c.Id()) != c {
|
||||||
|
g.mu.Unlock()
|
||||||
|
return ConditionalUnlockClientAbsent
|
||||||
|
}
|
||||||
|
if !member("op", c.Permissions()) {
|
||||||
|
g.mu.Unlock()
|
||||||
|
return ConditionalUnlockClientNotOperator
|
||||||
|
}
|
||||||
|
g.locked = nil
|
||||||
|
clients := g.getClientsUnlocked(nil)
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
for _, client := range clients {
|
||||||
|
client.Joined(g.Name(), "change")
|
||||||
|
}
|
||||||
|
return ConditionalUnlockSucceeded
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Hall) Data() map[string]interface{} {
|
func (g *Hall) Data() map[string]interface{} {
|
||||||
g.mu.Lock()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
durationUnlockPattern = regexp.MustCompile(`^([0-9]+)[mM]$`)
|
||||||
|
twentyFourHourPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):([0-5][0-9])$`)
|
||||||
|
twelveHourPattern = regexp.MustCompile(`^(0?[1-9]|1[0-2]):([0-5][0-9])\s*([aApP])\.?[mM]\.?$`)
|
||||||
|
unlockNow = time.Now
|
||||||
|
)
|
||||||
|
|
||||||
|
type scheduledUnlockRequest struct {
|
||||||
|
spec string
|
||||||
|
timeZone string
|
||||||
|
timezoneOffset int
|
||||||
|
}
|
||||||
|
|
||||||
|
type scheduledHallUnlock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
active bool
|
||||||
|
hall *hall.Hall
|
||||||
|
issuer *webClient
|
||||||
|
issuerName string
|
||||||
|
deadline time.Time
|
||||||
|
timers []*time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
var hallUnlockSchedules = struct {
|
||||||
|
sync.Mutex
|
||||||
|
entries map[*hall.Hall]*scheduledHallUnlock
|
||||||
|
}{
|
||||||
|
entries: make(map[*hall.Hall]*scheduledHallUnlock),
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduledUnlockRequestFromValue(value interface{}) (scheduledUnlockRequest, error) {
|
||||||
|
v, ok := value.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return scheduledUnlockRequest{}, hall.UserError("Invalid scheduled unlock request.")
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, ok := v["spec"].(string)
|
||||||
|
if !ok || strings.TrimSpace(spec) == "" {
|
||||||
|
return scheduledUnlockRequest{}, hall.UserError("Specify a duration or time to schedule an unlock.")
|
||||||
|
}
|
||||||
|
timeZone, _ := v["timeZone"].(string)
|
||||||
|
offsetValue, ok := v["timezoneOffset"].(float64)
|
||||||
|
if !ok || math.Trunc(offsetValue) != offsetValue || offsetValue < -14*60 || offsetValue > 14*60 {
|
||||||
|
return scheduledUnlockRequest{}, hall.UserError("Invalid browser timezone offset.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduledUnlockRequest{
|
||||||
|
spec: strings.TrimSpace(spec),
|
||||||
|
timeZone: timeZone,
|
||||||
|
timezoneOffset: int(offsetValue),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r scheduledUnlockRequest) location() (*time.Location, bool) {
|
||||||
|
if r.timeZone != "" && len(r.timeZone) <= 100 {
|
||||||
|
if location, err := time.LoadLocation(r.timeZone); err == nil {
|
||||||
|
return location, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// JavaScript's getTimezoneOffset is UTC minus local time.
|
||||||
|
return time.FixedZone("browser local time", -r.timezoneOffset*60), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScheduledUnlock(value interface{}, now time.Time) (time.Time, error) {
|
||||||
|
request, err := scheduledUnlockRequestFromValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
location, hasNamedTimeZone := request.location()
|
||||||
|
localNow := now.In(location)
|
||||||
|
|
||||||
|
if match := durationUnlockPattern.FindStringSubmatch(request.spec); match != nil {
|
||||||
|
minutes, err := strconv.ParseInt(match[1], 10, 32)
|
||||||
|
if err != nil || minutes <= 0 {
|
||||||
|
return time.Time{}, hall.UserError("The unlock duration must be at least 1 minute.")
|
||||||
|
}
|
||||||
|
if minutes > 24*60 {
|
||||||
|
return time.Time{}, hall.UserError("The unlock duration must end later today.")
|
||||||
|
}
|
||||||
|
target := localNow.Add(time.Duration(minutes) * time.Minute)
|
||||||
|
if !sameDate(localNow, target) {
|
||||||
|
return time.Time{}, hall.UserError("The specified duration crosses midnight, cannot unlock on a different day.")
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hour, minute, ok := parseUnlockClockTime(request.spec)
|
||||||
|
if !ok {
|
||||||
|
return time.Time{}, hall.UserError("Invalid unlock time. Use minutes such as 30m, 24-hour time such as 15:30, or 12-hour time such as 3:30 PM.")
|
||||||
|
}
|
||||||
|
if !hasNamedTimeZone {
|
||||||
|
return time.Time{}, hall.UserError("Cannot schedule a clock time because the browser timezone is unavailable; use a duration such as 30m.")
|
||||||
|
}
|
||||||
|
candidates := clockTimeCandidates(localNow, hour, minute, location)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return time.Time{}, hall.UserError("The specified local time does not exist today, cannot unlock.")
|
||||||
|
}
|
||||||
|
for _, target := range candidates {
|
||||||
|
if target.After(localNow) {
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, hall.UserError(fmt.Sprintf(
|
||||||
|
"The specified time is %s in the past, cannot unlock.",
|
||||||
|
formatElapsed(localNow.Sub(candidates[len(candidates)-1])),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clockTimeCandidates(date time.Time, hour, minute int, location *time.Location) []time.Time {
|
||||||
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||||
|
end := start.AddDate(0, 0, 1)
|
||||||
|
var candidates []time.Time
|
||||||
|
for candidate := start; candidate.Before(end); candidate = candidate.Add(time.Minute) {
|
||||||
|
local := candidate.In(location)
|
||||||
|
if sameDate(local, date) && local.Hour() == hour && local.Minute() == minute {
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUnlockClockTime(spec string) (int, int, bool) {
|
||||||
|
if match := twentyFourHourPattern.FindStringSubmatch(spec); match != nil {
|
||||||
|
hour, _ := strconv.Atoi(match[1])
|
||||||
|
minute, _ := strconv.Atoi(match[2])
|
||||||
|
return hour, minute, true
|
||||||
|
}
|
||||||
|
if match := twelveHourPattern.FindStringSubmatch(spec); match != nil {
|
||||||
|
hour, _ := strconv.Atoi(match[1])
|
||||||
|
minute, _ := strconv.Atoi(match[2])
|
||||||
|
if hour == 12 {
|
||||||
|
hour = 0
|
||||||
|
}
|
||||||
|
if strings.EqualFold(match[3], "p") {
|
||||||
|
hour += 12
|
||||||
|
}
|
||||||
|
return hour, minute, true
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameDate(a, b time.Time) bool {
|
||||||
|
ay, am, ad := a.Date()
|
||||||
|
by, bm, bd := b.Date()
|
||||||
|
return ay == by && am == bm && ad == bd
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatElapsed(duration time.Duration) string {
|
||||||
|
if duration >= time.Hour {
|
||||||
|
hours := int(duration / time.Hour)
|
||||||
|
if hours == 1 {
|
||||||
|
return "1 hour"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours", hours)
|
||||||
|
}
|
||||||
|
if duration >= time.Minute {
|
||||||
|
minutes := int(duration / time.Minute)
|
||||||
|
if minutes == 1 {
|
||||||
|
return "1 minute"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes", minutes)
|
||||||
|
}
|
||||||
|
return "less than 1 minute"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMinutesUntil(duration time.Duration) string {
|
||||||
|
minutes := int(math.Ceil(duration.Minutes()))
|
||||||
|
if minutes < 1 {
|
||||||
|
minutes = 1
|
||||||
|
}
|
||||||
|
if minutes == 1 {
|
||||||
|
return "1 minute"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes", minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlockWarningTimes(duration time.Duration) []time.Duration {
|
||||||
|
minutes := int(math.Ceil(duration.Minutes()))
|
||||||
|
if minutes >= 30 {
|
||||||
|
return []time.Duration{20 * time.Minute, 10 * time.Minute, 5 * time.Minute}
|
||||||
|
}
|
||||||
|
if minutes >= 15 {
|
||||||
|
return []time.Duration{10 * time.Minute, 5 * time.Minute}
|
||||||
|
}
|
||||||
|
if minutes >= 6 {
|
||||||
|
return []time.Duration{5 * time.Minute}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time) error {
|
||||||
|
hallUnlockSchedules.Lock()
|
||||||
|
|
||||||
|
locked, _ := g.Locked()
|
||||||
|
if !locked {
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
return hall.UserError("The hall is already unlocked.")
|
||||||
|
}
|
||||||
|
remaining := deadline.Sub(now)
|
||||||
|
if remaining <= 0 {
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
return hall.UserError("The specified unlock time has already passed.")
|
||||||
|
}
|
||||||
|
if remaining > 24*time.Hour {
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
return hall.UserError("The unlock time must be later today.")
|
||||||
|
}
|
||||||
|
|
||||||
|
issuerName := issuer.Username()
|
||||||
|
if issuerName == "" {
|
||||||
|
issuerName = "the issuing operator"
|
||||||
|
}
|
||||||
|
entry := &scheduledHallUnlock{
|
||||||
|
active: true,
|
||||||
|
hall: g,
|
||||||
|
issuer: issuer,
|
||||||
|
issuerName: issuerName,
|
||||||
|
deadline: deadline,
|
||||||
|
}
|
||||||
|
|
||||||
|
previous := hallUnlockSchedules.entries[g]
|
||||||
|
if previous != nil {
|
||||||
|
deactivateScheduledUnlock(previous)
|
||||||
|
}
|
||||||
|
hallUnlockSchedules.entries[g] = entry
|
||||||
|
for _, warning := range unlockWarningTimes(remaining) {
|
||||||
|
delay := deadline.Sub(now) - warning
|
||||||
|
if delay <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
warning := warning
|
||||||
|
entry.timers = append(entry.timers, time.AfterFunc(delay, func() {
|
||||||
|
announceScheduledUnlock(entry, warning)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
entry.timers = append(entry.timers, time.AfterFunc(remaining, func() {
|
||||||
|
finishScheduledUnlock(entry)
|
||||||
|
}))
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
|
||||||
|
message := fmt.Sprintf(
|
||||||
|
"Hall scheduled to unlock in %s by %s.",
|
||||||
|
formatMinutesUntil(remaining), entry.issuerName,
|
||||||
|
)
|
||||||
|
if previous != nil {
|
||||||
|
message = "Previous scheduled unlock replaced. " + message
|
||||||
|
}
|
||||||
|
entry.mu.Lock()
|
||||||
|
if entry.active {
|
||||||
|
broadcastHallInfo(g, message)
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func announceScheduledUnlock(entry *scheduledHallUnlock, remaining time.Duration) {
|
||||||
|
entry.mu.Lock()
|
||||||
|
defer entry.mu.Unlock()
|
||||||
|
if !entry.active {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
locked, _ := entry.hall.Locked()
|
||||||
|
if !locked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
broadcastHallInfo(entry.hall, fmt.Sprintf(
|
||||||
|
"Hall will unlock in %s.", formatMinutesUntil(remaining),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishScheduledUnlock(entry *scheduledHallUnlock) {
|
||||||
|
hallUnlockSchedules.Lock()
|
||||||
|
if hallUnlockSchedules.entries[entry.hall] != entry {
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.mu.Lock()
|
||||||
|
delete(hallUnlockSchedules.entries, entry.hall)
|
||||||
|
entry.active = false
|
||||||
|
stopScheduledUnlockTimers(entry)
|
||||||
|
result := entry.hall.UnlockIfOperatorPresent(entry.issuer)
|
||||||
|
switch result {
|
||||||
|
case hall.ConditionalUnlockSucceeded:
|
||||||
|
broadcastHallInfo(entry.hall, "Hall unlocked")
|
||||||
|
case hall.ConditionalUnlockClientAbsent:
|
||||||
|
broadcastHallInfo(entry.hall, fmt.Sprintf(
|
||||||
|
"Scheduled unlock cancelled because %s is no longer present in the hall.",
|
||||||
|
entry.issuerName,
|
||||||
|
))
|
||||||
|
case hall.ConditionalUnlockClientNotOperator:
|
||||||
|
broadcastHallInfo(entry.hall, fmt.Sprintf(
|
||||||
|
"Scheduled unlock cancelled because %s is no longer an operator.",
|
||||||
|
entry.issuerName,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
entry.mu.Unlock()
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHallLockState(g *hall.Hall, locked bool, message string) bool {
|
||||||
|
hallUnlockSchedules.Lock()
|
||||||
|
entry := hallUnlockSchedules.entries[g]
|
||||||
|
if entry != nil {
|
||||||
|
delete(hallUnlockSchedules.entries, g)
|
||||||
|
deactivateScheduledUnlock(entry)
|
||||||
|
}
|
||||||
|
g.SetLocked(locked, message)
|
||||||
|
if locked {
|
||||||
|
if entry != nil {
|
||||||
|
broadcastHallInfo(g, "Scheduled hall unlock cancelled because the hall was locked again.")
|
||||||
|
}
|
||||||
|
broadcastHallInfo(g, "Hall locked")
|
||||||
|
} else {
|
||||||
|
broadcastHallInfo(g, "Hall unlocked")
|
||||||
|
}
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
return entry != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelScheduledHallUnlock(g *hall.Hall) bool {
|
||||||
|
hallUnlockSchedules.Lock()
|
||||||
|
entry := hallUnlockSchedules.entries[g]
|
||||||
|
if entry != nil {
|
||||||
|
delete(hallUnlockSchedules.entries, g)
|
||||||
|
deactivateScheduledUnlock(entry)
|
||||||
|
}
|
||||||
|
hallUnlockSchedules.Unlock()
|
||||||
|
return entry != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deactivateScheduledUnlock(entry *scheduledHallUnlock) {
|
||||||
|
entry.mu.Lock()
|
||||||
|
entry.active = false
|
||||||
|
stopScheduledUnlockTimers(entry)
|
||||||
|
entry.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopScheduledUnlockTimers(entry *scheduledHallUnlock) {
|
||||||
|
for _, timer := range entry.timers {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func unlockRequest(spec, timeZone string, timezoneOffset float64) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"spec": spec,
|
||||||
|
"timeZone": timeZone,
|
||||||
|
"timezoneOffset": timezoneOffset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScheduledUnlock(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, time.July, 21, 15, 0, 0, 0, location)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
spec string
|
||||||
|
wantHour int
|
||||||
|
wantMin int
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "lowercase duration", spec: "30m", wantHour: 15, wantMin: 30},
|
||||||
|
{name: "uppercase duration", spec: "30M", wantHour: 15, wantMin: 30},
|
||||||
|
{name: "24 hour", spec: "15:45", wantHour: 15, wantMin: 45},
|
||||||
|
{name: "one digit hour is not 24 hour", spec: "3:45", wantErr: "Invalid unlock time"},
|
||||||
|
{name: "12 hour spaced", spec: "3:45 PM", wantHour: 15, wantMin: 45},
|
||||||
|
{name: "12 hour compact lowercase", spec: "3:45pm", wantHour: 15, wantMin: 45},
|
||||||
|
{name: "midnight", spec: "12:30 AM", wantErr: "14 hours in the past"},
|
||||||
|
{name: "unqualified noon is 24 hour", spec: "12:00", wantErr: "3 hours in the past"},
|
||||||
|
{name: "missing meridiem minutes", spec: "3 PM", wantErr: "Invalid unlock time"},
|
||||||
|
{name: "invalid hour", spec: "25:00", wantErr: "Invalid unlock time"},
|
||||||
|
{name: "zero duration", spec: "0m", wantErr: "at least 1 minute"},
|
||||||
|
{name: "duration cannot contain space", spec: "30 m", wantErr: "Invalid unlock time"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := parseScheduledUnlock(
|
||||||
|
unlockRequest(test.spec, "America/New_York", 240), now,
|
||||||
|
)
|
||||||
|
if test.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||||
|
t.Fatalf("parseScheduledUnlock(%q) error = %v, want containing %q", test.spec, err, test.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseScheduledUnlock(%q): %v", test.spec, err)
|
||||||
|
}
|
||||||
|
local := got.In(location)
|
||||||
|
if local.Hour() != test.wantHour || local.Minute() != test.wantMin {
|
||||||
|
t.Fatalf("parseScheduledUnlock(%q) = %v, want %02d:%02d", test.spec, local, test.wantHour, test.wantMin)
|
||||||
|
}
|
||||||
|
if !sameDate(local, now) {
|
||||||
|
t.Fatalf("parseScheduledUnlock(%q) changed date: %v", test.spec, local)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScheduledUnlockRequiresNamedTimezoneForClockTime(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.July, 21, 15, 0, 0, 0, time.UTC)
|
||||||
|
_, err := parseScheduledUnlock(unlockRequest("16:00", "", 0), now)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "browser timezone is unavailable") {
|
||||||
|
t.Fatalf("clock time without named timezone error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := parseScheduledUnlock(unlockRequest("30m", "", 0), now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("duration with offset fallback: %v", err)
|
||||||
|
}
|
||||||
|
if got := target.Sub(now); got != 30*time.Minute {
|
||||||
|
t.Fatalf("duration with offset fallback = %v, want 30m", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScheduledUnlockRejectsCrossMidnight(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, time.July, 21, 23, 45, 0, 0, location)
|
||||||
|
_, err = parseScheduledUnlock(
|
||||||
|
unlockRequest("30m", "America/New_York", 240), now,
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "crosses midnight") {
|
||||||
|
t.Fatalf("cross-midnight duration error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScheduledUnlockUsesDaylightSavingTime(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, time.March, 8, 1, 30, 0, 0, location)
|
||||||
|
target, err := parseScheduledUnlock(
|
||||||
|
unlockRequest("03:30", "America/New_York", 300), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseScheduledUnlock across DST boundary: %v", err)
|
||||||
|
}
|
||||||
|
if got := target.Sub(now); got != time.Hour {
|
||||||
|
t.Fatalf("DST-aware duration = %v, want 1h", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScheduledUnlockRejectsNonexistentDaylightSavingTime(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, time.March, 8, 1, 30, 0, 0, location)
|
||||||
|
_, err = parseScheduledUnlock(
|
||||||
|
unlockRequest("02:30", "America/New_York", 300), now,
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "does not exist today") {
|
||||||
|
t.Fatalf("nonexistent DST time error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScheduledUnlockChoosesNextRepeatedDaylightSavingTime(t *testing.T) {
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation: %v", err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
nowUTC time.Time
|
||||||
|
wantUTC time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "before first occurrence",
|
||||||
|
nowUTC: time.Date(2026, time.November, 1, 4, 30, 0, 0, time.UTC),
|
||||||
|
wantUTC: time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "during second occurrence",
|
||||||
|
nowUTC: time.Date(2026, time.November, 1, 6, 15, 0, 0, time.UTC),
|
||||||
|
wantUTC: time.Date(2026, time.November, 1, 6, 30, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
target, err := parseScheduledUnlock(
|
||||||
|
unlockRequest("01:30", "America/New_York", 240), test.nowUTC,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse repeated clock time: %v", err)
|
||||||
|
}
|
||||||
|
if !target.Equal(test.wantUTC) {
|
||||||
|
t.Fatalf("repeated clock target = %v (%v), want %v", target, target.In(location), test.wantUTC)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlockWarningTimes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
duration time.Duration
|
||||||
|
want []time.Duration
|
||||||
|
}{
|
||||||
|
{30 * time.Minute, []time.Duration{20 * time.Minute, 10 * time.Minute, 5 * time.Minute}},
|
||||||
|
{29 * time.Minute, []time.Duration{10 * time.Minute, 5 * time.Minute}},
|
||||||
|
{15 * time.Minute, []time.Duration{10 * time.Minute, 5 * time.Minute}},
|
||||||
|
{6 * time.Minute, []time.Duration{5 * time.Minute}},
|
||||||
|
{5 * time.Minute, nil},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
got := unlockWarningTimes(test.duration)
|
||||||
|
if len(got) != len(test.want) {
|
||||||
|
t.Fatalf("unlockWarningTimes(%v) = %v, want %v", test.duration, got, test.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != test.want[i] {
|
||||||
|
t.Fatalf("unlockWarningTimes(%v) = %v, want %v", test.duration, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestHallInfo(messages []clientMessage) string {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Type == "usermessage" && messages[i].Kind == "info" {
|
||||||
|
message, _ := messages[i].Value.(string)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduledUnlockEntry(g *hall.Hall) *scheduledHallUnlock {
|
||||||
|
hallUnlockSchedules.Lock()
|
||||||
|
defer hallUnlockSchedules.Unlock()
|
||||||
|
return hallUnlockSchedules.entries[g]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduledUnlockRequiresIssuingOperator(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
participant := addTestWebClient(t, hallName, "Participant", "present")
|
||||||
|
g := operator.hall
|
||||||
|
g.SetLocked(true, "")
|
||||||
|
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil {
|
||||||
|
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||||
|
}
|
||||||
|
entry := scheduledUnlockEntry(g)
|
||||||
|
if entry == nil {
|
||||||
|
t.Fatal("scheduled unlock was not stored")
|
||||||
|
}
|
||||||
|
if got := len(entry.timers); got != 4 {
|
||||||
|
t.Fatalf("30-minute schedule has %d timers, want 4", got)
|
||||||
|
}
|
||||||
|
if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "30 minutes") {
|
||||||
|
t.Fatalf("schedule announcement = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
hall.DelClient(operator)
|
||||||
|
drainMessages(participant)
|
||||||
|
finishScheduledUnlock(entry)
|
||||||
|
if locked, _ := g.Locked(); !locked {
|
||||||
|
t.Fatal("hall unlocked after issuing operator left")
|
||||||
|
}
|
||||||
|
if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "Operator is no longer present") {
|
||||||
|
t.Fatalf("departure cancellation announcement = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduledUnlockCompletesWhileIssuerIsOperator(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
g := operator.hall
|
||||||
|
g.SetLocked(true, "")
|
||||||
|
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := scheduleHallUnlock(g, operator, now.Add(6*time.Minute), now); err != nil {
|
||||||
|
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||||
|
}
|
||||||
|
entry := scheduledUnlockEntry(g)
|
||||||
|
drainMessages(operator)
|
||||||
|
finishScheduledUnlock(entry)
|
||||||
|
if locked, _ := g.Locked(); locked {
|
||||||
|
t.Fatal("hall remained locked while issuing operator was present")
|
||||||
|
}
|
||||||
|
if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" {
|
||||||
|
t.Fatalf("unlock announcement = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduledUnlockRequiresOperatorPermissionAtDeadline(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
participant := addTestWebClient(t, hallName, "Participant", "present")
|
||||||
|
g := operator.hall
|
||||||
|
g.SetLocked(true, "")
|
||||||
|
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := scheduleHallUnlock(g, operator, now.Add(15*time.Minute), now); err != nil {
|
||||||
|
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||||
|
}
|
||||||
|
entry := scheduledUnlockEntry(g)
|
||||||
|
drainMessages(participant)
|
||||||
|
operator.SetPermissions([]string{"present"})
|
||||||
|
finishScheduledUnlock(entry)
|
||||||
|
if locked, _ := g.Locked(); !locked {
|
||||||
|
t.Fatal("hall unlocked after issuer lost operator permission")
|
||||||
|
}
|
||||||
|
if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "Operator is no longer an operator") {
|
||||||
|
t.Fatalf("permission cancellation announcement = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScheduledUnlockRequestReplacesPreviousSchedule(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
g := operator.hall
|
||||||
|
g.SetLocked(true, "")
|
||||||
|
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||||
|
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, time.July, 21, 15, 0, 0, 0, location)
|
||||||
|
firstDeadline, err := parseScheduledUnlock(
|
||||||
|
unlockRequest("30m", "America/New_York", 240), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse first schedule: %v", err)
|
||||||
|
}
|
||||||
|
if err := scheduleHallUnlock(g, operator, firstDeadline, now); err != nil {
|
||||||
|
t.Fatalf("schedule first unlock: %v", err)
|
||||||
|
}
|
||||||
|
first := scheduledUnlockEntry(g)
|
||||||
|
drainMessages(operator)
|
||||||
|
|
||||||
|
secondDeadline, err := parseScheduledUnlock(
|
||||||
|
unlockRequest("20m", "America/New_York", 240), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse second schedule: %v", err)
|
||||||
|
}
|
||||||
|
if err := scheduleHallUnlock(g, operator, secondDeadline, now); err != nil {
|
||||||
|
t.Fatalf("schedule second unlock: %v", err)
|
||||||
|
}
|
||||||
|
second := scheduledUnlockEntry(g)
|
||||||
|
if second == nil || second == first {
|
||||||
|
t.Fatal("second request did not replace the first schedule")
|
||||||
|
}
|
||||||
|
if got := latestHallInfo(drainMessages(operator)); !strings.Contains(got, "Previous scheduled unlock replaced") {
|
||||||
|
t.Fatalf("replacement announcement = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedUnlockHallActionCreatesSchedule(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
g := operator.hall
|
||||||
|
g.SetLocked(true, "")
|
||||||
|
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||||
|
oldUnlockNow := unlockNow
|
||||||
|
location, err := time.LoadLocation("America/New_York")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLocation: %v", err)
|
||||||
|
}
|
||||||
|
unlockNow = func() time.Time {
|
||||||
|
return time.Date(2026, time.July, 21, 15, 0, 0, 0, location)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { unlockNow = oldUnlockNow })
|
||||||
|
|
||||||
|
if err := handleClientMessage(operator, clientMessage{
|
||||||
|
Type: "hallaction",
|
||||||
|
Kind: "unlock",
|
||||||
|
Value: unlockRequest("30m", "America/New_York", 240),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("timed unlock hall action: %v", err)
|
||||||
|
}
|
||||||
|
entry := scheduledUnlockEntry(g)
|
||||||
|
if entry == nil {
|
||||||
|
t.Fatal("timed unlock hall action did not create a schedule")
|
||||||
|
}
|
||||||
|
if got := latestHallInfo(drainMessages(operator)); !strings.Contains(got, "30 minutes") {
|
||||||
|
t.Fatalf("timed unlock announcement = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImmediateUnlockCommandCancelsScheduledUnlock(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
g := operator.hall
|
||||||
|
g.SetLocked(true, "")
|
||||||
|
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil {
|
||||||
|
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||||
|
}
|
||||||
|
entry := scheduledUnlockEntry(g)
|
||||||
|
drainMessages(operator)
|
||||||
|
if err := handleClientMessage(operator, clientMessage{
|
||||||
|
Type: "hallaction",
|
||||||
|
Kind: "unlock",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("immediate unlock hall action: %v", err)
|
||||||
|
}
|
||||||
|
if scheduledUnlockEntry(g) != nil {
|
||||||
|
t.Fatal("immediate unlock left scheduled unlock active")
|
||||||
|
}
|
||||||
|
finishScheduledUnlock(entry)
|
||||||
|
if locked, _ := g.Locked(); locked {
|
||||||
|
t.Fatal("immediate unlock left hall locked")
|
||||||
|
}
|
||||||
|
if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" {
|
||||||
|
t.Fatalf("immediate unlock announcement = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockCommandCancelsScheduledUnlock(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
g := operator.hall
|
||||||
|
g.SetLocked(true, "")
|
||||||
|
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil {
|
||||||
|
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||||
|
}
|
||||||
|
entry := scheduledUnlockEntry(g)
|
||||||
|
drainMessages(operator)
|
||||||
|
if err := handleClientMessage(operator, clientMessage{
|
||||||
|
Type: "hallaction",
|
||||||
|
Kind: "lock",
|
||||||
|
Value: "Remain closed",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("lock hall action: %v", err)
|
||||||
|
}
|
||||||
|
if scheduledUnlockEntry(g) != nil {
|
||||||
|
t.Fatal("lock command left scheduled unlock active")
|
||||||
|
}
|
||||||
|
if locked, message := g.Locked(); !locked || message != "Remain closed" {
|
||||||
|
t.Fatalf("lock state = %v, %q", locked, message)
|
||||||
|
}
|
||||||
|
finishScheduledUnlock(entry)
|
||||||
|
if locked, message := g.Locked(); !locked || message != "Remain closed" {
|
||||||
|
t.Fatalf("cancelled deadline changed lock state to %v, %q", locked, message)
|
||||||
|
}
|
||||||
|
messages := drainMessages(operator)
|
||||||
|
foundCancellation := false
|
||||||
|
for _, message := range messages {
|
||||||
|
if message.Kind == "info" && strings.Contains(message.Value.(string), "locked again") {
|
||||||
|
foundCancellation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundCancellation {
|
||||||
|
t.Fatalf("lock messages missing scheduled-unlock cancellation: %#v", messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-23
@@ -114,17 +114,18 @@ func broadcastChalkboard(g *hall.Hall, state hall.ChalkboardState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type webClient struct {
|
type webClient struct {
|
||||||
hall *hall.Hall
|
hall *hall.Hall
|
||||||
addr net.Addr
|
addr net.Addr
|
||||||
id string
|
id string
|
||||||
username string
|
username string
|
||||||
permissions []string
|
permissions []string
|
||||||
data map[string]interface{}
|
data map[string]interface{}
|
||||||
requested map[string][]string
|
requested map[string][]string
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
writeCh chan interface{}
|
writeCh chan interface{}
|
||||||
writerDone chan struct{}
|
writerDone chan struct{}
|
||||||
actions *unbounded.Channel[any]
|
actions *unbounded.Channel[any]
|
||||||
|
permissionsMu sync.RWMutex
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
down map[string]*rtpDownConnection
|
down map[string]*rtpDownConnection
|
||||||
@@ -152,7 +153,9 @@ func (c *webClient) SetUsername(username string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *webClient) Permissions() []string {
|
func (c *webClient) Permissions() []string {
|
||||||
return c.permissions
|
c.permissionsMu.RLock()
|
||||||
|
defer c.permissionsMu.RUnlock()
|
||||||
|
return append([]string(nil), c.permissions...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *webClient) Data() map[string]interface{} {
|
func (c *webClient) Data() map[string]interface{} {
|
||||||
@@ -160,7 +163,9 @@ func (c *webClient) Data() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *webClient) SetPermissions(perms []string) {
|
func (c *webClient) SetPermissions(perms []string) {
|
||||||
c.permissions = perms
|
c.permissionsMu.Lock()
|
||||||
|
c.permissions = append([]string(nil), perms...)
|
||||||
|
c.permissionsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *webClient) PushClient(hall, kind, id string, username string, perms []string, data map[string]interface{}) error {
|
func (c *webClient) PushClient(hall, kind, id string, username string, perms []string, data map[string]interface{}) error {
|
||||||
@@ -1249,11 +1254,16 @@ func handleAction(c *webClient, a any) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case changePermissionsAction:
|
case changePermissionsAction:
|
||||||
|
allowRecording := false
|
||||||
|
if a.kind == "op" {
|
||||||
|
g := c.Hall()
|
||||||
|
allowRecording = g != nil && g.Description().AllowRecording
|
||||||
|
}
|
||||||
|
c.permissionsMu.Lock()
|
||||||
switch a.kind {
|
switch a.kind {
|
||||||
case "op":
|
case "op":
|
||||||
c.permissions = addnew("op", c.permissions)
|
c.permissions = addnew("op", c.permissions)
|
||||||
g := c.Hall()
|
if allowRecording {
|
||||||
if g != nil && g.Description().AllowRecording {
|
|
||||||
c.permissions = addnew("record", c.permissions)
|
c.permissions = addnew("record", c.permissions)
|
||||||
}
|
}
|
||||||
case "unop":
|
case "unop":
|
||||||
@@ -1272,15 +1282,17 @@ func handleAction(c *webClient, a any) error {
|
|||||||
case "unchalkboard":
|
case "unchalkboard":
|
||||||
c.permissions = remove("chalkboard", c.permissions)
|
c.permissions = remove("chalkboard", c.permissions)
|
||||||
default:
|
default:
|
||||||
|
c.permissionsMu.Unlock()
|
||||||
return hall.UserError("unknown permission")
|
return hall.UserError("unknown permission")
|
||||||
}
|
}
|
||||||
|
c.permissionsMu.Unlock()
|
||||||
c.action(permissionsChangedAction{})
|
c.action(permissionsChangedAction{})
|
||||||
case permissionsChangedAction:
|
case permissionsChangedAction:
|
||||||
g := c.Hall()
|
g := c.Hall()
|
||||||
if g == nil {
|
if g == nil {
|
||||||
return errors.New("Permissions changed in no hall")
|
return errors.New("Permissions changed in no hall")
|
||||||
}
|
}
|
||||||
perms := append([]string(nil), c.permissions...)
|
perms := c.Permissions()
|
||||||
status := g.Status(true, nil)
|
status := g.Status(true, nil)
|
||||||
status.Recording = hallRecording(g)
|
status.Recording = hallRecording(g)
|
||||||
username := c.username
|
username := c.username
|
||||||
@@ -1367,7 +1379,7 @@ func leaveHall(c *webClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hall.DelClient(c)
|
hall.DelClient(c)
|
||||||
c.permissions = 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
|
||||||
@@ -1719,17 +1731,23 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
|||||||
if !member("op", c.permissions) {
|
if !member("op", c.permissions) {
|
||||||
return c.error(hall.UserError("not authorised"))
|
return c.error(hall.UserError("not authorised"))
|
||||||
}
|
}
|
||||||
|
if m.Kind == "unlock" && m.Value != nil {
|
||||||
|
now := unlockNow()
|
||||||
|
deadline, err := parseScheduledUnlock(m.Value, now)
|
||||||
|
if err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
if err := scheduleHallUnlock(g, c, deadline, now); err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
message := ""
|
message := ""
|
||||||
v, ok := m.Value.(string)
|
v, ok := m.Value.(string)
|
||||||
if ok {
|
if ok {
|
||||||
message = v
|
message = v
|
||||||
}
|
}
|
||||||
g.SetLocked(m.Kind == "lock", message)
|
setHallLockState(g, m.Kind == "lock", message)
|
||||||
if m.Kind == "lock" {
|
|
||||||
broadcastHallInfo(g, "Hall locked")
|
|
||||||
} else {
|
|
||||||
broadcastHallInfo(g, "Hall unlocked")
|
|
||||||
}
|
|
||||||
case "record":
|
case "record":
|
||||||
if !member("record", c.permissions) {
|
if !member("record", c.permissions) {
|
||||||
return c.error(hall.UserError("not authorised"))
|
return c.error(hall.UserError("not authorised"))
|
||||||
|
|||||||
@@ -413,6 +413,30 @@ temporary `chalkboard` permission:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
An `unlock` action without a value unlocks the hall immediately. A browser
|
||||||
|
may request an unlock later on the same local calendar day by including the
|
||||||
|
user's command text and timezone information:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
type: 'hallaction',
|
||||||
|
kind: 'unlock',
|
||||||
|
value: {
|
||||||
|
spec: '30m',
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
timezoneOffset: 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `spec` is either a number of minutes followed by `m` or `M`, a 24-hour
|
||||||
|
time such as `15:30`, or a 12-hour time with `AM` or `PM`. The server owns the
|
||||||
|
timer, rejects past and next-day times, and only unlocks if the operator who
|
||||||
|
scheduled it is still present in the same session with operator permission.
|
||||||
|
A new timed request replaces the previous one. An immediate `unlock` or any
|
||||||
|
`lock` action cancels a pending timer. Longer schedules broadcast polite
|
||||||
|
countdowns; a 30-minute schedule announces 20, 10, and 5 minutes remaining.
|
||||||
|
|
||||||
|
|
||||||
# Peer-to-peer file transfer protocol
|
# Peer-to-peer file transfer protocol
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -2417,7 +2417,7 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
|
|||||||
}
|
}
|
||||||
let text = '' + message;
|
let text = '' + message;
|
||||||
localMessage(text);
|
localMessage(text);
|
||||||
displayMessage(text);
|
displayMessage(text, false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'recording': {
|
case 'recording': {
|
||||||
@@ -3204,9 +3204,25 @@ commands.lock = {
|
|||||||
|
|
||||||
commands.unlock = {
|
commands.unlock = {
|
||||||
predicate: operatorPredicate,
|
predicate: operatorPredicate,
|
||||||
description: 'unlock this hall, revert the effect of /lock',
|
description: 'unlock this hall now or later today',
|
||||||
|
parameters: '[minutes[m|M] | HH:MM | h:mm AM/PM]',
|
||||||
f: (c, r) => {
|
f: (c, r) => {
|
||||||
serverConnection.hallAction('unlock');
|
let spec = r.trim();
|
||||||
|
if(!spec) {
|
||||||
|
serverConnection.hallAction('unlock');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let timeZone = '';
|
||||||
|
try {
|
||||||
|
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('Could not determine browser timezone', e);
|
||||||
|
}
|
||||||
|
serverConnection.hallAction('unlock', {
|
||||||
|
spec: spec,
|
||||||
|
timeZone: timeZone,
|
||||||
|
timezoneOffset: new Date().getTimezoneOffset(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3906,8 +3922,9 @@ document.getElementById('resizer').addEventListener('keydown', function(e) {
|
|||||||
/**
|
/**
|
||||||
* @param {unknown} message
|
* @param {unknown} message
|
||||||
* @param {string} [level]
|
* @param {string} [level]
|
||||||
|
* @param {boolean} [announce]
|
||||||
*/
|
*/
|
||||||
function displayError(message, level) {
|
function displayError(message, level, announce = true) {
|
||||||
if(!level)
|
if(!level)
|
||||||
level = "error";
|
level = "error";
|
||||||
let text = String(message);
|
let text = String(message);
|
||||||
@@ -3926,10 +3943,12 @@ function displayError(message, level) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(level === "info")
|
if(announce) {
|
||||||
announceChat(text);
|
if(level === "info")
|
||||||
else
|
announceChat(text);
|
||||||
announceUrgent(text);
|
else
|
||||||
|
announceUrgent(text);
|
||||||
|
}
|
||||||
|
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
Toastify({
|
Toastify({
|
||||||
@@ -3951,9 +3970,10 @@ function displayWarning(message) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} message
|
* @param {unknown} message
|
||||||
|
* @param {boolean} [announce]
|
||||||
*/
|
*/
|
||||||
function displayMessage(message) {
|
function displayMessage(message, announce = true) {
|
||||||
return displayError(message, "info");
|
return displayError(message, "info", announce);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('loginform').onsubmit = async function(e) {
|
document.getElementById('loginform').onsubmit = async function(e) {
|
||||||
|
|||||||
@@ -108,6 +108,18 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) {
|
|||||||
requireContains(t, js, "if(isGlobalShortcut(e, 't'))", "microphone shortcut")
|
requireContains(t, js, "if(isGlobalShortcut(e, 't'))", "microphone shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTimedUnlockCommandStaysDocumentedAndUsesServerAnnouncements(t *testing.T) {
|
||||||
|
js := readStaticFile(t, "skald.js")
|
||||||
|
|
||||||
|
requireContains(t, js, "[minutes[m|M] | HH:MM | h:mm AM/PM]", "unlock command help")
|
||||||
|
requireContains(t, js, "Intl.DateTimeFormat().resolvedOptions().timeZone", "unlock timezone")
|
||||||
|
requireContains(t, js, "timezoneOffset: new Date().getTimezoneOffset()", "unlock timezone fallback")
|
||||||
|
requireContains(t, js, "serverConnection.hallAction('unlock', {", "scheduled unlock request")
|
||||||
|
requireFunctionContains(t, js, "gotUserMessage", "displayMessage(text, false)")
|
||||||
|
requireFunctionContains(t, js, "displayError", "if(announce)")
|
||||||
|
requireFunctionContains(t, js, "displayError", "announceChat(text)")
|
||||||
|
}
|
||||||
|
|
||||||
func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) {
|
func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) {
|
||||||
html := readStaticFile(t, "skald.html")
|
html := readStaticFile(t, "skald.html")
|
||||||
js := readStaticFile(t, "skald.js")
|
js := readStaticFile(t, "skald.js")
|
||||||
|
|||||||
Reference in New Issue
Block a user