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 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "time"
11
12 "gorm.io/gorm"
13)
14
15// FeedView is a feed with its poll counts, for the share page.
16type FeedView struct {
17 Feed
18 CalendarName string
19}
20
21// MintFeed creates a subscription capability and returns the token —
22// once.
23//
24// Only the hash is stored. A plaintext token in this table would be a
25// live, unauthenticated read capability for somebody's whole calendar,
26// sitting in a file the platform replicates to object storage — which
27// is exactly what the one rule exists to prevent.
28//
29// The consequence is that the URL cannot be shown again. The share page
30// says so at the moment of minting, rather than letting somebody
31// discover it by coming back for it.
32func (s *Store) MintFeed(ctx context.Context, personID, calendarID int64) (string, error) {
33 if _, err := s.Calendar(ctx, personID, calendarID); err != nil {
34 return "", err
35 }
36 raw := make([]byte, 32)
37 if _, err := rand.Read(raw); err != nil {
38 return "", fmt.Errorf("calendar: no randomness for a feed token: %w", err)
39 }
40 token := hex.EncodeToString(raw)
41
42 f := Feed{
43 PersonID: personID,
44 CalendarID: calendarID,
45 TokenHash: HashFeedToken(token),
46 CreatedAt: time.Now().UTC(),
47 }
48 if err := s.g.WithContext(ctx).Create(&f).Error; err != nil {
49 return "", fmt.Errorf("calendar: mint feed: %w", err)
50 }
51 return token, nil
52}
53
54// HashFeedToken is the stored form. SHA-256 with no salt, deliberately:
55// the token is 32 bytes of entropy, so there is no dictionary to
56// defend against, and an unsalted hash is what lets a lookup be a
57// single indexed read rather than a scan of every row.
58func HashFeedToken(token string) string {
59 sum := sha256.Sum256([]byte(token))
60 return hex.EncodeToString(sum[:])
61}
62
63// FeedByToken resolves a subscription URL, and records the poll.
64//
65// The poll count is the honest part. A feed is not free: every poll
66// wakes a hibernating instance, and Apple Calendar polls roughly every
67// fifteen minutes forever. Recording it means the share page can show
68// what a feed actually costs rather than implying it is free.
69func (s *Store) FeedByToken(ctx context.Context, token string) (Feed, error) {
70 var f Feed
71 err := s.g.WithContext(ctx).
72 Where("token_hash = ? AND revoked_at IS NULL", HashFeedToken(token)).
73 First(&f).Error
74 if errors.Is(err, gorm.ErrRecordNotFound) {
75 return Feed{}, ErrNotFound
76 }
77 if err != nil {
78 return Feed{}, fmt.Errorf("calendar: read feed: %w", err)
79 }
80 now := time.Now().UTC()
81 // Best effort: a failed counter update must not fail the response.
82 // Somebody's calendar not loading is a real problem; a poll count
83 // being one short is not.
84 _ = s.g.WithContext(ctx).Model(&Feed{}).Where("id = ?", f.ID).
85 Updates(map[string]any{
86 "last_polled_at": now,
87 "poll_count": gorm.Expr("poll_count + 1"),
88 }).Error
89 return f, nil
90}
91
92// FeedsFor lists a person's own feeds on a calendar.
93func (s *Store) FeedsFor(ctx context.Context, personID, calendarID int64) ([]Feed, error) {
94 var out []Feed
95 err := s.g.WithContext(ctx).
96 Where("person_id = ? AND calendar_id = ? AND revoked_at IS NULL", personID, calendarID).
97 Order("created_at DESC").
98 Find(&out).Error
99 if err != nil {
100 return nil, fmt.Errorf("calendar: list feeds: %w", err)
101 }
102 return out, nil
103}
104
105// RevokeFeed turns a subscription URL off.
106//
107// Revoked rather than deleted, so the share page can keep showing that
108// it existed and how much it was polled — and so a URL that is still
109// pasted into somebody's phone stops working rather than silently
110// matching a later feed that happened to reuse the row.
111func (s *Store) RevokeFeed(ctx context.Context, personID, feedID int64) error {
112 now := time.Now().UTC()
113 res := s.g.WithContext(ctx).Model(&Feed{}).
114 Where("id = ? AND person_id = ?", feedID, personID).
115 Update("revoked_at", now)
116 if res.Error != nil {
117 return fmt.Errorf("calendar: revoke feed: %w", res.Error)
118 }
119 if res.RowsAffected == 0 {
120 return ErrNotFound
121 }
122 return nil
123}
124
125// EventsForFeed reads a calendar's events for export, opening the UID
126// and attaching the feed owner's own reminder offsets.
127func (s *Store) EventsForFeed(ctx context.Context, personID, calendarID, from, to int64) ([]EventView, error) {
128 all, err := s.feedEventRows(ctx, personID, calendarID, from, to)
129 if err != nil {
130 return nil, err
131 }
132 offsets, err := s.reminderOffsets(ctx, personID)
133 if err != nil {
134 return nil, err
135 }
136 out := make([]EventView, 0, len(all))
137 for _, e := range all {
138 if e.CalendarID != calendarID {
139 continue
140 }
141 uid, err := s.openUID(e.Event)
142 if err != nil {
143 return nil, err
144 }
145 e.UIDSealedOpen = uid
146 e.ReminderOffsetMin = offsets[e.ID]
147 out = append(out, e)
148 }
149 return out, nil
150}
151
152func (s *Store) openUID(e Event) (string, error) {
153 uid, err := s.seal.OpenString("event.uid", e.UIDSealed)
154 if err != nil {
155 return "", fmt.Errorf("calendar: open uid (event %d): %w", e.ID, err)
156 }
157 return uid, nil
158}
159
160// reminderOffsets maps event id to the person's largest warning on it,
161// for the alarms a feed can carry.
162func (s *Store) reminderOffsets(ctx context.Context, personID int64) (map[int64]int, error) {
163 var rows []Reminder
164 if err := s.g.WithContext(ctx).
165 Where("person_id = ?", personID).Find(&rows).Error; err != nil {
166 return nil, fmt.Errorf("calendar: read reminder offsets: %w", err)
167 }
168 out := map[int64]int{}
169 for _, r := range rows {
170 if r.OffsetMin > out[r.EventID] {
171 out[r.EventID] = r.OffsetMin
172 }
173 }
174 return out, nil
175}
176
177// SaveSettings updates a person's own display preferences.
178func (s *Store) SaveSettings(ctx context.Context, personID int64, zone string, weekStart, reminderMin, allDayMin int) error {
179 if _, err := Zone(zone); err != nil {
180 return ValidationError{"zone", "That is not a time zone we know."}
181 }
182 switch weekStart {
183 case WeekStartMonday, WeekStartSaturday, WeekStartSunday:
184 default:
185 weekStart = WeekStartMonday
186 }
187 err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
188 Updates(map[string]any{
189 "zone": zone,
190 "week_start": weekStart,
191 "default_reminder_min": reminderMin,
192 "default_all_day_min": allDayMin,
193 "updated_at": time.Now().UTC(),
194 }).Error
195 if err != nil {
196 return fmt.Errorf("calendar: save settings: %w", err)
197 }
198 return nil
199}
200