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 "errors"
6 "fmt"
7 "time"
8
9 "gorm.io/gorm"
10
11 "amadan.net/oficina/calendar/internal/calendar/seal"
12)
13
14// ErrNotFound is what every scoped read returns when the row does not
15// exist OR the viewer has no grant on it. One error for both, on
16// purpose: a handler that turned "exists but not yours" into 403 would
17// let anyone enumerate the team's calendars by watching which ids
18// answered differently. A row that isn't yours doesn't exist.
19var ErrNotFound = errors.New("calendar: not found")
20
21// ErrReadOnly is a viewer attempting a write. It is separate from
22// ErrNotFound because the viewer CAN see the thing — they are looking
23// at its page — so hiding it would be a lie rather than a defence.
24var ErrReadOnly = errors.New("calendar: read only")
25
26// CalendarView is a Calendar with its sealed columns opened. Handlers
27// and templates only ever see this; the sealed struct never leaves the
28// store.
29type CalendarView struct {
30 Calendar
31 Name string
32
33 // Role is the viewer's grant on it, so a template can hide an edit
34 // control the server would refuse anyway.
35 Role string
36}
37
38// TeamCalendarName is what the one shared calendar is called before
39// anybody renames it.
40const TeamCalendarName = "Team"
41
42// EnsurePersonSetup makes a newly-signed-in person usable: their row,
43// the instance's one team calendar, a grant onto it, and a personal
44// calendar of their own.
45//
46// Done here rather than at boot because a boot-time write is a write
47// every instance performs whether or not anybody ever signs in, and
48// because the team calendar's first member is the fact that creates it.
49func (s *Store) EnsurePersonSetup(ctx context.Context, address string) (Person, error) {
50 p, err := s.EnsurePerson(ctx, address)
51 if err != nil {
52 return Person{}, err
53 }
54 team, err := s.ensureTeamCalendar(ctx, p.ID)
55 if err != nil {
56 return Person{}, err
57 }
58 // Editor, not owner: the team calendar belongs to the team, and
59 // nobody should be able to delete it out from under the others.
60 if err := s.ensureGrant(ctx, team.ID, p.ID, RoleEditor, p.ID); err != nil {
61 return Person{}, err
62 }
63 if err := s.ensurePersonalCalendar(ctx, p); err != nil {
64 return Person{}, err
65 }
66 return p, nil
67}
68
69func (s *Store) ensureTeamCalendar(ctx context.Context, creator int64) (Calendar, error) {
70 var c Calendar
71 err := s.g.WithContext(ctx).Where("kind = ?", "team").First(&c).Error
72 if err == nil {
73 return c, nil
74 }
75 if !errors.Is(err, gorm.ErrRecordNotFound) {
76 return Calendar{}, fmt.Errorf("calendar: find team calendar: %w", err)
77 }
78 name, err := s.seal.SealString(seal.PurposeCalendarName, TeamCalendarName)
79 if err != nil {
80 return Calendar{}, fmt.Errorf("calendar: seal team calendar name: %w", err)
81 }
82 c = Calendar{
83 NameSealed: name,
84 Kind: "team",
85 OwnerID: creator,
86 Hue: teamHue,
87 CreatedAt: time.Now().UTC(),
88 }
89 if err := s.g.WithContext(ctx).Create(&c).Error; err != nil {
90 // A concurrent first sign-in may have won the race. Re-read
91 // rather than fail: either row is the team calendar.
92 var again Calendar
93 if e := s.g.WithContext(ctx).Where("kind = ?", "team").First(&again).Error; e == nil {
94 return again, nil
95 }
96 return Calendar{}, fmt.Errorf("calendar: create team calendar: %w", err)
97 }
98 return c, nil
99}
100
101func (s *Store) ensurePersonalCalendar(ctx context.Context, p Person) error {
102 var n int64
103 if err := s.g.WithContext(ctx).Model(&Calendar{}).
104 Where("kind = ? AND owner_id = ?", "personal", p.ID).Count(&n).Error; err != nil {
105 return fmt.Errorf("calendar: count personal calendars: %w", err)
106 }
107 if n > 0 {
108 return nil
109 }
110 name, err := s.seal.SealString(seal.PurposeCalendarName, "My calendar")
111 if err != nil {
112 return fmt.Errorf("calendar: seal personal calendar name: %w", err)
113 }
114 c := Calendar{
115 NameSealed: name,
116 Kind: "personal",
117 OwnerID: p.ID,
118 Hue: personalHue(p.ID),
119 CreatedAt: time.Now().UTC(),
120 }
121 if err := s.g.WithContext(ctx).Create(&c).Error; err != nil {
122 return fmt.Errorf("calendar: create personal calendar: %w", err)
123 }
124 return s.ensureGrant(ctx, c.ID, p.ID, RoleOwner, p.ID)
125}
126
127func (s *Store) ensureGrant(ctx context.Context, calendarID, personID int64, role string, by int64) error {
128 g := Grant{}
129 err := s.g.WithContext(ctx).
130 Where(Grant{CalendarID: calendarID, PersonID: personID}).
131 Attrs(Grant{Role: role, GrantedBy: by, CreatedAt: time.Now().UTC()}).
132 FirstOrCreate(&g).Error
133 if err != nil {
134 return fmt.Errorf("calendar: ensure grant: %w", err)
135 }
136 return nil
137}
138
139// Calendars lists what a person may see, most recently created last so
140// the team calendar — always the first row written — leads.
141func (s *Store) Calendars(ctx context.Context, personID int64) ([]CalendarView, error) {
142 type row struct {
143 Calendar
144 Role string
145 }
146 var rows []row
147 err := s.g.WithContext(ctx).
148 Model(&Calendar{}).
149 Select("calendars.*, grants.role as role").
150 Joins("JOIN grants ON grants.calendar_id = calendars.id AND grants.person_id = ?", personID).
151 Where("calendars.archived_at IS NULL").
152 Order("calendars.id").
153 Scan(&rows).Error
154 if err != nil {
155 return nil, fmt.Errorf("calendar: list calendars: %w", err)
156 }
157 out := make([]CalendarView, 0, len(rows))
158 for _, r := range rows {
159 v, err := s.openCalendar(r.Calendar, r.Role)
160 if err != nil {
161 return nil, err
162 }
163 out = append(out, v)
164 }
165 return out, nil
166}
167
168// Calendar reads one, scoped. ErrNotFound covers both "no such id" and
169// "no grant" — see its own doc comment.
170func (s *Store) Calendar(ctx context.Context, personID, calendarID int64) (CalendarView, error) {
171 type row struct {
172 Calendar
173 Role string
174 }
175 var r row
176 err := s.g.WithContext(ctx).
177 Model(&Calendar{}).
178 Select("calendars.*, grants.role as role").
179 Joins("JOIN grants ON grants.calendar_id = calendars.id AND grants.person_id = ?", personID).
180 Where("calendars.id = ?", calendarID).
181 Limit(1).
182 Scan(&r).Error
183 if err != nil {
184 return CalendarView{}, fmt.Errorf("calendar: read calendar: %w", err)
185 }
186 if r.Calendar.ID == 0 {
187 return CalendarView{}, ErrNotFound
188 }
189 return s.openCalendar(r.Calendar, r.Role)
190}
191
192func (s *Store) openCalendar(c Calendar, role string) (CalendarView, error) {
193 name, err := s.seal.OpenString(seal.PurposeCalendarName, c.NameSealed)
194 if err != nil {
195 // No name in the error: it is the thing we failed to decrypt,
196 // and an error string is somewhere we cannot control the
197 // storage of.
198 return CalendarView{}, fmt.Errorf("calendar: open calendar name (id %d): %w", c.ID, err)
199 }
200 return CalendarView{Calendar: c, Name: name, Role: role}, nil
201}
202
203// DefaultCalendarID is where a new event lands when nobody has said
204// otherwise: the person's own calendar, falling back to the first they
205// can write.
206//
207// Personal rather than team, deliberately. Putting a half-formed event
208// on the shared calendar by default means the cost of forgetting to
209// change a dropdown is everybody else seeing it, and that asymmetry
210// should fall on the side of privacy.
211func (s *Store) DefaultCalendarID(ctx context.Context, personID int64) (int64, error) {
212 cals, err := s.Calendars(ctx, personID)
213 if err != nil {
214 return 0, err
215 }
216 for _, c := range cals {
217 if c.Kind == "personal" && c.OwnerID == personID {
218 return c.ID, nil
219 }
220 }
221 for _, c := range cals {
222 if CanWrite(c.Role) {
223 return c.ID, nil
224 }
225 }
226 return 0, nil
227}
228
229// Colour is an intent on the hue wheel, resolved to a real pair by the
230// theme so it clears the contrast floor in both schemes. teamHue is a
231// settled blue; a personal calendar is spread around the wheel by id so
232// two people rarely collide, and never predictably.
233const teamHue = 220
234
235func personalHue(personID int64) int {
236 // 137 is close to the golden angle in degrees, which spreads
237 // successive ids about as far apart as a fixed step can.
238 return int((personID*137 + 40) % 360)
239}
240