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 "amadan.net/oficina/calendar/internal/calendar/seal"
13)
14
15// EventInput is what a form supplies. A struct, never a bind onto the
16// model: binding a request body onto a GORM struct is how a hidden
17// field ends up setting calendar_id, sequence, or created_by.
18type EventInput struct {
19 CalendarID int64
20 Title string
21 Location string
22 Notes string
23
24 AllDay bool
25 StartLocal string
26 EndLocal string
27 TZ string
28
29 RRule string
30 Kind string
31 Status string
32 Exdates string
33}
34
35// EventView is an Event with its sealed columns opened, plus the one
36// occurrence it is being shown as. Handlers and templates see only
37// this.
38type EventView struct {
39 Event
40 Title string
41 Location string
42 Notes string
43
44 // StartUTC and EndUTC on the embedded Event are the SERIES master's
45 // own instants. For an expanded occurrence these two are the
46 // occurrence's, which is what every view sorts and packs on.
47 OccStartUTC int64
48 OccEndUTC int64
49
50 // Attendees is populated only where it is needed — the ICS export.
51 // Every view query would otherwise pay a second read per event to
52 // carry a field no chip prints.
53 Attendees []string
54
55 // OccurrenceLocal is the occurrence's original local start, and the
56 // key an override is written against. Empty for a single event.
57 OccurrenceLocal string
58
59 // CalendarName and CalendarHue travel with the event because every
60 // view shows events from several calendars at once and a second
61 // lookup per chip is a query per row.
62 CalendarName string
63 CalendarHue int
64
65 // Role is the viewer's grant on the owning calendar.
66 Role string
67
68 // UIDSealedOpen is the opened iCalendar UID. Populated only where
69 // export needs it — every other view leaves it empty rather than
70 // decrypting a value nothing is going to print.
71 UIDSealedOpen string
72
73 // ReminderOffsetMin is the viewer's own warning on this event, in
74 // minutes before it starts, for a feed that carries alarms. Zero
75 // means none.
76 ReminderOffsetMin int
77}
78
79// Writable reports whether the viewer may change this event.
80func (v EventView) Writable() bool { return CanWrite(v.Role) }
81
82// CreateEvent writes a new event onto a calendar the person may write.
83func (s *Store) CreateEvent(ctx context.Context, personID int64, in EventInput) (Event, error) {
84 cal, err := s.Calendar(ctx, personID, in.CalendarID)
85 if err != nil {
86 return Event{}, err
87 }
88 if !CanWrite(cal.Role) {
89 return Event{}, ErrReadOnly
90 }
91 e, err := s.buildEvent(personID, in)
92 if err != nil {
93 return Event{}, err
94 }
95 if err := s.g.WithContext(ctx).Create(&e).Error; err != nil {
96 return Event{}, fmt.Errorf("calendar: create event: %w", err)
97 }
98 return e, nil
99}
100
101// buildEvent turns validated input into a row, sealing what is content
102// and deriving what is an index.
103func (s *Store) buildEvent(personID int64, in EventInput) (Event, error) {
104 if err := ValidateEventInput(&in); err != nil {
105 return Event{}, err
106 }
107 startUTC, err := ToUTC(in.StartLocal, in.TZ, in.AllDay)
108 if err != nil {
109 return Event{}, err
110 }
111 endUTC, err := ToUTC(in.EndLocal, in.TZ, in.AllDay)
112 if err != nil {
113 return Event{}, err
114 }
115
116 uid := newUID()
117 uidSealed, err := s.seal.SealString(seal.PurposeEventUID, uid)
118 if err != nil {
119 return Event{}, fmt.Errorf("calendar: seal uid: %w", err)
120 }
121 title, err := s.seal.SealString(seal.PurposeEventTitle, in.Title)
122 if err != nil {
123 return Event{}, fmt.Errorf("calendar: seal title: %w", err)
124 }
125 loc, err := s.seal.SealString(seal.PurposeEventLocation, in.Location)
126 if err != nil {
127 return Event{}, fmt.Errorf("calendar: seal location: %w", err)
128 }
129 notes, err := s.seal.SealString(seal.PurposeEventNotes, in.Notes)
130 if err != nil {
131 return Event{}, fmt.Errorf("calendar: seal notes: %w", err)
132 }
133
134 now := time.Now().UTC()
135 e := Event{
136 CalendarID: in.CalendarID,
137 UIDHash: HashUID(uid),
138 UIDSealed: uidSealed,
139
140 TitleSealed: title,
141 LocationSealed: loc,
142 NotesSealed: notes,
143
144 AllDay: in.AllDay,
145 StartLocal: in.StartLocal,
146 EndLocal: in.EndLocal,
147 TZ: in.TZ,
148 StartUTC: startUTC,
149 EndUTC: endUTC,
150
151 RRule: in.RRule,
152 Exdates: in.Exdates,
153 Kind: in.Kind,
154 Status: in.Status,
155
156 CreatedBy: personID,
157 CreatedAt: now,
158 UpdatedAt: now,
159 }
160 if until, ok := RuleUntilUTC(in.RRule, in.StartLocal, in.TZ, in.AllDay); ok {
161 e.UntilUTC = &until
162 }
163 return e, nil
164}
165
166// newUID mints an iCalendar UID. Random rather than derived from the
167// content, because a UID must survive every edit the content makes.
168func newUID() string {
169 b := make([]byte, 16)
170 if _, err := rand.Read(b); err != nil {
171 // crypto/rand does not fail on any platform this runs on, and
172 // a UID that silently repeated would make two events one on
173 // every client that imported them.
174 panic("calendar: no randomness for a UID: " + err.Error())
175 }
176 return hex.EncodeToString(b) + "@calendar.oficina"
177}
178
179// HashUID is the lookup key a re-import dedupes on, since the UID
180// itself is sealed and sealed values are not comparable.
181func HashUID(uid string) string {
182 sum := sha256.Sum256([]byte(uid))
183 return hex.EncodeToString(sum[:])
184}
185
186// NotFound reports whether err is the scoped-read miss, so handlers can
187// answer 404 without importing errors.Is at every call site.
188func NotFound(err error) bool { return errors.Is(err, ErrNotFound) }
189