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() } }