oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "strings"
10 "sync/atomic"
11 "time"
12
13 "gorm.io/gorm"
14 "gorm.io/gorm/clause"
15
16 "amadan.net/oficina/calls/internal/calls/seal"
17)
18
19// grantLife is ruling 4: a guest grant expires two hours after the
20// meeting's start, or two hours after creation when there is no start.
21const grantLife = 2 * time.Hour
22
23// pendingLife matches the pending-join cookie: fifteen minutes.
24const pendingLife = 15 * time.Minute
25
26// clock is the store's time source. It is a value tests move (see
27// TestHandle.SetClock) while request goroutines are reading it — a
28// stream handler outlives the request that opened it — so the function
29// lives behind an atomic rather than in a plain field.
30type clock struct{ fn atomic.Value }
31
32func newClock(f func() time.Time) *clock {
33 c := &clock{}
34 c.set(f)
35 return c
36}
37
38func (c *clock) set(f func() time.Time) { c.fn.Store(f) }
39
40func (c *clock) now() time.Time { return c.fn.Load().(func() time.Time)() }
41
42// store is every database access in the app. Sealing happens here and
43// nowhere else, so a handler never holds a plaintext title it did not
44// get from the store.
45type store struct {
46 g *gorm.DB
47 sealer *seal.Sealer
48 clk *clock
49 logger *slog.Logger
50}
51
52// now is the app's clock, the one a test can move.
53func (s *store) now() time.Time { return s.clk.now() }
54
55func actorFor(p Person) string { return fmt.Sprintf("person:%d", p.ID) }
56
57// ensurePerson reads first and writes only when the read is empty:
58// this runs on every signed-in request, and SQLite has one writer.
59func (s *store) ensurePerson(ctx context.Context, address string) (Person, error) {
60 email := normalizeAddress(address)
61 if email == "" {
62 return Person{}, errors.New("calls: cannot map an empty address to a person")
63 }
64 var found Person
65 err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error
66 switch {
67 case err == nil:
68 return found, nil
69 case !errors.Is(err, gorm.ErrRecordNotFound):
70 return Person{}, fmt.Errorf("calls: load person: %w", err)
71 }
72 name := email
73 if at := strings.Index(email, "@"); at > 0 {
74 name = email[:at]
75 }
76 created := Person{Email: email, Name: name, CreatedAt: s.now().UTC()}
77 if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created); res.Error != nil {
78 return Person{}, fmt.Errorf("calls: create person: %w", res.Error)
79 }
80 if err := s.g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil {
81 return Person{}, fmt.Errorf("calls: load person: %w", err)
82 }
83 return found, nil
84}
85
86type meetingView struct {
87 Meeting
88 Title string
89 LinkPresent bool
90 CreatedByPrincipal string
91 ByAgent bool
92}
93
94func (s *store) title(m Meeting) string {
95 t, err := s.sealer.OpenString(seal.PurposeMeetingTitle, m.TitleSealed)
96 if err != nil {
97 s.logger.Error("calls: open title", "meeting_id", m.ID, "err", err)
98 return "(title unreadable)"
99 }
100 return t
101}
102
103// createMeeting creates the meeting and its first link in one
104// transaction. The token is sealed for subsequent member sharing.
105func (s *store) createMeeting(ctx context.Context, title string, startsAt *time.Time, actor string) (Meeting, string, error) {
106 return s.insertMeeting(ctx, title, false, "", startsAt, actor)
107}
108
109// createCall is createMeeting for a meeting with no title of its own —
110// the list's Start a call and the apps' Start meeting: the title is the
111// one Calls wrote, and the meeting says so (Meeting.AutoTitle).
112func (s *store) createCall(ctx context.Context, title autoTitle, actor string) (Meeting, string, error) {
113 return s.insertMeeting(ctx, title.Text, true, title.Zone, nil, actor)
114}
115
116func (s *store) insertMeeting(ctx context.Context, title string, auto bool, zone string, startsAt *time.Time, actor string) (Meeting, string, error) {
117 sealed, err := s.sealer.SealString(seal.PurposeMeetingTitle, title)
118 if err != nil {
119 return Meeting{}, "", err
120 }
121 token, hash, err := newLinkToken()
122 if err != nil {
123 return Meeting{}, "", err
124 }
125 linkSealed, err := s.sealer.SealString(seal.PurposeMeetingLink, token)
126 if err != nil {
127 return Meeting{}, "", err
128 }
129 defaults, err := s.meetingDefaults(ctx)
130 if err != nil {
131 return Meeting{}, "", err
132 }
133 var m Meeting
134 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
135 for attempt := 0; ; attempt++ {
136 sqid, err := newSqid()
137 if err != nil {
138 return err
139 }
140 m = Meeting{RequireSignin: defaults.RequireSignin, WaitingRoom: defaults.WaitingRoom, AutoTitle: auto, TitleZone: zone, Sqid: sqid, TitleSealed: sealed, StartsAt: startsAt, CreatedBy: actor, CreatedAt: s.now().UTC()}
141 res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&m)
142 if res.Error != nil {
143 return res.Error
144 }
145 if res.RowsAffected == 1 {
146 break
147 }
148 if attempt+1 >= maxSqidAttempts {
149 return errors.New("calls: could not mint a unique sqid")
150 }
151 }
152 return tx.Create(&MeetingLink{MeetingID: m.ID, TokenHash: hash, TokenSealed: linkSealed, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error
153 })
154 if err != nil {
155 return Meeting{}, "", err
156 }
157 return m, token, nil
158}
159
160func (s *store) listMeetings(ctx context.Context) ([]meetingView, error) {
161 var ms []Meeting
162 if err := s.g.WithContext(ctx).Order("ended_at IS NOT NULL, starts_at IS NULL, starts_at ASC, created_at DESC").Find(&ms).Error; err != nil {
163 return nil, err
164 }
165 // labels is shared across the whole list so a run of meetings from
166 // the same actor (one agent's usual shape) resolves creatorLabel
167 // once rather than once per row — the N+1 Task 3's report flagged
168 // for exactly this move.
169 labels := make(map[string]string, len(ms))
170 out := make([]meetingView, 0, len(ms))
171 for _, m := range ms {
172 v, err := s.meetingViewOfCached(ctx, m, labels)
173 if err != nil {
174 return nil, err
175 }
176 out = append(out, v)
177 }
178 return out, nil
179}
180
181// meetingViewOf is one row as every reader wants it: the meeting, its
182// title opened, whether a live link exists, and who made it — an
183// agent's principal (§8.1's robot badge and operator name "on every
184// write") or a person's display name, resolved through creatorLabel.
185// Factored out of listMeetings so the drive surface's single-meeting
186// read (§8.2) answers link_present from the same query the list does,
187// rather than growing a second opinion about what "present" means.
188func (s *store) meetingViewOf(ctx context.Context, m Meeting) (meetingView, error) {
189 return s.meetingViewOfCached(ctx, m, map[string]string{})
190}
191
192// meetingViewOfCached is meetingViewOf's body, taking a creator-label
193// cache a caller can share across many rows. meetingViewOf itself
194// passes a throwaway map, since a single row has nothing to share a
195// cache with; listMeetings passes one map for the whole list.
196func (s *store) meetingViewOfCached(ctx context.Context, m Meeting, labels map[string]string) (meetingView, error) {
197 var n int64
198 if err := s.g.WithContext(ctx).Model(&MeetingLink{}).
199 Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&n).Error; err != nil {
200 return meetingView{}, err
201 }
202 by, ok := labels[m.CreatedBy]
203 if !ok {
204 by = s.creatorLabel(ctx, m.CreatedBy)
205 labels[m.CreatedBy] = by
206 }
207 return meetingView{
208 Meeting: m,
209 Title: s.title(m),
210 LinkPresent: n > 0,
211 CreatedByPrincipal: by,
212 ByAgent: strings.HasPrefix(m.CreatedBy, "agent:"),
213 }, nil
214}
215
216func (s *store) meetingBySqid(ctx context.Context, sqid string) (Meeting, bool, error) {
217 var m Meeting
218 err := s.g.WithContext(ctx).Where("sqid = ?", sqid).Take(&m).Error
219 if errors.Is(err, gorm.ErrRecordNotFound) {
220 return Meeting{}, false, nil
221 }
222 return m, err == nil, err
223}
224
225// personByID is the hub's half of standing: the tick and the entry
226// checks hold a person id, not a request, so the row is read back here.
227func (s *store) personByID(ctx context.Context, id int64) (Person, bool, error) {
228 var p Person
229 err := s.g.WithContext(ctx).Where("id = ?", id).Take(&p).Error
230 if errors.Is(err, gorm.ErrRecordNotFound) {
231 return Person{}, false, nil
232 }
233 return p, err == nil, err
234}
235
236// liveLink answers one false for a token that never existed, one that
237// was revoked, and one whose meeting has ended (spec §3.2's one 404).
238func (s *store) liveLink(ctx context.Context, token string) (MeetingLink, Meeting, bool, error) {
239 if token == "" {
240 return MeetingLink{}, Meeting{}, false, nil
241 }
242 return s.liveLinkByHash(ctx, hashLinkToken(token))
243}
244
245func (s *store) liveLinkByHash(ctx context.Context, hash string) (MeetingLink, Meeting, bool, error) {
246 var l MeetingLink
247 err := s.g.WithContext(ctx).Where("token_hash = ?", hash).Take(&l).Error
248 if errors.Is(err, gorm.ErrRecordNotFound) {
249 return MeetingLink{}, Meeting{}, false, nil
250 }
251 if err != nil {
252 return MeetingLink{}, Meeting{}, false, err
253 }
254 return s.liveLinkRow(ctx, l)
255}
256
257func (s *store) liveLinkByID(ctx context.Context, id int64) (MeetingLink, Meeting, bool, error) {
258 var l MeetingLink
259 err := s.g.WithContext(ctx).Where("id = ?", id).Take(&l).Error
260 if errors.Is(err, gorm.ErrRecordNotFound) {
261 return MeetingLink{}, Meeting{}, false, nil
262 }
263 if err != nil {
264 return MeetingLink{}, Meeting{}, false, err
265 }
266 return s.liveLinkRow(ctx, l)
267}
268
269func (s *store) liveLinkRow(ctx context.Context, l MeetingLink) (MeetingLink, Meeting, bool, error) {
270 if l.RevokedAt != nil {
271 return MeetingLink{}, Meeting{}, false, nil
272 }
273 var m Meeting
274 if err := s.g.WithContext(ctx).Where("id = ?", l.MeetingID).Take(&m).Error; err != nil {
275 return MeetingLink{}, Meeting{}, false, err
276 }
277 if m.EndedAt != nil {
278 return MeetingLink{}, Meeting{}, false, nil
279 }
280 return l, m, true, nil
281}
282
283// errMeetingEnded is replaceLink's answer when the meeting ended before
284// the transaction reached it (review S5-2). A caller's own earlier read
285// of ended_at is a snapshot, not a guarantee: the only read that can be
286// trusted against a concurrent end is the one inside the write's own
287// transaction, and this is that read's refusal.
288var errMeetingEnded = errors.New("calls: the meeting has ended")
289
290// replaceLink revokes every live link for the meeting and mints one
291// new one, in one transaction. The replacement is sealed for repeat sharing.
292//
293// The meeting's ended_at is re-read inside that transaction, matched in
294// the same WHERE as the id: a meeting row is never deleted, so the one
295// way the row fails to match is that it ended, and that is
296// errMeetingEnded. Without it a replacement could read a running
297// meeting, pause while another request ended it, and leave an
298// unrevoked link on an ended meeting.
299func (s *store) replaceLink(ctx context.Context, meetingID int64, actor string) (string, error) {
300 token, hash, err := newLinkToken()
301 if err != nil {
302 return "", err
303 }
304 sealed, err := s.sealer.SealString(seal.PurposeMeetingLink, token)
305 if err != nil {
306 return "", err
307 }
308 now := s.now().UTC()
309 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
310 var m Meeting
311 if err := tx.Where("id = ? AND ended_at IS NULL", meetingID).Take(&m).Error; err != nil {
312 if errors.Is(err, gorm.ErrRecordNotFound) {
313 return errMeetingEnded
314 }
315 return err
316 }
317 if err := tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error; err != nil {
318 return err
319 }
320 return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, TokenSealed: sealed, CreatedBy: actor, CreatedAt: now}).Error
321 })
322 if err != nil {
323 return "", err
324 }
325 return token, nil
326}
327
328// shareLink is a member action. Older hash-only invitations cannot be opened;
329// mint one recoverable invitation alongside them, then reuse it. Replacement
330// and ending still revoke every live invitation in their own transactions.
331func (s *store) shareLink(ctx context.Context, meetingID int64, actor string) (string, error) {
332 var token string
333 err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
334 var m Meeting
335 if err := tx.Where("id = ? AND ended_at IS NULL", meetingID).Take(&m).Error; err != nil {
336 if errors.Is(err, gorm.ErrRecordNotFound) {
337 return errMeetingEnded
338 }
339 return err
340 }
341 var link MeetingLink
342 err := tx.Where("meeting_id = ? AND revoked_at IS NULL AND token_sealed IS NOT NULL", meetingID).Order("id DESC").Take(&link).Error
343 if err == nil {
344 token, err = s.sealer.OpenString(seal.PurposeMeetingLink, link.TokenSealed)
345 if err != nil || token == "" || hashLinkToken(token) != link.TokenHash {
346 return errors.New("calls: cannot open invitation")
347 }
348 return nil
349 }
350 if !errors.Is(err, gorm.ErrRecordNotFound) {
351 return err
352 }
353 var hash string
354 token, hash, err = newLinkToken()
355 if err != nil {
356 return err
357 }
358 sealed, err := s.sealer.SealString(seal.PurposeMeetingLink, token)
359 if err != nil {
360 return err
361 }
362 return tx.Create(&MeetingLink{MeetingID: meetingID, TokenHash: hash, TokenSealed: sealed, CreatedBy: actor, CreatedAt: s.now().UTC()}).Error
363 })
364 return token, err
365}
366
367// endMeeting sets ended_at and revokes every link, and reports whether
368// this call is the one that ended the meeting. False means the
369// conditional UPDATE matched no row — the meeting was already ended,
370// by another request or an earlier one of this caller's — and nothing
371// was written, so the links the winning end revoked stay revoked
372// (review S5-2). Two racing ends therefore get one true and one false
373// rather than two 200s.
374func (s *store) endMeeting(ctx context.Context, meetingID int64, actor string) (bool, error) {
375 now := s.now().UTC()
376 ended := false
377 err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
378 res := tx.Model(&Meeting{}).Where("id = ? AND ended_at IS NULL", meetingID).
379 Select("ended_at", "ended_by").Updates(map[string]any{"ended_at": now, "ended_by": actor})
380 if res.Error != nil {
381 return res.Error
382 }
383 if res.RowsAffected == 0 {
384 return nil
385 }
386 ended = true
387 return tx.Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", meetingID).Update("revoked_at", now).Error
388 })
389 if err != nil {
390 return false, err
391 }
392 return ended, nil
393}
394
395func (s *store) grantFor(ctx context.Context, meetingID, personID int64) (GuestGrant, bool, error) {
396 var g GuestGrant
397 err := s.g.WithContext(ctx).Where("meeting_id = ? AND person_id = ?", meetingID, personID).Take(&g).Error
398 if errors.Is(err, gorm.ErrRecordNotFound) {
399 return GuestGrant{}, false, nil
400 }
401 return g, err == nil, err
402}
403
404// mintGrant is idempotent: following the link twice touches nothing.
405func (s *store) mintGrant(ctx context.Context, m Meeting, personID int64) (GuestGrant, error) {
406 if g, ok, err := s.grantFor(ctx, m.ID, personID); err != nil || ok {
407 return g, err
408 }
409 now := s.now().UTC()
410 base := now
411 if m.StartsAt != nil {
412 base = m.StartsAt.UTC()
413 }
414 g := GuestGrant{MeetingID: m.ID, PersonID: personID, GrantedAt: now, ExpiresAt: base.Add(grantLife)}
415 if res := s.g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&g); res.Error != nil {
416 return GuestGrant{}, res.Error
417 }
418 g, _, err := s.grantFor(ctx, m.ID, personID)
419 return g, err
420}
421
422// mintGrantViaLink mints a grant for the link the browser actually
423// followed, re-reading that link inside the grant's own transaction
424// (review C1-3). followLink resolves the link and then mints; between
425// those two steps a member can replace the link, and mintGrant on its
426// own would never notice, because it is handed the meeting rather than
427// the link. Standing checks the grant and not its origin, so such a
428// grant would stay usable for its full two hours.
429//
430// It answers false — no error — when the link has been revoked or its
431// meeting has ended, which the caller turns into the same 404 an unknown
432// token gets (spec §3.2's one 404).
433//
434// SQLite's writer pool is one connection, so the transaction's read and
435// its write cannot be interleaved with another transaction's.
436func (s *store) mintGrantViaLink(ctx context.Context, linkID, personID int64) (GuestGrant, bool, error) {
437 var g GuestGrant
438 live := false
439 err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
440 ts := &store{g: tx, sealer: s.sealer, clk: s.clk, logger: s.logger}
441 _, m, ok, err := ts.liveLinkByID(ctx, linkID)
442 if err != nil || !ok {
443 return err
444 }
445 if g, err = ts.mintGrant(ctx, m, personID); err != nil {
446 return err
447 }
448 live = true
449 return nil
450 })
451 if err != nil {
452 return GuestGrant{}, false, err
453 }
454 return g, live, nil
455}
456
457func (s *store) fingerprint(address string) (string, error) {
458 return s.sealer.Fingerprint(seal.PurposePendingAddress, normalizeAddress(address))
459}
460
461func (s *store) writePending(ctx context.Context, address string, link MeetingLink) error {
462 fp, err := s.fingerprint(address)
463 if err != nil {
464 return err
465 }
466 now := s.now().UTC()
467 return s.g.WithContext(ctx).Create(&PendingJoin{
468 MeetingID: link.MeetingID, LinkID: link.ID, AddressFingerprint: fp,
469 CreatedAt: now, ExpiresAt: now.Add(pendingLife),
470 }).Error
471}
472
473// pendingLive is the Authorize half: does this address hold an
474// unexpired pending row whose link is still live?
475func (s *store) pendingLive(ctx context.Context, address string) (bool, error) {
476 p, ok, err := s.latestPending(ctx, address)
477 if err != nil || !ok {
478 return false, err
479 }
480 _, _, live, err := s.liveLinkByID(ctx, p.LinkID)
481 return live, err
482}
483
484func (s *store) latestPending(ctx context.Context, address string) (PendingJoin, bool, error) {
485 fp, err := s.fingerprint(address)
486 if err != nil {
487 return PendingJoin{}, false, err
488 }
489 var p PendingJoin
490 err = s.g.WithContext(ctx).Where("address_fingerprint = ? AND expires_at > ?", fp, s.now().UTC()).
491 Order("created_at DESC, id DESC").Take(&p).Error
492 if errors.Is(err, gorm.ErrRecordNotFound) {
493 return PendingJoin{}, false, nil
494 }
495 return p, err == nil, err
496}
497
498func (s *store) deletePending(ctx context.Context, address string) error {
499 fp, err := s.fingerprint(address)
500 if err != nil {
501 return err
502 }
503 return s.g.WithContext(ctx).Where("address_fingerprint = ?", fp).Delete(&PendingJoin{}).Error
504}
505
506// consumePending re-checks the pending row's link and meeting as
507// GET /j/{token} would, mints the grant for a non-member, and deletes
508// every pending row for the address, all in one transaction. It returns
509// the holding-screen path, or "" when the re-check failed.
510//
511// The row is re-selected inside the transaction and the caller's value
512// is only trusted when it is still the newest unexpired one (review
513// C1-4). latestPending runs before the transaction opens, so by the time
514// we get here the row may have been consumed by a concurrent /continue,
515// or superseded by a newer request from another browser. A stale value
516// must not mint — and must not delete the row that superseded it, so a
517// mismatch deletes nothing at all and the caller falls through to / or
518// /welcome. Expiry needs no separate check: latestPending already
519// excludes expired rows, and the sweeper removes them.
520func (s *store) consumePending(ctx context.Context, p Person, pend PendingJoin, member bool) (string, error) {
521 dest := ""
522 err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
523 ts := &store{g: tx, sealer: s.sealer, clk: s.clk, logger: s.logger}
524 current, ok, err := ts.latestPending(ctx, p.Email)
525 if err != nil || !ok || current.ID != pend.ID {
526 return err
527 }
528 if err := ts.deletePending(ctx, p.Email); err != nil {
529 return err
530 }
531 _, m, live, err := ts.liveLinkByID(ctx, current.LinkID)
532 if err != nil || !live {
533 return err
534 }
535 if !member {
536 if _, err := ts.mintGrant(ctx, m, p.ID); err != nil {
537 return err
538 }
539 }
540 dest = "/m/" + m.Sqid + "/join"
541 return nil
542 })
543 return dest, err
544}
545
546// sweepLimit bounds one sweep call to a page of rows, so a large
547// backlog cannot hold SQLite's one write connection for an unbounded
548// delete (review C2-3). The caller loops while the count it gets back
549// is exactly this many.
550const sweepLimit = 1000
551
552// sweep deletes pending rows whose fifteen minutes are up, at most
553// sweepLimit per call via a subselect on ids (a bare DELETE has no
554// portable LIMIT). Reads already exclude expired rows; this is about
555// not keeping a record past its life (review C1-7). It returns how many
556// rows went.
557func (s *store) sweep(ctx context.Context, now time.Time) (int64, error) {
558 res := s.g.WithContext(ctx).Where(
559 "id IN (SELECT id FROM pending_joins WHERE expires_at <= ? LIMIT ?)",
560 now.UTC(), sweepLimit,
561 ).Delete(&PendingJoin{})
562 return res.RowsAffected, res.Error
563}
564
565// nextExpiry answers the earliest expires_at across pending_joins,
566// rastrillo's own auth_links, and rastrillo's own sessions, or the zero
567// time when all three are empty. It is the platform wake-up input for
568// cmd/calls/main.go's Options.NextDue (review C2-2).
569//
570// auth_links matters here too: a plain member sign-in writes no pending
571// row at all (that table exists for the outsider/pending-join path,
572// spec §3.2), only rastrillo's own magic-link row. If nextExpiry read
573// pending_joins alone, an app that hibernated with nothing outstanding
574// but one unclicked member link would never be woken to sweep it — the
575// platform sees a zero NextDue and assumes there is nothing to do
576// (round 2 follow-up, caught on re-review of 9e067e4).
577//
578// sessions is here for the same reason, one level further out:
579// auth.Sweep cleans auth_links AND sessions in one call
580// (auth/store.go:117 delegates to sessions.Sweep), so a session sitting
581// alone with nothing else outstanding needs the same wake-up guarantee
582// an unclicked link does (Codex round 3, C3-1).
583func (s *store) nextExpiry(ctx context.Context) (time.Time, error) {
584 var p PendingJoin
585 err := s.g.WithContext(ctx).Order("expires_at ASC").Take(&p).Error
586 var pending time.Time
587 switch {
588 case errors.Is(err, gorm.ErrRecordNotFound):
589 // pending stays zero.
590 case err != nil:
591 return time.Time{}, err
592 default:
593 pending = p.ExpiresAt
594 }
595
596 authLink, err := s.rastrilloTextExpiry(ctx, "auth_links")
597 if err != nil {
598 return time.Time{}, err
599 }
600 session, err := s.rastrilloTextExpiry(ctx, "sessions")
601 if err != nil {
602 return time.Time{}, err
603 }
604
605 next := time.Time{}
606 for _, t := range []time.Time{pending, authLink, session} {
607 if t.IsZero() {
608 continue
609 }
610 if next.IsZero() || t.Before(next) {
611 next = t
612 }
613 }
614 return next, nil
615}
616
617// rastrilloTextExpiry reads the earliest expires_at from one of
618// rastrillo's own tables — auth_links or sessions, the only two callers
619// pass, never anything caller-supplied. Both are rastrillo's tables, not
620// store models: rastrillo writes to them over database/sql rather than
621// gorm, storing expires_at as RFC3339 text via
622// now.UTC().Format(time.RFC3339) (auth/store.go:83,152,
623// sessions/sessions.go:157; see auth/migrations/0001_init.sql and
624// sessions/migrations/0001_init.sql for the column). MIN() over that
625// text column sorts correctly because every row is formatted the same
626// fixed-width, UTC way, so lexicographic and chronological order agree.
627// The zero time means no row; an empty table's MIN() is a SQL NULL, not
628// an empty string, hence sql.NullString rather than a bare string.
629func (s *store) rastrilloTextExpiry(ctx context.Context, table string) (time.Time, error) {
630 var raw sql.NullString
631 if err := s.g.WithContext(ctx).Raw("SELECT MIN(expires_at) FROM " + table).Row().Scan(&raw); err != nil {
632 return time.Time{}, err
633 }
634 if !raw.Valid || raw.String == "" {
635 return time.Time{}, nil
636 }
637 t, err := time.Parse(time.RFC3339, raw.String)
638 if err != nil {
639 return time.Time{}, fmt.Errorf("calls: parse %s.expires_at: %w", table, err)
640 }
641 return t, nil
642}
643
644// Identity rows use AUTOINCREMENT and are never recycled for another person.
645func (s *store) existingHomePerson(ctx context.Context, address string) (Person, error) {
646 var p Person
647 err := s.g.WithContext(ctx).Where("email = ?", normalizeAddress(address)).Take(&p).Error
648 return p, err
649}
650func (s *store) homePersonByID(ctx context.Context, q interface {
651 QueryRowContext(context.Context, string, ...any) *sql.Row
652}, id int64) (Person, error) {
653 var p Person
654 err := q.QueryRowContext(ctx, "SELECT id, email, name FROM persons WHERE id = ?", id).Scan(&p.ID, &p.Email, &p.Name)
655 return p, err
656}
657
658func (s *store) meetingDefaults(ctx context.Context) (MeetingDefaults, error) {
659 var d MeetingDefaults
660 err := s.g.WithContext(ctx).Where("id = ?", 1).Take(&d).Error
661 if errors.Is(err, gorm.ErrRecordNotFound) {
662 return MeetingDefaults{ID: 1}, nil
663 }
664 return d, err
665}
666func (s *store) saveMeetingDefaults(ctx context.Context, signin, waiting bool) error {
667 return s.g.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"require_signin", "waiting_room"})}).Create(&MeetingDefaults{ID: 1, RequireSignin: signin, WaitingRoom: waiting}).Error
668}
669
670// Meetings are team-owned, as are the other meeting mutations in this store.
671func (s *store) saveMeetingSettings(ctx context.Context, id int64, signin, waiting bool) error {
672 return s.g.WithContext(ctx).Model(&Meeting{}).Where("id = ? AND ended_at IS NULL", id).Select("RequireSignin", "WaitingRoom").Updates(map[string]any{"RequireSignin": signin, "WaitingRoom": waiting}).Error
673}
674func (s *store) createGuest(ctx context.Context, name string) (Person, error) {
675 p := Person{Email: "guest:" + nativeRandom(), Name: name, Anonymous: true, CreatedAt: s.now().UTC()}
676 err := s.g.WithContext(ctx).Create(&p).Error
677 return p, err
678}
679
680// saveDeviceToken records where a person can be reached when the app is
681// not running, and is an upsert on the token rather than on the person:
682// Apple reissues a token to whoever installs the app next, so the same
683// string arriving under a different person means the device changed
684// hands and the old row is wrong. LastSeen moves on every registration,
685// which is what lets a token that has gone quiet be swept later.
686func (s *store) saveDeviceToken(ctx context.Context, personID int64, token, platform string) error {
687 now := s.now().UTC()
688 return s.g.WithContext(ctx).Clauses(clause.OnConflict{
689 Columns: []clause.Column{{Name: "token"}},
690 DoUpdates: clause.AssignmentColumns([]string{"person_id", "platform", "last_seen"}),
691 }).Create(&DeviceToken{
692 PersonID: personID, Token: token, Platform: platform, CreatedAt: now, LastSeen: now,
693 }).Error
694}
695
696// deviceTokensFor answers every device a set of people can be reached
697// on. Plural because one person has a Mac and a phone, and a knock is
698// worth saying on both — whichever they are near.
699func (s *store) deviceTokensFor(ctx context.Context, personIDs []int64) ([]DeviceToken, error) {
700 if len(personIDs) == 0 {
701 return nil, nil
702 }
703 var tokens []DeviceToken
704 err := s.g.WithContext(ctx).Where("person_id IN ?", personIDs).Find(&tokens).Error
705 return tokens, err
706}
707
708// forgetDeviceToken deletes one token, which is the only correct answer
709// to Apple reporting it unregistered. Deleting by token and not by
710// person: the device is gone, the person is not.
711func (s *store) forgetDeviceToken(ctx context.Context, token string) error {
712 return s.g.WithContext(ctx).Where("token = ?", token).Delete(&DeviceToken{}).Error
713}
714
715// memberPersonIDs is everyone who may admit: the people whose address is
716// on the team allowlist. A knock is theirs to answer, and nobody else's.
717func (s *store) memberPersonIDs(ctx context.Context, allowed allowlist) ([]int64, error) {
718 var persons []Person
719 if err := s.g.WithContext(ctx).Where("email NOT LIKE ?", "guest:%").Find(&persons).Error; err != nil {
720 return nil, err
721 }
722 ids := make([]int64, 0, len(persons))
723 for _, p := range persons {
724 if allowed.allows(p.Email) {
725 ids = append(ids, p.ID)
726 }
727 }
728 return ids, nil
729}
730