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 "errors"
5 "net/http"
6 "net/url"
7 "strings"
8 "time"
9 "unicode/utf8"
10
11 "github.com/go-chi/chi/v5"
12)
13
14const maxTitleLen = 200
15
16// titleTooLong is the one length rule a title is ever measured
17// against, shared by the create form and the API (api.go's
18// apiCreateMeeting): 200 characters, counted as runes so the two
19// surfaces agree on what "200 characters" means for a title that is
20// not plain ASCII — a byte count would let the API refuse a title the
21// screen's own copy ("Keep the title under 200 characters.") promises
22// is fine.
23func titleTooLong(title string) bool { return utf8.RuneCountInString(title) > maxTitleLen }
24
25// linkURL is built from configuration, never from the Host header.
26func (a *app) linkURL(token string) string {
27 return a.cfg.Origin + "/j/" + token
28}
29
30// parseStart reads the optional start time from the form in the
31// instance zone (config.go). Returns a message when it is malformed.
32func (a *app) parseStart(form url.Values) (*time.Time, string) {
33 raw := strings.TrimSpace(form.Get("starts_at"))
34 if raw == "" {
35 return nil, ""
36 }
37 t, err := time.ParseInLocation("2006-01-02T15:04", raw, a.cfg.zone)
38 if err != nil {
39 return nil, "Enter the start as a date and time, or leave it blank to start now."
40 }
41 u := t.UTC()
42 return &u, ""
43}
44
45// fmtTime is the instance zone's one date+time rendering, shared by
46// every screen that shows a stored moment (a meeting's start, an
47// agent's minted-at) rather than each keeping its own copy of the
48// layout string.
49func (a *app) fmtTime(t time.Time) string {
50 return t.In(a.cfg.zone).Format("Mon 2 Jan 2006, 15:04") + " " + a.cfg.Zone
51}
52
53func (a *app) fmtStart(t *time.Time) string {
54 if t == nil {
55 return ""
56 }
57 return a.fmtTime(*t)
58}
59
60func (a *app) index(w http.ResponseWriter, r *http.Request) {
61 p, ok := currentPerson(r)
62 if !ok || !a.isMember(p) {
63 http.Redirect(w, r, "/welcome", http.StatusSeeOther)
64 return
65 }
66 a.renderIndex(w, r, "", "")
67}
68
69func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, startErr string) {
70 views, err := a.store.listMeetings(r.Context())
71 if err != nil {
72 a.logger.Error("calls: list meetings", "err", err)
73 a.ErrorPage(w, r, http.StatusInternalServerError, "")
74 return
75 }
76 type row struct {
77 Sqid, Title, Starts string
78 Ended, LinkPresent bool
79 ByAgent bool
80 CreatedByPrincipal string
81 }
82 var upcoming, ended []row
83 for _, v := range views {
84 rw := row{
85 Sqid: v.Sqid, Title: v.Title, Starts: a.fmtStart(v.StartsAt),
86 Ended: v.EndedAt != nil, LinkPresent: v.LinkPresent,
87 ByAgent: v.ByAgent, CreatedByPrincipal: v.CreatedByPrincipal,
88 }
89 if rw.Ended {
90 ended = append(ended, rw)
91 } else {
92 upcoming = append(upcoming, rw)
93 }
94 }
95 render(w, "index", a.page(r, frame{}, map[string]any{
96 "Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone,
97 "TitleError": titleErr, "StartError": startErr, "HomeEnabled": a.showHomeConnection(r),
98 }))
99}
100
101func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) {
102 _ = r.ParseForm()
103 title := strings.TrimSpace(r.FormValue("title"))
104 var titleErr string
105 switch {
106 case title == "":
107 titleErr = "Give the meeting a title."
108 case titleTooLong(title):
109 titleErr = "Keep the title under 200 characters."
110 }
111 start, startErr := a.parseStart(r.Form)
112 if titleErr != "" || startErr != "" {
113 w.WriteHeader(http.StatusUnprocessableEntity)
114 a.renderIndex(w, r, titleErr, startErr)
115 return
116 }
117 p, _ := currentPerson(r)
118 m, token, err := a.store.createMeeting(r.Context(), title, start, actorFor(p))
119 if err != nil {
120 a.logger.Error("calls: create meeting", "err", err)
121 a.ErrorPage(w, r, http.StatusInternalServerError, "")
122 return
123 }
124 a.renderLinkOnce(w, r, m, token, false)
125}
126
127// autoTitle is a title Calls writes for a meeting nobody named, and the
128// zone its time is in.
129type autoTitle struct {
130 Text string
131 Zone string
132}
133
134// callTitle is the title a one-click "Start a call" meeting gets:
135// "Call, 10 Sep 09:15", in the starter's own time zone when the browser
136// or the app said which (zone.js, the apps' Start meeting), so it reads
137// as the time they started it, and otherwise in the instance's. now is
138// passed in so a test can fix it.
139//
140// The zone is not in the words. From f86d4da (2026-10-01, the shared
141// chrome) until 2026-10-02 a title in the instance zone ended with its
142// name — "Call, 2 Oct 15:40 Europe/Dublin" — so that a reader elsewhere
143// was not shown a bare time in a zone they could not see. That was the
144// title every meeting started from the Mac and iPhone apps got, because
145// they sent no zone, and Paul's screenshot of the Mac call is what it
146// looked like. The apps now send theirs, the name goes to TitleZone, and
147// the title reads as a title.
148func callTitle(now time.Time, local *time.Location, instance *time.Location) autoTitle {
149 loc := instance
150 if local != nil {
151 loc = local
152 }
153 return autoTitle{Text: "Call, " + now.In(loc).Format("2 Jan 15:04"), Zone: loc.String()}
154}
155
156// callZone is the browser's IANA zone from the Start a call form, or nil
157// for an empty, unknown or implausible one.
158func callZone(name string) *time.Location {
159 if name == "" || len(name) > 64 || name == "Local" {
160 return nil
161 }
162 loc, err := time.LoadLocation(name)
163 if err != nil {
164 return nil
165 }
166 return loc
167}
168
169// startCall is POST /meetings/start: the list's one-click button. It
170// creates a meeting with no start time — a call starts now — through
171// the same store.createMeeting every scheduled meeting goes through,
172// and answers with the same link-once page.
173func (a *app) startCall(w http.ResponseWriter, r *http.Request) {
174 p, _ := currentPerson(r)
175 title := callTitle(a.store.now(), callZone(r.PostFormValue("zone")), a.cfg.zone)
176 m, token, err := a.store.createCall(r.Context(), title, actorFor(p))
177 if err != nil {
178 a.logger.Error("calls: start call", "err", err)
179 a.ErrorPage(w, r, http.StatusInternalServerError, "")
180 return
181 }
182 a.renderLinkOnce(w, r, m, token, false)
183}
184
185// renderLinkOnce answers with a page, not a redirect: a token in a
186// query string lands in the edge's access logs (Sheets' ruling).
187func (a *app) renderLinkOnce(w http.ResponseWriter, r *http.Request, m Meeting, token string, replaced bool) {
188 w.Header().Set("Cache-Control", "no-store")
189 title := a.store.title(m)
190 render(w, "link_once", a.page(r, onMeeting("Your meeting link", title), map[string]any{
191 "Sqid": m.Sqid, "Title": title, "Starts": a.fmtStart(m.StartsAt),
192 "Link": a.linkURL(token), "Replaced": replaced,
193 }))
194}
195
196func (a *app) shareMeeting(w http.ResponseWriter, r *http.Request) {
197 m, ok := a.meetingForMember(w, r)
198 if !ok {
199 return
200 }
201 p, _ := currentPerson(r)
202 token, err := a.store.shareLink(r.Context(), m.ID, actorFor(p))
203 if errors.Is(err, errMeetingEnded) {
204 a.ErrorPage(w, r, http.StatusNotFound, "")
205 return
206 }
207 if err != nil {
208 a.logger.Error("calls: share meeting", "err", err)
209 a.ErrorPage(w, r, http.StatusInternalServerError, "")
210 return
211 }
212 w.Header().Set("Cache-Control", "no-store")
213 // strict-origin rather than no-referrer: the page shows the account
214 // menu, and a chained sign-out is admitted by id.* on the Referer's
215 // origin (spec §9.5). The path, which names the meeting, is never sent.
216 w.Header().Set("Referrer-Policy", "strict-origin")
217 link := a.linkURL(token)
218 if r.Header.Get("Accept") == "application/json" {
219 streamJSON(w, http.StatusOK, map[string]any{"url": link})
220 return
221 }
222 title := a.store.title(m)
223 render(w, "share", a.page(r, onMeeting("Share meeting", title), map[string]any{"Title": title, "Link": link}))
224}
225
226// meetingForMember loads the meeting in the URL or answers 404.
227func (a *app) meetingForMember(w http.ResponseWriter, r *http.Request) (Meeting, bool) {
228 m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid"))
229 if err != nil {
230 a.logger.Error("calls: load meeting", "err", err)
231 a.ErrorPage(w, r, http.StatusInternalServerError, "")
232 return Meeting{}, false
233 }
234 if !ok {
235 a.ErrorPage(w, r, http.StatusNotFound, "")
236 return Meeting{}, false
237 }
238 return m, true
239}
240
241func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) {
242 m, ok := a.meetingForMember(w, r)
243 if !ok {
244 return
245 }
246 render(w, "confirm", a.page(r, onMeeting("Replace the link?", a.store.title(m)), map[string]any{
247 "Title": "Replace the link?",
248 "Body": "Everyone holding a current link for \"" + a.store.title(m) + "\" will get a not-found page. You can share the replacement from the meeting.",
249 "Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link",
250 "CancelHref": "/", "Sqid": m.Sqid,
251 }))
252}
253
254func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) {
255 m, ok := a.meetingForMember(w, r)
256 if !ok {
257 return
258 }
259 if m.EndedAt != nil {
260 a.ErrorPage(w, r, http.StatusNotFound, "")
261 return
262 }
263 p, _ := currentPerson(r)
264 token, err := a.store.replaceLink(r.Context(), m.ID, actorFor(p))
265 // The screen's own answer for an ended meeting is the not-found
266 // page, above; a meeting that ended between that read and the write
267 // gets the same page rather than a 500, since the store now settles
268 // the question inside its transaction (review S5-2).
269 if errors.Is(err, errMeetingEnded) {
270 a.ErrorPage(w, r, http.StatusNotFound, "")
271 return
272 }
273 if err != nil {
274 a.logger.Error("calls: replace link", "err", err)
275 a.ErrorPage(w, r, http.StatusInternalServerError, "")
276 return
277 }
278 a.renderLinkOnce(w, r, m, token, true)
279}
280
281func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) {
282 m, ok := a.meetingForMember(w, r)
283 if !ok {
284 return
285 }
286 render(w, "confirm", a.page(r, onMeeting("End meeting for everyone?", a.store.title(m)), map[string]any{
287 "Title": "End meeting for everyone?",
288 "Body": "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.",
289 "Action": "/meetings/" + m.Sqid + "/end", "Label": "End meeting for everyone",
290 "CancelHref": "/", "Sqid": m.Sqid,
291 }))
292}
293
294func (a *app) endMeeting(w http.ResponseWriter, r *http.Request) {
295 m, ok := a.meetingForMember(w, r)
296 if !ok {
297 return
298 }
299 p, _ := currentPerson(r)
300 // The screen's answer is the same either way: ending a meeting that
301 // is already ended is the list page, not an error — a member who
302 // submits the confirm page twice has got what they asked for. The
303 // bool endMeeting now returns is the API's 409 (review S5-2), which
304 // this side has never had and does not gain here.
305 if _, err := a.store.endMeeting(r.Context(), m.ID, actorFor(p)); err != nil {
306 a.logger.Error("calls: end meeting", "err", err)
307 a.ErrorPage(w, r, http.StatusInternalServerError, "")
308 return
309 }
310 // The row is the authority; the hub is told so every open stream
311 // gets lost{ended} now rather than at the next tick (spec §2.4).
312 a.hub.End(m.Sqid)
313 http.Redirect(w, r, "/", http.StatusSeeOther)
314}
315