390 lines
11 KiB
Go
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()
|
|
}
|
|
}
|