oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "gorm.io/gorm"
9)
10
11// The sweep's booking half: expiring holds, forgetting IPs, and the two
12// NextDueAt sources those imply (booking spec §8).
13
14// ExpireHolds moves every lapsed hold to expired, soft-deletes its
15// tentative event, drops its reminder rows and forgets any mail it was
16// owed. Bounded to limit rows a pass; the rest come due on the next
17// wake, which NextDueAt already points at.
18func (s *Store) ExpireHolds(ctx context.Context, now time.Time, limit int) (int, error) {
19 var lapsed []Booking
20 err := s.g.WithContext(ctx).
21 Where("state = ? AND hold_expires_at <= ?", BookingHeld, now.Unix()).
22 Order("hold_expires_at ASC").Limit(limit).Find(&lapsed).Error
23 if err != nil {
24 return 0, fmt.Errorf("calendar: find lapsed holds: %w", err)
25 }
26 n := 0
27 for _, b := range lapsed {
28 err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
29 res := tx.Model(&Booking{}).
30 Where("id = ? AND state = ? AND hold_expires_at <= ?", b.ID, BookingHeld, now.Unix()).
31 Updates(map[string]any{"state": BookingExpired, "updated_at": now.UTC()})
32 if res.Error != nil {
33 return fmt.Errorf("calendar: expire hold: %w", res.Error)
34 }
35 if res.RowsAffected == 0 {
36 return nil // confirmed under us; not ours to expire
37 }
38 n++
39 return retireHeldEvent(tx, b.EventID, b.ID, now)
40 })
41 if err != nil {
42 return n, err
43 }
44 }
45 return n, nil
46}
47
48// retireHeldEvent is what an expired or abandoned hold leaves behind:
49// no event, no reminder rows, no owed mail.
50func retireHeldEvent(tx *gorm.DB, eventID, bookingID int64, now time.Time) error {
51 if err := tx.Model(&Event{}).Where("id = ? AND deleted_at IS NULL", eventID).
52 Update("deleted_at", now.UTC()).Error; err != nil {
53 return fmt.Errorf("calendar: retire held event: %w", err)
54 }
55 if err := deleteReminderRowsFor(tx, eventID); err != nil {
56 return err
57 }
58 return deleteUnsentMail(tx, bookingID)
59}
60
61// deleteReminderRowsFor drops the queue rows of every reminder on an
62// event. The rules stay: a cancelled meeting's reminder preference is
63// still the person's, and a queue row is what fires.
64func deleteReminderRowsFor(tx *gorm.DB, eventID int64) error {
65 err := tx.Exec(`DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, eventID).Error
66 if err != nil {
67 return fmt.Errorf("calendar: drop reminder rows: %w", err)
68 }
69 return nil
70}
71
72// ForgetOldIPs nulls the IP fingerprint of bookings past ip_forget_at.
73// The fingerprint's purpose — the abuse caps — ends with the attempt
74// window; a permanent network-linkage column is a leak the estate does
75// not otherwise have.
76func (s *Store) ForgetOldIPs(ctx context.Context, now time.Time, limit int) (int, error) {
77 var ids []int64
78 err := s.g.WithContext(ctx).Model(&Booking{}).
79 Where("ip_fp IS NOT NULL AND ip_forget_at <= ?", now.Unix()).
80 Order("ip_forget_at ASC").Limit(limit).Pluck("id", &ids).Error
81 if err != nil {
82 return 0, fmt.Errorf("calendar: find forgettable ips: %w", err)
83 }
84 if len(ids) == 0 {
85 return 0, nil
86 }
87 res := s.g.WithContext(ctx).Model(&Booking{}).Where("id IN ?", ids).Update("ip_fp", nil)
88 if res.Error != nil {
89 return 0, fmt.Errorf("calendar: forget ips: %w", res.Error)
90 }
91 return int(res.RowsAffected), nil
92}
93
94// NextHoldExpiry is the earliest lapse among open holds.
95func (s *Store) NextHoldExpiry(ctx context.Context) time.Time {
96 var at *int64
97 err := s.g.WithContext(ctx).Raw(`SELECT MIN(hold_expires_at) FROM bookings WHERE state = ?`, BookingHeld).Scan(&at).Error
98 if err != nil || at == nil {
99 return time.Time{}
100 }
101 return time.Unix(*at, 0)
102}
103
104// NextIPForget is the earliest fingerprint still to be forgotten.
105func (s *Store) NextIPForget(ctx context.Context) time.Time {
106 var at *int64
107 err := s.g.WithContext(ctx).Raw(`SELECT MIN(ip_forget_at) FROM bookings WHERE ip_fp IS NOT NULL`).Scan(&at).Error
108 if err != nil || at == nil {
109 return time.Time{}
110 }
111 return time.Unix(*at, 0)
112}
113
114// HoldCounts is what the abuse caps compare against (booking spec §7.1).
115type HoldCounts struct {
116 OpenIP, OpenPage, OpenAddress int
117 AttemptsIP, AttemptsAddress int
118}
119
120// The caps themselves.
121const (
122 MaxOpenHoldsPerIP = 5
123 MaxOpenHoldsPerPage = 20
124 MaxOpenHoldsPerAddress = 2
125 MaxAttemptsPerIP = 20
126 MaxAttemptsPerAddress = 6
127 attemptWindow = 24 * time.Hour
128)
129
130// HoldCountsFor reads the five counts for one prospective hold. Every
131// state counts toward attempts — that is why a hold whose mail failed
132// is a row and not a rollback.
133func (s *Store) HoldCountsFor(ctx context.Context, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error) {
134 var c HoldCounts
135 open := func(where string, args ...any) (int, error) {
136 var n int64
137 err := s.g.WithContext(ctx).Model(&Booking{}).
138 Where("state = ? AND hold_expires_at > ?", BookingHeld, now.Unix()).
139 Where(where, args...).Count(&n).Error
140 return int(n), err
141 }
142 attempts := func(where string, args ...any) (int, error) {
143 var n int64
144 err := s.g.WithContext(ctx).Model(&Booking{}).
145 Where("created_at > ?", now.Add(-attemptWindow).UTC()).
146 Where(where, args...).Count(&n).Error
147 return int(n), err
148 }
149 var err error
150 if c.OpenIP, err = open("ip_fp = ?", ipFP); err != nil {
151 return c, fmt.Errorf("calendar: count holds: %w", err)
152 }
153 if c.OpenPage, err = open("page_id = ?", pageID); err != nil {
154 return c, fmt.Errorf("calendar: count holds: %w", err)
155 }
156 if c.OpenAddress, err = open("booker_address_fp = ?", addressFP); err != nil {
157 return c, fmt.Errorf("calendar: count holds: %w", err)
158 }
159 if c.AttemptsIP, err = attempts("ip_fp = ?", ipFP); err != nil {
160 return c, fmt.Errorf("calendar: count attempts: %w", err)
161 }
162 if c.AttemptsAddress, err = attempts("booker_address_fp = ?", addressFP); err != nil {
163 return c, fmt.Errorf("calendar: count attempts: %w", err)
164 }
165 return c, nil
166}
167
168// overCap reports whether any cap refuses this hold.
169func (c HoldCounts) overCap() bool {
170 return c.OpenIP >= MaxOpenHoldsPerIP || c.OpenPage >= MaxOpenHoldsPerPage ||
171 c.OpenAddress >= MaxOpenHoldsPerAddress ||
172 c.AttemptsIP >= MaxAttemptsPerIP || c.AttemptsAddress >= MaxAttemptsPerAddress
173}
174