Files

390 lines
11 KiB
Go

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 scheduledUnlockDeadline(g *hall.Hall) (time.Time, bool) {
hallUnlockSchedules.Lock()
entry := hallUnlockSchedules.entries[g]
if entry == nil {
hallUnlockSchedules.Unlock()
return time.Time{}, false
}
entry.mu.Lock()
deadline, active := entry.deadline, entry.active
entry.mu.Unlock()
hallUnlockSchedules.Unlock()
return deadline, active
}
func scheduledUnlockRequestFromValue(value interface{}) (scheduledUnlockRequest, error) {
v, ok := value.(map[string]interface{})
if !ok {
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, true)
}
entry.mu.Unlock()
waitingHallChanged(g)
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),
), true)
}
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", true)
case hall.ConditionalUnlockClientAbsent:
broadcastHallInfo(entry.hall, fmt.Sprintf(
"Scheduled unlock cancelled because %s is no longer present in the hall.",
entry.issuerName,
), true)
case hall.ConditionalUnlockClientNotOperator:
broadcastHallInfo(entry.hall, fmt.Sprintf(
"Scheduled unlock cancelled because %s is no longer an operator.",
entry.issuerName,
), true)
}
entry.mu.Unlock()
hallUnlockSchedules.Unlock()
waitingHallChanged(entry.hall)
}
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.", true)
}
broadcastHallInfo(g, "Hall locked", false)
} else {
broadcastHallInfo(g, "Hall unlocked", false)
}
hallUnlockSchedules.Unlock()
waitingHallChanged(g)
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 cancelScheduledUnlockByIssuer(g *hall.Hall, issuer *webClient) bool {
hallUnlockSchedules.Lock()
entry := hallUnlockSchedules.entries[g]
if entry == nil || entry.issuer != issuer {
hallUnlockSchedules.Unlock()
return false
}
delete(hallUnlockSchedules.entries, g)
deactivateScheduledUnlock(entry)
hallUnlockSchedules.Unlock()
broadcastHallInfo(g, "Scheduled hall unlock cancelled because the issuing operator is no longer present as an operator.", true)
waitingHallChanged(g)
return true
}
func deactivateScheduledUnlock(entry *scheduledHallUnlock) {
entry.mu.Lock()
entry.active = false
stopScheduledUnlockTimers(entry)
entry.mu.Unlock()
}
func stopScheduledUnlockTimers(entry *scheduledHallUnlock) {
for _, timer := range entry.timers {
timer.Stop()
}
}