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 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "time"
10
11 "gorm.io/gorm"
12
13 "amadan.net/oficina/calendar/internal/calendar/seal"
14)
15
16var (
17 // ErrSlotTaken says the slot the booker chose is no longer free —
18 // somebody else held it, or the host's calendar filled it.
19 ErrSlotTaken = errors.New("calendar: slot taken")
20 // ErrOverCap says one of the abuse caps refused (booking spec §7.1).
21 ErrOverCap = errors.New("calendar: too many open holds")
22)
23
24// HoldInput is what a stranger's form supplies. Never bound from the
25// request: the handler validates each field and builds this.
26type HoldInput struct {
27 Page BookingPageView
28 Host Person
29 StartUTC, EndUTC int64
30 Name, Address string
31 Note, BookerZone string
32 IPFP string
33}
34
35// FreeSlots is the host's windows minus everything the host can see,
36// for one page, over [from, to). excludeEventID (0 = none) is the
37// booking being rescheduled; durationMin (0 = the page's) is the
38// EVENT's own length on a reschedule.
39//
40// It reads through the STRICT range reader and fails closed: a busy
41// series that could not be fully expanded is an error here, never a
42// free afternoon (booking spec B23).
43func (s *Store) FreeSlots(ctx context.Context, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64, durationMin int) ([]Slot, error) {
44 return s.freeSlotsOn(s.g.WithContext(ctx), page, host, now, from, to, excludeEventID, durationMin)
45}
46
47// freeSlotsOn is FreeSlots on a caller's transaction, so the hold and
48// the reschedule can re-check the slot behind the write lock (B12).
49func (s *Store) freeSlotsOn(tx *gorm.DB, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64, durationMin int) ([]Slot, error) {
50 skip := map[int64]bool{}
51 if excludeEventID != 0 {
52 skip[excludeEventID] = true
53 }
54 // A hold that lapsed but has not been swept yet is not busy: its
55 // slot is free the moment it lapses (spec §4.4).
56 var lapsed []int64
57 if err := tx.Model(&Booking{}).
58 Where("state = ? AND hold_expires_at <= ?", BookingHeld, now.Unix()).
59 Pluck("event_id", &lapsed).Error; err != nil {
60 return nil, fmt.Errorf("calendar: lapsed holds: %w", err)
61 }
62 for _, id := range lapsed {
63 skip[id] = true
64 }
65 busy, err := s.busyOn(tx, host, from.Unix(), to.Unix(), skip)
66 if err != nil {
67 return nil, err
68 }
69 rule := ruleFor(page, host)
70 if durationMin > 0 {
71 rule.DurationMin = durationMin
72 }
73 return ComputeSlots(rule, busy, now, from, to)
74}
75
76func ruleFor(page BookingPageView, host Person) SlotRule {
77 return SlotRule{
78 DurationMin: page.DurationMin, BufferMin: page.BufferMin,
79 NoticeMin: page.NoticeMin, HorizonDays: page.HorizonDays,
80 Zone: host.Zone, Windows: page.Windows,
81 }
82}
83
84// newToken is the management capability: 32 bytes from crypto/rand as
85// 64 hex characters (B14). Distinct from the eight-character page code,
86// which is not a secret.
87func newToken() string {
88 b := make([]byte, 32)
89 if _, err := rand.Read(b); err != nil {
90 panic("calendar: no randomness for a booking token: " + err.Error())
91 }
92 return hex.EncodeToString(b)
93}
94
95// HoldBooking is the hold transition (booking spec §5.2, §5.7).
96//
97// Inside holdMu: the caps, then a slot check for the RESPONSE, then one
98// transaction that first takes SQLite's write lock with a no-op write,
99// re-runs the slot check through the transaction, and only then
100// inserts. The mutex orders booking requests against each other; the
101// write lock orders them against an ordinary CreateEvent by the host,
102// which can land between a read outside the transaction and the
103// insert (r7 f2).
104//
105// Whatever happens to the mail afterwards, the hold stands (B16).
106func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (Booking, error) {
107 s.holdMu.Lock()
108 defer s.holdMu.Unlock()
109
110 addr := normalizeAddress(in.Address)
111 addrFP, err := s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr)
112 if err != nil {
113 return Booking{}, fmt.Errorf("calendar: fingerprint booker: %w", err)
114 }
115 counts, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now)
116 if err != nil {
117 return Booking{}, err
118 }
119 if counts.overCap() {
120 return Booking{}, ErrOverCap
121 }
122
123 loc, err := Zone(in.Host.Zone)
124 if err != nil {
125 return Booking{}, err
126 }
127 d := time.Unix(in.StartUTC, 0).In(loc)
128 day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
129 dayEnd := day.AddDate(0, 0, 1)
130
131 slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, day, dayEnd, 0, 0)
132 if err != nil {
133 return Booking{}, err
134 }
135 if !hasSlot(slots, in.StartUTC, in.EndUTC) {
136 return Booking{}, ErrSlotTaken
137 }
138 if s.afterSlotCheckForTest != nil {
139 s.afterSlotCheckForTest()
140 }
141
142 var b Booking
143 for attempt := 0; attempt < 2; attempt++ {
144 b, err = s.holdOnce(ctx, in, addrFP, day, dayEnd, now)
145 if err == nil || !errors.Is(err, errTokenCollision) {
146 return b, err
147 }
148 // A 256-bit collision is not a thing that happens; a second
149 // draw is the spec's whole answer to it, and then we stop.
150 }
151 return Booking{}, err
152}
153
154var errTokenCollision = errors.New("calendar: token collision")
155
156func (s *Store) holdOnce(ctx context.Context, in HoldInput, addrFP string, day, dayEnd, now time.Time) (Booking, error) {
157 token := newToken()
158 loc, _ := Zone(in.Host.Zone)
159
160 var b Booking
161 err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
162 // The write lock, before any read this decision depends on.
163 if err := tx.Exec(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`, in.Page.ID).Error; err != nil {
164 return fmt.Errorf("calendar: take write lock: %w", err)
165 }
166 // The page, its windows and the host as they are NOW, behind
167 // the lock: a page unpublished or a host who moved zone since
168 // the request read them must not be booked against.
169 page, host, err := s.currentPageOn(tx, in.Page.ID)
170 if err != nil {
171 return err
172 }
173 in.Page, in.Host = page, host
174 loc, err = Zone(host.Zone)
175 if err != nil {
176 return err
177 }
178 // The snapshot is sealed from the page AS RE-READ, so the
179 // booking's title and the event's title come from one version
180 // of it.
181 sealed, err := s.sealBooker(in, token)
182 if err != nil {
183 return err
184 }
185 again, err := s.freeSlotsOn(tx, page, host, now, day, dayEnd, 0, 0)
186 if err != nil {
187 return err
188 }
189 if !hasSlot(again, in.StartUTC, in.EndUTC) {
190 return ErrSlotTaken
191 }
192
193 calID, err := s.defaultCalendarOn(tx, in.Host.ID)
194 if err != nil {
195 return err
196 }
197 e, err := s.buildEvent(in.Host.ID, EventInput{
198 CalendarID: calID,
199 Title: in.Page.Title + " — " + in.Name,
200 Notes: in.Note,
201 StartLocal: FormatLocal(time.Unix(in.StartUTC, 0).In(loc), false),
202 EndLocal: FormatLocal(time.Unix(in.EndUTC, 0).In(loc), false),
203 TZ: in.Host.Zone,
204 Kind: KindMeeting,
205 Status: StatusTentative,
206 })
207 if err != nil {
208 return err
209 }
210 if err := tx.Create(&e).Error; err != nil {
211 return fmt.Errorf("calendar: create held event: %w", err)
212 }
213
214 expires := now.Add(HoldTTL).Unix()
215 if in.StartUTC < expires {
216 expires = in.StartUTC // B15: never confirmable after it starts
217 }
218 ipfp := in.IPFP
219 b = Booking{
220 PageID: in.Page.ID, EventID: e.ID,
221 TokenHash: HashFeedToken(token), TokenSealed: sealed.token,
222 State: BookingHeld,
223 TitleSealed: sealed.title,
224 DescriptionSealed: sealed.description,
225 BookerNameSealed: sealed.name, BookerAddressSealed: sealed.address,
226 BookerAddressFP: addrFP, NoteSealed: sealed.note,
227 BookerZone: in.BookerZone,
228 HoldExpiresAt: expires,
229 IPFP: &ipfp, IPForgetAt: now.Add(IPForgetAfter).Unix(),
230 CreatedAt: now.UTC(), UpdatedAt: now.UTC(),
231 }
232 if err := tx.Create(&b).Error; err != nil {
233 if isUniqueViolation(err) {
234 return errTokenCollision
235 }
236 return fmt.Errorf("calendar: create booking: %w", err)
237 }
238 return queueBookingMail(tx, b.ID, MailHold, []string{ToBooker}, 0, now)
239 })
240 if err != nil {
241 return Booking{}, err
242 }
243 return b, nil
244}
245