Unlock commands now support a given time, e.g 10 minutes.

This commit is contained in:
Storm Dragon
2026-07-22 21:37:34 -04:00
parent 704b1a9558
commit 6a94f893b6
8 changed files with 934 additions and 34 deletions
@@ -2,7 +2,7 @@
# shellcheck shell=bash disable=SC2034,SC2154
pkgname=skald-git
pkgver=2026.07.08.r0.g9a9742e
pkgver=2026.07.08.r1.g704b1a9
pkgrel=1
pkgdesc='Audio-only hall-based conferencing server'
arch=('x86_64' 'aarch64')
+35
View File
@@ -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{} {
g.mu.Lock()
defer g.mu.Unlock()
+357
View File
@@ -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()
}
}
+434
View File
@@ -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
View File
@@ -114,17 +114,18 @@ func broadcastChalkboard(g *hall.Hall, state hall.ChalkboardState) {
}
type webClient struct {
hall *hall.Hall
addr net.Addr
id string
username string
permissions []string
data map[string]interface{}
requested map[string][]string
done chan struct{}
writeCh chan interface{}
writerDone chan struct{}
actions *unbounded.Channel[any]
hall *hall.Hall
addr net.Addr
id string
username string
permissions []string
data map[string]interface{}
requested map[string][]string
done chan struct{}
writeCh chan interface{}
writerDone chan struct{}
actions *unbounded.Channel[any]
permissionsMu sync.RWMutex
mu sync.Mutex
down map[string]*rtpDownConnection
@@ -152,7 +153,9 @@ func (c *webClient) SetUsername(username 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{} {
@@ -160,7 +163,9 @@ func (c *webClient) Data() map[string]interface{} {
}
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 {
@@ -1249,11 +1254,16 @@ func handleAction(c *webClient, a any) error {
}
}
case changePermissionsAction:
allowRecording := false
if a.kind == "op" {
g := c.Hall()
allowRecording = g != nil && g.Description().AllowRecording
}
c.permissionsMu.Lock()
switch a.kind {
case "op":
c.permissions = addnew("op", c.permissions)
g := c.Hall()
if g != nil && g.Description().AllowRecording {
if allowRecording {
c.permissions = addnew("record", c.permissions)
}
case "unop":
@@ -1272,15 +1282,17 @@ func handleAction(c *webClient, a any) error {
case "unchalkboard":
c.permissions = remove("chalkboard", c.permissions)
default:
c.permissionsMu.Unlock()
return hall.UserError("unknown permission")
}
c.permissionsMu.Unlock()
c.action(permissionsChangedAction{})
case permissionsChangedAction:
g := c.Hall()
if g == nil {
return errors.New("Permissions changed in no hall")
}
perms := append([]string(nil), c.permissions...)
perms := c.Permissions()
status := g.Status(true, nil)
status.Recording = hallRecording(g)
username := c.username
@@ -1367,7 +1379,7 @@ func leaveHall(c *webClient) {
}
hall.DelClient(c)
c.permissions = nil
c.SetPermissions(nil)
c.data = nil
c.requested = make(map[string][]string)
c.hall = nil
@@ -1719,17 +1731,23 @@ func handleClientMessage(c *webClient, m clientMessage) error {
if !member("op", c.permissions) {
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 := ""
v, ok := m.Value.(string)
if ok {
message = v
}
g.SetLocked(m.Kind == "lock", message)
if m.Kind == "lock" {
broadcastHallInfo(g, "Hall locked")
} else {
broadcastHallInfo(g, "Hall unlocked")
}
setHallLockState(g, m.Kind == "lock", message)
case "record":
if !member("record", c.permissions) {
return c.error(hall.UserError("not authorised"))
+24
View File
@@ -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
+30 -10
View File
@@ -2417,7 +2417,7 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
}
let text = '' + message;
localMessage(text);
displayMessage(text);
displayMessage(text, false);
break;
}
case 'recording': {
@@ -3204,9 +3204,25 @@ commands.lock = {
commands.unlock = {
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) => {
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 {string} [level]
* @param {boolean} [announce]
*/
function displayError(message, level) {
function displayError(message, level, announce = true) {
if(!level)
level = "error";
let text = String(message);
@@ -3926,10 +3943,12 @@ function displayError(message, level) {
break;
}
if(level === "info")
announceChat(text);
else
announceUrgent(text);
if(announce) {
if(level === "info")
announceChat(text);
else
announceUrgent(text);
}
/** @ts-ignore */
Toastify({
@@ -3951,9 +3970,10 @@ function displayWarning(message) {
/**
* @param {unknown} message
* @param {boolean} [announce]
*/
function displayMessage(message) {
return displayError(message, "info");
function displayMessage(message, announce = true) {
return displayError(message, "info", announce);
}
document.getElementById('loginform').onsubmit = async function(e) {
+12
View File
@@ -108,6 +108,18 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) {
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) {
html := readStaticFile(t, "skald.html")
js := readStaticFile(t, "skald.js")