diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index edbdea6..01c8169 100644 --- a/cmd/verdande/main.go +++ b/cmd/verdande/main.go @@ -75,6 +75,7 @@ func run() error { runner := jobs.New(cfg, db, log, api.Mail(), api.Hub()) runner.SyncGmail = api.SyncGmail runner.SyncMailbox = api.SyncMailbox + runner.SyncCalendars = api.SyncCalendars runner.Push = api.PushToUser runner.Start(jobCtx) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ade28d3..eb51ba4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2507,6 +2507,126 @@ paths: created: { type: integer } "502": { $ref: "#/components/responses/Upstream" } + /api/v1/calendar: + get: + tags: [integrations] + summary: The Google Calendar connection and the calendars in it. + description: | + Read-only. verdande shows a Google calendar beside its own dated tasks and + never writes one back — writing needs the `calendar.events` scope and an + answer to what happens when both sides moved the same meeting, which is a + synchronisation model rather than a scope. + + The list of calendars is fetched from Google when it is empty, so an + account that connected during a hiccup corrects itself on the next look + rather than showing an empty panel for ever. + responses: + "200": { $ref: "#/components/responses/Calendar" } + delete: + tags: [integrations] + summary: Disconnect the calendar and forget its tokens. + description: > + The cached events go too. Unlike the tasks a mailbox made, none of this is + the person's own work — it is a copy of somebody else's calendar, and it + means nothing without the account. + responses: + "204": { description: Disconnected. } + + /api/v1/calendar/calendars: + put: + tags: [integrations] + summary: Choose which calendars are drawn. + description: > + The whole set in one write rather than one flip at a time, for the reason + the projects' order is written the same way: the question is "these ones", + and a list built out of several requests can land half applied. A calendar + turned off has its cached events removed; they come back if it is turned + on again. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + shown: + type: array + items: { type: string } + responses: + "204": { description: Saved. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/calendar/authorize: + post: + tags: [integrations] + summary: Begin the OAuth flow for a calendar. + description: | + The same registration Gmail signs in through, asking for + `calendar.readonly` and coming back to `/oauth/calendar/callback`. Google + issues a refresh token per authorisation, so connecting a calendar does not + disturb a working Gmail connection. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + url: { type: string } + "409": + $ref: "#/components/responses/Conflict" + + /api/v1/calendar/sync: + post: + tags: [integrations] + summary: Refresh the chosen calendars now. + description: > + Refreshes the list of calendars and replaces the cached window of events + for the ones that are shown. `partial` means the budget ran out before the + last calendar; what was read is kept, and the rest arrive on the next run. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + events: { type: integer } + partial: { type: boolean } + "502": { $ref: "#/components/responses/Upstream" } + + /api/v1/calendar/events: + get: + tags: [integrations] + summary: The events of the chosen calendars, between two days. + description: | + Both ends inclusive, and an event that merely overlaps the range comes + back — a grid asks which cells an event covers, not where it begins. + + `from` and `to` in the answer are the window verdande holds a copy of, + which is not the window that was asked for. A grid paged past it gets no + events, and an empty answer with no explanation is a calendar quietly + claiming a day is clear. + parameters: + - { name: from, in: query, required: true, schema: { type: string, format: date } } + - { name: to, in: query, required: true, schema: { type: string, format: date } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + events: + type: array + items: { $ref: "#/components/schemas/CalendarEvent" } + from: { type: string, format: date } + to: { type: string, format: date } + "422": { $ref: "#/components/responses/Validation" } + /api/v1/export/notes.zip: get: tags: [notes] @@ -3150,6 +3270,26 @@ paths: responses: "302": { description: "Back into the app, with the outcome as a query parameter." } + /oauth/calendar/callback: + get: + tags: [integrations] + summary: Where Google sends the browser after consent to a calendar. + description: | + A URI of its own rather than a reuse of Gmail's. One callback deciding from + stored state which of the two flows had come back would put the choice of + which token store to write behind a lookup — and when that goes wrong it + goes wrong by overwriting a working Gmail connection with calendar tokens. + + Answers with a redirect to `/indstillinger/integrationer`, not with JSON. + An `org_internal` error here means the OAuth app is registered as Internal + and the account is outside the organisation. + parameters: + - { name: code, in: query, schema: { type: string } } + - { name: state, in: query, schema: { type: string } } + - { name: error, in: query, schema: { type: string } } + responses: + "302": { description: "Back into the app, with the outcome as a query parameter." } + /mcp: post: tags: [integrations] @@ -3332,6 +3472,40 @@ components: email: { type: string } trigger: { type: string, enum: ["", starred, label, both] } label: { type: string } + Calendar: + description: OK + content: + application/json: + schema: + type: object + properties: + connected: { type: boolean } + account: + type: string + description: > + The address of the connected account, taken from the id of its + primary calendar — which is where Google puts it, so no profile + scope has to be asked for to learn it. + read_only: + type: boolean + description: > + Always true today. Stated rather than assumed: an interface that + quietly looks editable is one somebody will try to edit. + has_client: + type: boolean + description: > + Whether an OAuth client is registered on this instance at all, so + the page can explain rather than offer a button that answers 409. + redirect_uri: + type: string + description: > + What has to be registered with Google, spelled out. Derived from + VERDANDE_BASE_URL and matched exactly — the single most likely + thing to be wrong, and the error Google gives names neither value. + last_sync_at: { type: string, format: date-time } + calendars: + type: array + items: { $ref: "#/components/schemas/Calendar" } ImportResult: description: Created content: @@ -3349,6 +3523,62 @@ components: items: { type: string } schemas: + Calendar: + type: object + description: > + One calendar inside a connected account. Kept whether or not it is shown, + so turning one off and on again gives back the same row. + properties: + id: { type: string } + remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." } + name: { type: string } + colour: + type: string + description: > + Google's own hex value, not one of verdande's ten named colours. Those + are names so the interface can restyle them per theme and they are + verdande's palette; a Google calendar is told apart by the colour the + person already knows it by. + time_zone: { type: string } + primary: { type: boolean } + writable: + type: boolean + description: > + Whether the connected account could write to it. Nothing does — the + scope is read-only — and it is stored so the day something can, the + interface knows which calendars could even be offered. + shown: { type: boolean } + + CalendarEvent: + type: object + description: > + One occurrence, in the shape a grid asks about. A recurrence is expanded by + Google into its occurrences, so what arrives here is a day, not a rule. + properties: + id: { type: string } + calendar_id: { type: string } + summary: { type: string } + starts_at: + type: string + description: > + RFC 3339 as Google wrote it, offset and all. Empty for an all-day + event, which has a day rather than a moment — inventing midnight for it + is how an event moves a day when a container moves time zone. + ends_at: { type: string } + start_day: { type: string, format: date } + end_day: + type: string + format: date + description: > + The last day the event covers, inclusive. Google's all-day end date is + exclusive and is corrected on the way in, once, rather than by every + reader. + all_day: { type: boolean } + location: { type: string } + url: { type: string, description: "Opens the event in Google's own interface." } + calendar_name: { type: string } + colour: { type: string } + Note: type: object description: A page of Markdown. The body is the stored form and the exported form. diff --git a/internal/config/config.go b/internal/config/config.go index 38526ad..afe7c2d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,10 +59,19 @@ type Config struct { // without spending the real one on every run. GmailSyncBudget time.Duration - // GmailEndpoints redirects the Gmail calls at a server the tests control. Empty - // in production, where it means Google. - GmailTokenURL string - GmailAPIURL string + // CalendarSyncBudget is the same bound for one calendar refresh, and exists for + // the same reason: a slow Google held the request open past what Cloudflare is + // willing to wait, and the browser got Cloudflare's HTML instead of an error + // this server could explain. + CalendarSyncBudget time.Duration + + // Where Google is. Empty in production; a test points them at a server it + // controls, which is the only way to exercise a Google that is slow or refuses. + // The token endpoint is shared — Gmail and Calendar sign in through the same + // registration — and the two APIs are not. + GoogleTokenURL string + GmailAPIURL string + CalendarAPIURL string PanelURL string PanelToken string @@ -98,14 +107,15 @@ func Load() (*Config, error) { TrashRetention: 30 * 24 * time.Hour, Dev: envBool("VERDANDE_DEV", false), - GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""), - GmailSyncBudget: 25 * time.Second, - PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"), - PanelToken: env("VERDANDE_PANEL_TOKEN", ""), - PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""), - GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""), - SecretKey: env("VERDANDE_SECRET_KEY", ""), - UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false), + GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""), + GmailSyncBudget: 25 * time.Second, + CalendarSyncBudget: 25 * time.Second, + PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"), + PanelToken: env("VERDANDE_PANEL_TOKEN", ""), + PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""), + GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""), + SecretKey: env("VERDANDE_SECRET_KEY", ""), + UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false), } var err error @@ -152,6 +162,15 @@ func (c *Config) BackupsDir() string { return filepath.Join(c.DataDir, "backups" // configured separately and left to drift out of step with it. func (c *Config) GmailRedirectURL() string { return c.BaseURL + "/oauth/gmail/callback" } +// CalendarRedirectURL is a second registered URI rather than a reuse of Gmail's. +// +// One callback that decided from server-side state which of the two flows had come +// back would save the operator one line in Google Cloud, and would put the choice +// of *which token store to write* behind a lookup. When that goes wrong it goes +// wrong by overwriting a working Gmail connection with calendar tokens — a failure +// nobody would think to look for. Two paths cannot do that. +func (c *Config) CalendarRedirectURL() string { return c.BaseURL + "/oauth/calendar/callback" } + func env(key, def string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v diff --git a/internal/gcal/gcal.go b/internal/gcal/gcal.go index da71332..4b8f1c7 100644 --- a/internal/gcal/gcal.go +++ b/internal/gcal/gcal.go @@ -37,6 +37,17 @@ const Scope = "https://www.googleapis.com/auth/calendar.readonly" const defaultAPIBase = "https://www.googleapis.com/calendar/v3" +// The OAuth flow, which is Google's rather than the calendar's — the same one +// Gmail signs in through, asking for the scope above. Aliased so a caller reads +// one package for one feature. +type ( + Config = google.Config + PKCE = google.PKCE + Token = google.Token +) + +var NewPKCE = google.NewPKCE + // ErrUnauthorized means the tokens are no longer good — access was revoked, or the // password changed. Re-exported so a caller need not know the flow lives elsewhere. var ErrUnauthorized = google.ErrUnauthorized diff --git a/internal/httpapi/calendar_handlers.go b/internal/httpapi/calendar_handlers.go new file mode 100644 index 0000000..c79cfcb --- /dev/null +++ b/internal/httpapi/calendar_handlers.go @@ -0,0 +1,548 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/kristianwind/verdande/internal/gcal" + "github.com/kristianwind/verdande/internal/store" +) + +// defaultCalendarSyncBudget bounds one "fetch now" when nothing else says +// otherwise. Well under the hundred seconds a proxy is willing to wait, so this +// server is the one that answers a slow Google rather than Cloudflare's own HTML +// page — which is how a failure arrives with no code, no message and nothing in +// the error log. +const defaultCalendarSyncBudget = 25 * time.Second + +// The window verdande keeps a copy of. +// +// A month grid pages, so asking Google on every arrow press would mean a round trip +// for a question answered ninety seconds ago — a calendar slower than the calendar +// it is showing. A cached window is the alternative, and a window has to end +// somewhere. Ninety days back is roughly "when was that", a year forward is past +// anything anybody has booked; between them they cover every screen the grid can +// reach without a person noticing an edge. +// +// The edge is still real, and the events endpoint says where it is rather than +// answering an empty list for a month outside it. A calendar that quietly shows no +// events is a calendar that lies about a day being clear. +const ( + calendarWindowBack = 90 + calendarWindowAhead = 365 + calendarDateLayout = "2006-01-02" + calendarProviderName = "google" +) + +func (s *Server) calendarClient(accessToken string) *gcal.Client { + return gcal.NewClient(accessToken).At(s.cfg.CalendarAPIURL) +} + +// calendarConfig is the same registration Gmail signs in through, asking for a +// different scope and coming back to a different URI. +// +// One OAuth client, two features. Google issues a refresh token per authorisation +// and keeps up to a hundred of them live per account, each carrying the scopes it +// was granted with — so connecting the calendar does not disturb a working Gmail, +// and disconnecting one does not take the other with it. +func (s *Server) calendarConfig(ctx context.Context) gcal.Config { + cfg := s.gmailConfig(ctx) + cfg.Scope = gcal.Scope + cfg.RedirectURL = s.cfg.CalendarRedirectURL() + return cfg +} + +// --- connecting ------------------------------------------------------------------ + +// handleCalendarAuthorize starts the flow. Same shape as Gmail's, and the same +// reason for keeping the verifier server-side: the callback arrives as a top-level +// navigation from Google, and a SameSite=Lax cookie is the kind of thing that works +// until a browser tightens its defaults. +func (s *Server) handleCalendarAuthorize(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + cfg := s.calendarConfig(r.Context()) + + if !cfg.Configured() { + writeError(w, http.StatusConflict, CodeGmailNotConfigured, + "no Google OAuth client is registered on this server") + return + } + + pkce, err := gcal.NewPKCE() + if err != nil { + s.internal(w, r, "calendar pkce", err) + return + } + + // Its own settings scope, not Gmail's. Two handshakes in one row would mean a + // person who started both flows had one verifier, and whichever came back + // second would be refused for a reason nothing on the screen could explain. + settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar") + if err != nil { + s.internal(w, r, "calendar settings", err) + return + } + settings["pkce_verifier"] = pkce.Verifier + settings["pkce_state"] = pkce.State + // The attempt expires: a stored verifier that lives forever is a replay window + // that lives forever. + settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix() + + if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil { + s.internal(w, r, "calendar settings", err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)}) +} + +// handleCalendarCallback is where Google sends the browser back. +// +// It answers with a redirect into the app rather than with JSON: the person is +// looking at a browser tab, not at a fetch response. +func (s *Server) handleCalendarCallback(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + settingsURL := s.cfg.BaseURL + "/indstillinger/integrationer" + + if errParam := r.URL.Query().Get("error"); errParam != "" { + // Somebody clicked "cancel" on the consent screen — or, on an Internal + // OAuth app, Google refused an account outside the organisation with + // `org_internal`. Both arrive here, and the page names what it was given. + http.Redirect(w, r, settingsURL+"?calendar="+errParam, http.StatusFound) + return + } + + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + if code == "" || state == "" { + http.Redirect(w, r, settingsURL+"?calendar=invalid", http.StatusFound) + return + } + + settings, err := s.db.UserSettings(r.Context(), user.ID, "calendar") + if err != nil { + s.internal(w, r, "calendar settings", err) + return + } + verifier, _ := settings["pkce_verifier"].(string) + expected, _ := settings["pkce_state"].(string) + expires, _ := settings["pkce_expires"].(float64) + + // The state has to match, or this endpoint accepts a code from anywhere. + if verifier == "" || expected == "" || state != expected { + http.Redirect(w, r, settingsURL+"?calendar=state", http.StatusFound) + return + } + if int64(expires) < time.Now().Unix() { + http.Redirect(w, r, settingsURL+"?calendar=expired", http.StatusFound) + return + } + + token, err := s.calendarConfig(r.Context()).Exchange(r.Context(), code, + gcal.PKCE{Verifier: verifier}) + if err != nil { + s.log.Error("calendar exchange", "err", err, "user", user.ID) + http.Redirect(w, r, settingsURL+"?calendar=failed", http.StatusFound) + return + } + if token.RefreshToken == "" { + // Without one the connection dies in an hour. Google only withholds it when + // the consent prompt was skipped, which access_type and prompt are meant to + // prevent — so this means something is wrong with the client registration + // rather than with this request. + s.log.Error("calendar returned no refresh token", "user", user.ID) + http.Redirect(w, r, settingsURL+"?calendar=norefresh", http.StatusFound) + return + } + + // The one-time values are cleared: keeping a spent verifier serves nothing and + // is one more secret at rest. + delete(settings, "pkce_verifier") + delete(settings, "pkce_state") + delete(settings, "pkce_expires") + if err := s.db.SetUserSettings(r.Context(), user.ID, "calendar", settings); err != nil { + s.internal(w, r, "clear calendar handshake", err) + return + } + + account := &store.CalendarAccount{ + UserID: user.ID, Provider: calendarProviderName, + RefreshToken: token.RefreshToken, AccessToken: token.AccessToken, + ExpiresAt: token.ExpiresAt, + } + if err := s.db.SaveCalendarAccount(r.Context(), account); err != nil { + s.internal(w, r, "save calendar tokens", err) + return + } + + // The list of calendars, fetched now so the page that opens next has something + // to tick rather than an empty panel and a button. Best effort: an account that + // connects but cannot list itself is still connected, and the status endpoint + // asks again when it finds nothing. + if _, err := s.refreshCalendarList(r.Context(), account); err != nil { + s.log.Warn("calendar list", "err", err, "user", user.ID) + } + + s.log.Info("calendar connected", "user", user.ID, "account", account.Account) + http.Redirect(w, r, settingsURL+"?calendar=connected", http.StatusFound) +} + +// --- what the settings page reads -------------------------------------------------- + +type calendarStatusJSON struct { + Connected bool `json:"connected"` + Account string `json:"account,omitempty"` + Calendars []store.Calendar `json:"calendars"` + // RedirectURI is what has to be registered with Google, spelled out. Derived + // from VERDANDE_BASE_URL and matched exactly — the single most likely thing to + // be wrong, and the error Google gives for it names neither value. + RedirectURI string `json:"redirect_uri"` + // HasClient says whether there is an OAuth registration to connect through at + // all, so the page can explain rather than offer a button that answers 409. + HasClient bool `json:"has_client"` + LastSyncAt string `json:"last_sync_at,omitempty"` + // ReadOnly is stated rather than assumed. Nothing writes a Google calendar, and + // an interface that quietly looks editable is one somebody will try to edit. + ReadOnly bool `json:"read_only"` +} + +func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + + out := calendarStatusJSON{ + Calendars: []store.Calendar{}, + RedirectURI: s.cfg.CalendarRedirectURL(), + HasClient: s.calendarConfig(r.Context()).Configured(), + ReadOnly: true, + } + + account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName) + if err != nil { + s.internal(w, r, "calendar account", err) + return + } + if account == nil || account.RefreshToken == "" { + writeJSON(w, http.StatusOK, out) + return + } + + out.Connected = true + out.Account = account.Account + if !account.LastSyncAt.IsZero() { + out.LastSyncAt = account.LastSyncAt.Format(time.RFC3339) + } + + calendars, err := s.db.Calendars(r.Context(), account.ID) + if err != nil { + s.internal(w, r, "calendars", err) + return + } + // An account connected during a Google hiccup has no calendars and no way back: + // the person sees an empty list and nothing to press. Asking again here costs + // one request on a page nobody opens often, and means the wrong answer lasts + // until the next look rather than for ever. Same reasoning as the Gmail address. + if len(calendars) == 0 { + if fetched, err := s.refreshCalendarList(r.Context(), account); err == nil { + calendars = fetched + out.Account = account.Account + } else { + s.log.Warn("calendar list", "err", err, "user", user.ID) + } + } + if calendars != nil { + out.Calendars = calendars + } + writeJSON(w, http.StatusOK, out) +} + +type showCalendarsRequest struct { + // Shown is the whole set, not one flip. Same reasoning as the projects' order: + // a person has a handful of calendars, the question is "these ones", and a list + // that can only be built by several requests can land half applied. + Shown []string `json:"shown"` +} + +func (s *Server) handleSetCalendars(w http.ResponseWriter, r *http.Request) { + var req showCalendarsRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName) + if err != nil { + s.internal(w, r, "calendar account", err) + return + } + if account == nil { + // A 404 rather than a 409: everything the caller may not see answers 404, + // and "there is no such connection" is the true answer either way. + writeError(w, http.StatusNotFound, CodeNotFound, "no calendar is connected") + return + } + if err := s.db.ShowCalendars(r.Context(), account.ID, req.Shown); err != nil { + s.internal(w, r, "show calendars", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleDisconnectCalendar forgets the connection, and the copy of the calendars +// with it. Unlike the tasks a mailbox made, none of this is the person's own work. +func (s *Server) handleDisconnectCalendar(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + account, err := s.db.CalendarAccountFor(r.Context(), user.ID, calendarProviderName) + if err != nil { + s.internal(w, r, "calendar account", err) + return + } + if account != nil { + if err := s.db.DeleteCalendarAccount(r.Context(), user.ID, account.ID); err != nil { + s.internal(w, r, "disconnect calendar", err) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + +// --- what the grid reads ----------------------------------------------------------- + +type calendarEventsJSON struct { + Events []store.CalendarEvent `json:"events"` + // From and To are the window verdande holds a copy of, not the window that was + // asked for. A grid that has paged past it gets no events, and an empty answer + // with no explanation is a calendar quietly claiming a day is clear. + From string `json:"from"` + To string `json:"to"` +} + +func (s *Server) handleCalendarEvents(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + from := r.URL.Query().Get("from") + to := r.URL.Query().Get("to") + + fields := map[string]string{} + if !validDay(from) { + fields["from"] = "must be a date, as 2026-08-20" + } + if !validDay(to) { + fields["to"] = "must be a date, as 2026-08-20" + } + if len(fields) > 0 { + writeFieldErrors(w, fields) + return + } + + events, err := s.db.CalendarEvents(r.Context(), user.ID, from, to) + if err != nil { + s.internal(w, r, "calendar events", err) + return + } + if events == nil { + events = []store.CalendarEvent{} + } + windowFrom, windowTo := calendarWindow(time.Now()) + writeJSON(w, http.StatusOK, calendarEventsJSON{ + Events: events, From: windowFrom, To: windowTo, + }) +} + +// handleCalendarSyncNow refreshes immediately, so somebody who has just connected +// can see it work rather than wondering whether it did. +func (s *Server) handleCalendarSyncNow(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + + // A budget of its own, well under what sits in front of this server. An unset + // budget is not a budget of nothing: a zero here would expire the context + // before the first call and make every sync a silent no-op. + budget := s.cfg.CalendarSyncBudget + if budget <= 0 { + budget = defaultCalendarSyncBudget + } + ctx, cancel := context.WithTimeout(r.Context(), budget) + defer cancel() + + count, err := s.SyncCalendars(ctx, user) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + // Not a failure: Google is slow or busy, some calendars were read, and + // the rest arrive on the next run. Every calendar is committed as it is + // read, so a short budget is a shorter run rather than a lost one. + writeJSON(w, http.StatusOK, map[string]any{"events": count, "partial": true}) + return + } + s.upstream(w, r, CodeCalendarFailed, "sync calendar", err) + return + } + writeJSON(w, http.StatusOK, map[string]int{"events": count}) +} + +// SyncCalendars refreshes every calendar this person has chosen to see. +// +// Returns how many events were written. Also used by the background job, which is +// why it takes a context and a user rather than a request. +func (s *Server) SyncCalendars(ctx context.Context, user *store.User) (int, error) { + account, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName) + if err != nil { + return 0, err + } + if account == nil || account.RefreshToken == "" { + return 0, nil // not connected; nothing to do and nothing to complain about + } + + // The same guard the mailboxes have, for the same reason: a press of "fetch + // now" during the sweep would otherwise have two runs replacing the same window + // at once, and the loser's delete would land after the winner's insert. + unlock := s.lockSync("calendar:" + account.ID) + defer unlock() + if fresh, err := s.db.CalendarAccountFor(ctx, user.ID, calendarProviderName); err == nil && fresh != nil { + account = fresh + } + + access, err := s.calendarAccessToken(ctx, account) + if err != nil { + return 0, err + } + client := s.calendarClient(access) + + // The list first, so a calendar added or renamed since the last run is drawn + // under its new name and one that has gone stops being drawn at all. + calendars, err := s.calendarList(ctx, client, account) + if err != nil { + return 0, s.forgetIfRevoked(ctx, user, account, err) + } + + from, to := calendarWindow(time.Now()) + written := 0 + for _, calendar := range calendars { + if !calendar.Shown { + continue + } + // A spent budget ends the run rather than turning into a failed fetch per + // calendar, logged one at a time. + if ctx.Err() != nil { + return written, ctx.Err() + } + events, err := client.Events(ctx, calendar.RemoteID, from, to) + if err != nil { + if errors.Is(err, gcal.ErrUnauthorized) { + return written, s.forgetIfRevoked(ctx, user, account, err) + } + // One calendar somebody was unshared from must not stop the others. + s.log.Warn("calendar events", "err", err, "calendar", calendar.Name) + continue + } + + rows := make([]store.CalendarEvent, 0, len(events)) + for _, e := range events { + rows = append(rows, store.CalendarEvent{ + RemoteID: e.ID, Summary: e.Summary, + StartsAt: e.StartsAt, EndsAt: e.EndsAt, + StartDay: e.StartDay, EndDay: e.EndDay, + AllDay: e.AllDay, Location: e.Location, URL: e.URL, + }) + } + if err := s.db.ReplaceCalendarEvents(ctx, calendar.ID, from, to, rows); err != nil { + return written, err + } + written += len(rows) + } + + if err := s.db.MarkCalendarSynced(ctx, account.ID, time.Now()); err != nil { + return written, err + } + return written, nil +} + +// calendarAccessToken returns a usable access token, refreshing only when the one +// held has actually expired. +func (s *Server) calendarAccessToken(ctx context.Context, account *store.CalendarAccount) (string, error) { + if account.AccessToken != "" && account.ExpiresAt.After(time.Now()) { + return account.AccessToken, nil + } + token, err := s.calendarConfig(ctx).Refresh(ctx, account.RefreshToken) + if err != nil { + return "", err + } + account.AccessToken = token.AccessToken + account.ExpiresAt = token.ExpiresAt + if err := s.db.SaveCalendarAccount(ctx, account); err != nil { + return "", err + } + return account.AccessToken, nil +} + +// refreshCalendarList fetches the calendar list with a token it obtains itself. +// Used by the paths that have an account but no client yet — connecting, and the +// status endpoint correcting an empty list. +func (s *Server) refreshCalendarList(ctx context.Context, account *store.CalendarAccount) ([]store.Calendar, error) { + access, err := s.calendarAccessToken(ctx, account) + if err != nil { + return nil, err + } + return s.calendarList(ctx, s.calendarClient(access), account) +} + +// calendarList writes what the provider says and returns what is stored, which is +// not the same list: the provider says what exists, and only verdande knows which +// of them somebody chose to look at. +func (s *Server) calendarList(ctx context.Context, client *gcal.Client, account *store.CalendarAccount) ([]store.Calendar, error) { + remote, err := client.Calendars(ctx) + if err != nil { + return nil, err + } + + list := make([]store.Calendar, 0, len(remote)) + for _, c := range remote { + list = append(list, store.Calendar{ + RemoteID: c.ID, Name: c.Name, Colour: c.Colour, + TimeZone: c.TimeZone, Primary: c.Primary, Writable: c.Owner, + }) + // The primary calendar is the account, and its id is the address. It is the + // only place the connected account names itself without a second scope — + // asking for a profile scope to learn an address already in the answer would + // be one more thing on the consent screen for nothing. + if c.Primary && account.Account == "" { + account.Account = c.ID + if err := s.db.SaveCalendarAccount(ctx, account); err != nil { + return nil, err + } + } + } + if err := s.db.ReplaceCalendars(ctx, account.ID, list); err != nil { + return nil, err + } + return s.db.Calendars(ctx, account.ID) +} + +// forgetIfRevoked disconnects when Google says the grant is gone, and passes every +// other failure through untouched. +// +// The distinction is the point. A revoked account polled every ten minutes for ever +// is a log line a week long and a connection the person believes they removed; a +// Google that is merely down is one that will answer next time. +func (s *Server) forgetIfRevoked(ctx context.Context, user *store.User, account *store.CalendarAccount, err error) error { + if errors.Is(err, gcal.ErrUnauthorized) { + s.log.Info("calendar access was revoked; disconnecting", "user", user.ID) + _ = s.db.DeleteCalendarAccount(ctx, user.ID, account.ID) + } + return err +} + +// calendarWindow is the span verdande keeps a copy of, measured from today. +// +// Built from the calendar date rather than by truncating the instant: Truncate +// rounds against the Unix epoch, which is midnight UTC, and in Copenhagen that is +// two in the morning — so for two hours of every summer day the window would start +// and end a day out. +func calendarWindow(now time.Time) (string, string) { + day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + return day.AddDate(0, 0, -calendarWindowBack).Format(calendarDateLayout), + day.AddDate(0, 0, calendarWindowAhead).Format(calendarDateLayout) +} + +func validDay(s string) bool { + _, err := time.Parse(calendarDateLayout, s) + return err == nil +} diff --git a/internal/httpapi/calendar_test.go b/internal/httpapi/calendar_test.go new file mode 100644 index 0000000..97f5b21 --- /dev/null +++ b/internal/httpapi/calendar_test.go @@ -0,0 +1,471 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/store" +) + +// fakeCalendarGoogle stands in for the parts of Google this feature touches: the +// token endpoint, the calendar list and one calendar's events. +// +// There is no other way to exercise any of it. The real thing needs a registered +// OAuth client, an account and somebody's consent, and none of the three can be had +// inside a test — so the shapes Google returns are written down and the behaviour +// is checked against them, the way internal/imap is checked against go-imap's own +// in-process server. +type fakeCalendarGoogle struct { + *httptest.Server + // refreshes counts token refreshes, so a test can prove one is not made for a + // token that has not expired. + refreshes int + // refuse makes every API call answer 401, which is what a revoked grant does. + refuse bool + events map[string][]map[string]any +} + +func newFakeCalendarGoogle(t *testing.T) *fakeCalendarGoogle { + t.Helper() + g := &fakeCalendarGoogle{events: map[string][]map[string]any{}} + g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/token": + g.refreshes++ + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "ya29.fresh", "refresh_token": "1//0-refresh", + "expires_in": 3600, + }) + case g.refuse: + http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized) + case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"): + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{ + {"id": "kw@nolimit.dk", "summary": "kw@nolimit.dk", "primary": true, + "accessRole": "owner", "backgroundColor": "#3f51b5", + "timeZone": "Europe/Copenhagen"}, + {"id": "fam", "summary": "Familien", "accessRole": "reader", + "backgroundColor": "#0b8043"}, + }}) + case strings.HasPrefix(r.URL.Path, "/calendars/"): + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events") + items := g.events[id] + if items == nil { + items = []map[string]any{} + } + _ = json.NewEncoder(w).Encode(map[string]any{"items": items}) + default: + http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(g.Close) + return g +} + +// connectedCalendar returns a server with a calendar account already connected — +// the state the OAuth callback leaves behind, without the consent screen nothing +// can automate. +func connectedCalendar(t *testing.T, g *fakeCalendarGoogle, tune func(*config.Config)) (*testServer, *store.User) { + t.Helper() + ts := newTestServerWith(t, func(cfg *config.Config) { + cfg.GmailClientID = "test-client" + cfg.GmailClientSecret = "test-secret" + cfg.GoogleTokenURL = g.URL + "/token" + cfg.CalendarAPIURL = g.URL + if tune != nil { + tune(cfg) + } + }) + ts.bootstrap(t) + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + account := &store.CalendarAccount{ + UserID: user.ID, Provider: "google", RefreshToken: "1//0-refresh", + } + if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil { + t.Fatal(err) + } + return ts, user +} + +// The scope on the consent screen is what somebody agrees to. It has to be the +// read-only one, and the redirect has to be the calendar's own — sharing Gmail's +// would put "which token store do I write" behind a lookup. +func TestAuthorizeAsksForReadOnlyCalendar(t *testing.T) { + g := newFakeCalendarGoogle(t) + ts, _ := connectedCalendar(t, g, nil) + + resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %v", resp.StatusCode, body) + } + url, _ := body["url"].(string) + for _, want := range []string{ + "scope=" + "https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly", + "%2Foauth%2Fcalendar%2Fcallback", + "access_type=offline", + "prompt=consent", + } { + if !strings.Contains(url, want) { + t.Errorf("the authorisation URL is missing %q:\n%s", want, url) + } + } + if strings.Contains(url, "calendar.events&") || strings.Contains(url, "auth%2Fcalendar&") { + t.Errorf("a writing scope was asked for:\n%s", url) + } +} + +// Without a registered client there is nothing to connect through, and the answer +// has to say that rather than "that already exists". +func TestAuthorizeWithoutAClientSaysSo(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "POST", "/api/v1/calendar/authorize", nil) + if resp.StatusCode != http.StatusConflict { + t.Fatalf("status %d, want 409", resp.StatusCode) + } + if body["code"] != CodeGmailNotConfigured { + t.Errorf("code = %v, want %q", body["code"], CodeGmailNotConfigured) + } +} + +// The whole path, minus the consent screen: sync fetches the list, refreshes the +// events of what was chosen, and the grid endpoint answers with them. +func TestSyncFetchesTheChosenCalendarsAndTheGridReadsThem(t *testing.T) { + g := newFakeCalendarGoogle(t) + g.events["fam"] = []map[string]any{ + {"id": "e1", "summary": "Bestyrelsesmøde", "htmlLink": "https://calendar.google.com/e1", + "start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"}, + "end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}}, + } + g.events["kw@nolimit.dk"] = []map[string]any{ + {"id": "e2", "summary": "Skal ikke med", + "start": map[string]string{"date": "2026-08-21"}, + "end": map[string]string{"date": "2026-08-22"}}, + } + ts, user := connectedCalendar(t, g, nil) + + // The list arrives on the first look at the settings page, which is what + // somebody would be doing at this point. + resp, body := ts.do(t, "GET", "/api/v1/calendar", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %v", resp.StatusCode, body) + } + if body["connected"] != true { + t.Fatalf("not reported as connected: %v", body) + } + if body["read_only"] != true { + t.Error("the connection did not say it is read-only; an interface that looks " + + "editable is one somebody will try to edit") + } + // The primary calendar's id is the address, which is how the account names + // itself without asking for a profile scope as well. + if body["account"] != "kw@nolimit.dk" { + t.Errorf("account = %v", body["account"]) + } + + list, _ := body["calendars"].([]any) + if len(list) != 2 { + t.Fatalf("got %d calendars, want 2: %v", len(list), body["calendars"]) + } + var famID string + for _, raw := range list { + c := raw.(map[string]any) + if c["shown"] == true { + t.Errorf("%v was shown without anybody choosing it", c["name"]) + } + if c["remote_id"] == "fam" { + famID = c["id"].(string) + } + } + + // Nothing is chosen, so a sync writes nothing: an account with a dozen shared + // calendars must not fill somebody's grid the moment it is connected. + resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil) + if resp.StatusCode != http.StatusOK || body["events"] != float64(0) { + t.Fatalf("an unchosen calendar was fetched: %d %v", resp.StatusCode, body) + } + + resp, _ = ts.do(t, "PUT", "/api/v1/calendar/calendars", + map[string]any{"shown": []string{famID}}) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("choosing a calendar: status %d", resp.StatusCode) + } + + resp, body = ts.do(t, "POST", "/api/v1/calendar/sync", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("sync: status %d, body %v", resp.StatusCode, body) + } + if body["events"] != float64(1) { + t.Fatalf("events = %v, want 1 — only the chosen calendar", body["events"]) + } + + resp, body = ts.do(t, "GET", + "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("events: status %d, body %v", resp.StatusCode, body) + } + events, _ := body["events"].([]any) + if len(events) != 1 { + t.Fatalf("got %d events, want 1: %v", len(events), body["events"]) + } + event := events[0].(map[string]any) + if event["summary"] != "Bestyrelsesmøde" || event["start_day"] != "2026-08-20" { + t.Errorf("the event came back as %v", event) + } + // The calendar's colour rides along, or the chip has to ask for it per row. + if event["colour"] != "#0b8043" { + t.Errorf("colour = %v; two calendars in one grid are told apart by it", event["colour"]) + } + // The window verdande holds is stated, so a grid paged past it can say so + // rather than showing an empty month it cannot explain. + if body["from"] == "" || body["to"] == "" { + t.Error("the answer does not say which window it covers") + } + + // And it is one person's calendar. Somebody else on the same instance sees + // nothing of it. + other := ts.newUser(t, "andreas@example.dk", "Andreas") + _, body = other.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil) + if events, _ := body["events"].([]any); len(events) != 0 { + t.Errorf("somebody else's calendar was visible: %v", body["events"]) + } + _ = user +} + +// A token that has not expired must not be traded in for another. Refreshing on +// every run burns the quota and makes the failure mode of a bad refresh token +// arrive on every sweep rather than once an hour. +func TestAFreshTokenIsNotRefreshed(t *testing.T) { + g := newFakeCalendarGoogle(t) + ts, user := connectedCalendar(t, g, nil) + + account, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google") + if err != nil { + t.Fatal(err) + } + account.AccessToken = "ya29.still-good" + account.ExpiresAt = time.Now().Add(time.Hour) + if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil { + t.Fatal(err) + } + + if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK { + t.Fatalf("sync: status %d, body %v", resp.StatusCode, body) + } + if g.refreshes != 0 { + t.Errorf("the token was refreshed %d times while it was still good", g.refreshes) + } + + // And an expired one is. + account.ExpiresAt = time.Now().Add(-time.Minute) + if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil { + t.Fatal(err) + } + if resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil); resp.StatusCode != http.StatusOK { + t.Fatalf("sync: status %d, body %v", resp.StatusCode, body) + } + if g.refreshes != 1 { + t.Errorf("an expired token was refreshed %d times, want 1", g.refreshes) + } +} + +// Access revoked at Google's end is a connection that has ended, not a failure to +// retry. Polling it every fifteen minutes for ever is a week of log lines and a +// connection the person believes they removed. +func TestRevokedAccessDisconnects(t *testing.T) { + g := newFakeCalendarGoogle(t) + ts, user := connectedCalendar(t, g, nil) + + account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google") + account.AccessToken = "ya29.revoked" + account.ExpiresAt = time.Now().Add(time.Hour) + if err := ts.db.SaveCalendarAccount(t.Context(), account); err != nil { + t.Fatal(err) + } + g.refuse = true + + resp, body := ts.do(t, "POST", "/api/v1/calendar/sync", nil) + if resp.StatusCode != StatusUpstreamRefused { + t.Fatalf("status %d, want %d — Google saying no is not this server breaking", + resp.StatusCode, StatusUpstreamRefused) + } + if body["code"] != CodeCalendarFailed { + t.Errorf("code = %v, want %q — `internal_error` is shown as a generic sentence, "+ + "which throws the diagnosis away", body["code"], CodeCalendarFailed) + } + + gone, err := ts.db.CalendarAccountFor(t.Context(), user.ID, "google") + if err != nil { + t.Fatal(err) + } + if gone != nil { + t.Error("the connection is still there after Google refused the token") + } + + // And the failure is in the error log, which is the point of having one: a + // sync that has been failing for a week is invisible otherwise. + _, logged := ts.do(t, "GET", "/api/v1/errors", nil) + rows, _ := logged["errors"].([]any) + if len(rows) == 0 { + t.Fatal("nothing was recorded") + } + if row := rows[0].(map[string]any); row["what"] != "sync calendar" { + t.Errorf("what = %v, want \"sync calendar\"", row["what"]) + } +} + +// Disconnecting takes the copy with it. None of it is the person's own work, and +// a calendar left behind after the account is gone is a frozen lie about a day. +func TestDisconnectingRemovesTheEvents(t *testing.T) { + g := newFakeCalendarGoogle(t) + g.events["fam"] = []map[string]any{ + {"id": "e1", "summary": "Møde", + "start": map[string]string{"date": "2026-08-20"}, + "end": map[string]string{"date": "2026-08-21"}}, + } + ts, user := connectedCalendar(t, g, nil) + + ts.do(t, "GET", "/api/v1/calendar", nil) + account, _ := ts.db.CalendarAccountFor(t.Context(), user.ID, "google") + calendars, _ := ts.db.Calendars(t.Context(), account.ID) + var famID string + for _, c := range calendars { + if c.RemoteID == "fam" { + famID = c.ID + } + } + ts.do(t, "PUT", "/api/v1/calendar/calendars", map[string]any{"shown": []string{famID}}) + ts.do(t, "POST", "/api/v1/calendar/sync", nil) + + if resp, _ := ts.do(t, "DELETE", "/api/v1/calendar", nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("disconnect: status %d", resp.StatusCode) + } + + _, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil) + if events, _ := body["events"].([]any); len(events) != 0 { + t.Errorf("events survived the disconnect: %v", body["events"]) + } + _, body = ts.do(t, "GET", "/api/v1/calendar", nil) + if body["connected"] != false { + t.Error("still reported as connected") + } +} + +// A range that is not a date has to be refused, not guessed at. An empty `from` +// compared as a string matches everything before it, which is a grid quietly +// answering a question nobody asked. +func TestEventsRefuseARangeThatIsNotADate(t *testing.T) { + g := newFakeCalendarGoogle(t) + ts, _ := connectedCalendar(t, g, nil) + + for _, query := range []string{ + "", "?from=2026-08-01", "?from=&to=2026-08-31", + "?from=i+morgen&to=2026-08-31", "?from=2026-08-01&to=2026-13-40", + } { + resp, body := ts.do(t, "GET", "/api/v1/calendar/events"+query, nil) + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("%q: status %d, want 422 (%v)", query, resp.StatusCode, body) + } + } +} + +// A person with no calendar connected still opens the view. It has to answer, and +// answer emptily, rather than fail — the grid is also verdande's own tasks, and +// they must not disappear because Google is not set up. +func TestTheGridAnswersWithNoCalendarConnected(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "GET", "/api/v1/calendar/events?from=2026-08-01&to=2026-08-31", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, body %v", resp.StatusCode, body) + } + if events, ok := body["events"].([]any); !ok || len(events) != 0 { + t.Errorf("events = %v, want an empty list rather than null", body["events"]) + } + + resp, body = ts.do(t, "GET", "/api/v1/calendar", nil) + if resp.StatusCode != http.StatusOK || body["connected"] != false { + t.Fatalf("status %d, body %v", resp.StatusCode, body) + } + if body["has_client"] != false { + t.Error("an instance with no OAuth registration claimed to have one; the page " + + "would offer a button that answers 409") + } + // Spelled out even when nothing is connected: it is what has to be registered + // with Google, and it is the single most likely thing to be wrong. + if uri, _ := body["redirect_uri"].(string); !strings.HasSuffix(uri, "/oauth/calendar/callback") { + t.Errorf("redirect_uri = %q", uri) + } +} + +// A slow Google must not hold the request open until something in front of this +// server gives up on it and answers with its own HTML. +func TestASlowCalendarDoesNotHangTheRequest(t *testing.T) { + blocked := make(chan struct{}) + defer close(blocked) + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-blocked: + case <-r.Context().Done(): + } + })) + defer slow.Close() + + ts := newTestServerWith(t, func(cfg *config.Config) { + cfg.GmailClientID = "test-client" + cfg.GmailClientSecret = "test-secret" + cfg.GoogleTokenURL = slow.URL + "/token" + cfg.CalendarAPIURL = slow.URL + // A second rather than the real twenty-five: what is being tested is that a + // budget is applied at all, and spending the production one on every run + // would put half a minute into the suite to learn the same thing. + cfg.CalendarSyncBudget = time.Second + }) + ts.bootstrap(t) + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + if err := ts.db.SaveCalendarAccount(t.Context(), &store.CalendarAccount{ + UserID: user.ID, Provider: "google", RefreshToken: "r", + AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + + // Its own client, because the shared one gives up after ten seconds — which is + // shorter than the budget being tested and would prove nothing either way. + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/calendar/sync", nil) + req.Header.Set("Sec-Fetch-Site", "same-origin") + for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) { + req.AddCookie(c) + } + + started := time.Now() + resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req) + if err != nil { + t.Fatalf("the request never came back (%v after %s); the sync has no budget of its "+ + "own, and whatever sits in front of this server answers for it", + err, time.Since(started).Round(time.Second)) + } + defer resp.Body.Close() + + if took := time.Since(started); took > 10*time.Second { + t.Errorf("the sync took %s; the budget is not being applied", took.Round(time.Second)) + } + if resp.StatusCode >= 500 { + t.Errorf("status %d — a slow Google is not this server breaking", resp.StatusCode) + } +} diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go index 1bee44d..1043a16 100644 --- a/internal/httpapi/errors.go +++ b/internal/httpapi/errors.go @@ -63,6 +63,11 @@ const ( // diagnosis — a wrong app password, a host that is not listening, a folder that // is not there — and it says it in its own words. CodeMailboxFailed = "mailbox_failed" + + // And for a calendar read from Google. Its own code rather than the Gmail one, + // because the sentence the interface wraps it in names where it came from — and + // "Gmail said no" about a calendar sends somebody to the wrong panel. + CodeCalendarFailed = "calendar_failed" ) // errLastAdmin is returned to the caller that already wrote the response, so it diff --git a/internal/httpapi/gmail_handlers.go b/internal/httpapi/gmail_handlers.go index a05ba23..ea68102 100644 --- a/internal/httpapi/gmail_handlers.go +++ b/internal/httpapi/gmail_handlers.go @@ -34,7 +34,7 @@ func (s *Server) gmailConfig(ctx context.Context) gmail.Config { ClientSecret: s.cfg.GmailClientSecret, RedirectURL: s.cfg.GmailRedirectURL(), Scope: gmail.Scope, - TokenURL: s.cfg.GmailTokenURL, + TokenURL: s.cfg.GoogleTokenURL, } if cfg.ClientID != "" && cfg.ClientSecret != "" { return cfg @@ -258,7 +258,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) { // The same guard as the mailboxes read over IMAP, for the same reason: a // press of "Fetch now" during the sweep would otherwise make two runs from // one marker. - unlock := s.lockMailbox(box.ID) + unlock := s.lockSync(box.ID) defer unlock() if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil { box = fresh diff --git a/internal/httpapi/gmail_test.go b/internal/httpapi/gmail_test.go index 15b2aee..f49f51a 100644 --- a/internal/httpapi/gmail_test.go +++ b/internal/httpapi/gmail_test.go @@ -29,7 +29,7 @@ func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) { ts := newTestServerWith(t, func(cfg *config.Config) { cfg.GmailClientID = "test-client" cfg.GmailClientSecret = "test-secret" - cfg.GmailTokenURL = google.URL + "/token" + cfg.GoogleTokenURL = google.URL + "/token" cfg.GmailAPIURL = google.URL }) ts.bootstrap(t) @@ -97,7 +97,7 @@ func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) { ts := newTestServerWith(t, func(cfg *config.Config) { cfg.GmailClientID = "test-client" cfg.GmailClientSecret = "test-secret" - cfg.GmailTokenURL = google.URL + "/token" + cfg.GoogleTokenURL = google.URL + "/token" cfg.GmailAPIURL = google.URL // A second rather than the real twenty-five: what is being tested is that a // budget is applied at all, and spending the production one on every run diff --git a/internal/httpapi/mailbox_handlers.go b/internal/httpapi/mailbox_handlers.go index 8baaeab..c1f7296 100644 --- a/internal/httpapi/mailbox_handlers.go +++ b/internal/httpapi/mailbox_handlers.go @@ -143,7 +143,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai // One reader of this mailbox at a time. Held for the whole run, marker and all: // releasing before the marker is written would leave exactly the gap this is // here to close. - unlock := s.lockMailbox(m.ID) + unlock := s.lockSync(m.ID) defer unlock() // Re-read under the lock. The row that came in may have been fetched before the @@ -231,8 +231,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai return created, nil } -// lockMailbox serialises one mailbox against itself and returns the release. -func (s *Server) lockMailbox(id string) func() { +// lockSync serialises one thing that polls against itself and returns the release. +// +// A mailbox to begin with; a calendar connection now takes the same lock under a +// key of its own. Two runs at once — somebody pressing "fetch now" while the sweep +// is in the middle of the same thing — both start from the state as it was, and +// the loser writes last. For a mailbox that made forty copies of one mail in a +// second; for a calendar it would be a window deleted after it was refilled. +func (s *Server) lockSync(id string) func() { value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{}) mu := value.(*sync.Mutex) mu.Lock() diff --git a/internal/httpapi/panel_test.go b/internal/httpapi/panel_test.go index cf6dc06..cf7fbf9 100644 --- a/internal/httpapi/panel_test.go +++ b/internal/httpapi/panel_test.go @@ -102,7 +102,7 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) { // Stands in for a run: takes the lock, notes how many are inside, and holds it // until told to let go. run := func(done chan<- struct{}) { - unlock := srv.lockMailbox("box-1") + unlock := srv.lockSync("box-1") mu.Lock() inside++ if inside > most { @@ -136,10 +136,10 @@ func TestTwoRunsOfOneMailboxDoNotOverlap(t *testing.T) { // And two different mailboxes must not wait for each other: one slow host is // not everybody's problem. - a := srv.lockMailbox("box-a") + a := srv.lockSync("box-a") done := make(chan struct{}) go func() { - srv.lockMailbox("box-b")() + srv.lockSync("box-b")() close(done) }() select { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 5a4de2c..dc0b3a7 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -133,6 +133,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Group(func(r chi.Router) { r.Use(s.requireAuth) r.Get("/oauth/gmail/callback", s.handleGmailCallback) + // The calendar comes back on a URI of its own rather than sharing Gmail's. + // One callback deciding from stored state which of the two flows had + // returned would put the choice of *which token store to write* behind a + // lookup — and when that goes wrong it goes wrong by overwriting a working + // Gmail connection with calendar tokens. + r.Get("/oauth/calendar/callback", s.handleCalendarCallback) }) r.Route("/caldav", s.caldavRoutes) @@ -316,6 +322,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server }) }) + // A calendar read from somewhere else, laid under verdande's own dated + // tasks. Read-only, so there is no write here to gate: the events are a + // copy of somebody else's calendar, and the only thing a person decides + // is which of them to look at. + r.Route("/calendar", func(r chi.Router) { + r.Get("/", s.handleGetCalendar) + r.Put("/calendars", s.handleSetCalendars) + r.Delete("/", s.handleDisconnectCalendar) + r.Post("/authorize", s.handleCalendarAuthorize) + r.Post("/sync", s.handleCalendarSyncNow) + r.Get("/events", s.handleCalendarEvents) + }) + r.Route("/import", func(r chi.Router) { r.Post("/todoist", s.handleImportTodoist) r.Post("/csv", s.handleImportCSV) diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 9cc9470..a26065c 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -41,6 +41,10 @@ type Runner struct { // reason as SyncGmail: it owns turning a message into a task. SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error) + // SyncCalendars refreshes one person's connected calendars. Supplied the same + // way and for the same reason: the HTTP layer owns the token refresh. + SyncCalendars func(ctx context.Context, user *store.User) (int, error) + // Push delivers a notification to somebody's devices. Supplied by the HTTP // layer, which owns the VAPID keys and the subscription list. Push func(userID, title, body, projectID string) @@ -69,6 +73,13 @@ func (r *Runner) Start(ctx context.Context) { // account for a to-do app. Ten minutes is fast enough for something somebody // starred and slow enough not to look like abuse. r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes) + // Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here + // becomes a task, so being a quarter of an hour behind on a meeting next + // Tuesday costs nothing — and the "fetch now" button covers the one case where + // it matters, which is somebody who has just moved something and is looking at + // the grid to check. A person who has just connected does not wait for this + // either; connecting fetches the list, and the first sync is one click away. + r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars) } func (r *Runner) Wait() { r.wg.Wait() } @@ -322,6 +333,44 @@ func (r *Runner) syncMailboxes(ctx context.Context) error { return nil } +// --- calendars ------------------------------------------------------------------------ + +// syncCalendars refreshes everybody's connected calendars. +// +// Its own sweep rather than a branch inside the mailbox one. They share nothing +// past the word "poll": that loop asks each mailbox how far it has read and makes +// tasks, this one replaces a window of somebody else's events. Folding them +// together would mean one interval for two questions that do not want the same +// answer, and a Google that is down would count against a mailbox that is not. +// +// Nobody is waiting, so no budget of its own — the request handler has one because +// a person is watching a spinner. One account that has gone quiet costs this run +// its own timeout and nothing else: the loop moves on per person. +func (r *Runner) syncCalendars(ctx context.Context) error { + if r.SyncCalendars == nil { + return nil + } + userIDs, err := r.db.UsersWithCalendars(ctx) + if err != nil { + return err + } + for _, id := range userIDs { + user, err := r.db.UserByID(ctx, id) + if err != nil || user == nil { + continue + } + written, err := r.SyncCalendars(ctx, user) + if err != nil { + r.log.Warn("calendar sync", "err", err, "user", id) + continue + } + if written > 0 { + r.log.Info("calendar refreshed", "user", id, "events", written) + } + } + return nil +} + // --- housekeeping ----------------------------------------------------------------- func (r *Runner) emptyTrash(ctx context.Context) error { diff --git a/web/src/lib/api.js b/web/src/lib/api.js index aac8df7..34240ed 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -61,6 +61,10 @@ const MESSAGES = { // Same shape for a mailbox read over IMAP: quoted when the host said something, // this when it did not. mailbox_failed: 'error.mailboxRefused', + // And for a calendar read from Google. Its own code, because the sentence this + // is wrapped in names where it came from — and "Gmail said no" about a calendar + // sends somebody to the wrong panel. + calendar_failed: 'error.calendarRefused', last_admin: 'error.lastAdmin' }; @@ -84,6 +88,9 @@ export function humanMessage(err) { if (err.code === 'gmail_failed' && err.message) { return t('error.gmailSaid', { what: err.message }); } + if (err.code === 'calendar_failed' && err.message) { + return t('error.calendarSaid', { what: err.message }); + } const key = MESSAGES[err.code]; if (key) return t(key); @@ -361,6 +368,16 @@ export const api = { authorizeGmail: () => post('/gmail/authorize'), syncGmail: () => post('/gmail/sync'), + // The Google Calendar connection. Read-only: there is nothing here that writes + // an event, only which calendars to look at. + getCalendar: () => get('/calendar'), + setCalendars: (shown) => put('/calendar/calendars', { shown }), + disconnectCalendar: () => del('/calendar'), + authorizeCalendar: () => post('/calendar/authorize'), + syncCalendar: () => post('/calendar/sync'), + calendarEvents: (from, to) => + get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`), + // Notes. Listing and searching are one endpoint because they answer the same // question with different words. notes: (params = {}) => { diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 91804b2..69909b5 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -65,6 +65,7 @@ export const da = { 'nav.upcoming': 'Kommende', 'nav.delegated': 'Venter på andre', 'nav.inbox': 'Indbakke', + 'nav.calendar': 'Kalender', 'nav.settings': 'Indstillinger', 'nav.signOut': 'Log ud', 'nav.main': 'Hovedmenu', @@ -586,6 +587,30 @@ export const da = { 'int.server': 'Server', 'int.caldavAuth': 'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.', + 'int.calendar': 'Google Kalender', + 'int.calendarHint': + 'Læses, aldrig skrives. Begivenhederne vises i Kalender ved siden af dine egne opgaver med forfaldsdato — de kan ikke flyttes eller lukkes herfra, for de hører til i Google.', + 'int.connectCalendar': 'Forbind Google Kalender', + 'int.calendarConnectedAs': 'Forbundet som', + 'int.whichCalendars': 'Hvilke kalendere vises', + 'int.noCalendars': 'Kontoen har ingen kalendere at vise.', + 'int.calendarLastSync': 'Sidst hentet {when}.', + 'int.calendarNeverSynced': 'Ikke hentet endnu.', + 'int.calendarFetchedOne': '{n} begivenhed hentet.', + 'int.calendarFetchedMany': '{n} begivenheder hentet.', + 'int.calendarNothingNew': 'Ingen begivenheder i vinduet.', + 'int.calendarConnected': 'Google Kalender er forbundet.', + 'int.calendarState': + 'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.', + 'int.calendarExpired': 'Forsøget udløb undervejs. Prøv igen.', + 'int.calendarInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.', + 'int.calendarFailed': 'Google afviste ombytningen af koden.', + 'int.calendarNoRefresh': + 'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.', + 'int.calendarDenied': 'Adgangen blev afvist hos Google.', + 'int.calendarOrgInternal': + 'Google-appen er registreret som Internal og tager kun imod konti i organisationen. En privat konto kan ikke godkende den, uanset hvad der står på skærmen.', + 'int.calendarOther': 'Google svarede: {what}', 'int.gmailFetchedOne': '{n} opgave hentet.', 'int.gmailFetchedMany': '{n} opgaver hentet.', 'int.gmailNothingNew': 'Ingen nye beskeder.', @@ -600,6 +625,17 @@ export const da = { 'int.gmailDenied': 'Adgangen blev afvist hos Google.', 'int.gmailOther': 'Gmail svarede: {what}', + // --- kalenderen, med Googles begivenheder lagt over --------------------------------- + 'cal.title': 'Kalender', + 'cal.notConnected': + 'Ingen kalender er forbundet endnu. Det gøres under Indstillinger → Integrationer.', + 'cal.settings': 'Indstillinger', + 'cal.allDay': 'Hele dagen', + 'cal.openInGoogle': 'Åbn i Google Kalender', + 'cal.beyondWindow': + 'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.', + 'cal.eventCannotMove': '{name} — en begivenhed i {calendar}. Den kan ikke flyttes herfra.', + // --- a project ---------------------------------------------------------------------- 'project.name': 'Projektets navn', 'project.clickToRename': 'Klik for at omdøbe', @@ -741,6 +777,10 @@ export const da = { 'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.', 'error.gmailSaid': 'Gmail svarede: {what}', + 'error.calendarRefused': 'Google afviste kaldet. Prøv at forbinde kalenderen igen.', + 'error.calendarSaid': 'Google svarede: {what}', + 'int.disconnectCalendarQuestion': + 'Afbryd forbindelsen til Google Kalender? Tokens bliver glemt, og den hentede kopi af begivenhederne bliver slettet. Dine egne opgaver rører den ikke.', 'error.mailboxRefused': 'Postkassen kunne ikke læses.', 'error.mailboxSaid': 'Postkassen svarede: {what}', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index cec3a21..e3c6c08 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -61,6 +61,7 @@ export const en = { 'nav.upcoming': 'Upcoming', 'nav.delegated': 'Waiting on others', 'nav.inbox': 'Inbox', + 'nav.calendar': 'Calendar', 'nav.settings': 'Settings', 'nav.signOut': 'Sign out', 'nav.main': 'Main menu', @@ -582,6 +583,29 @@ export const en = { 'int.server': 'Server', 'int.caldavAuth': 'The username is your email. The password is an API token — not your real password.', + 'int.calendar': 'Google Calendar', + 'int.calendarHint': + 'Read, never written. The events appear in Calendar beside your own tasks with due dates — they cannot be moved or completed from here, because they belong to Google.', + 'int.connectCalendar': 'Connect Google Calendar', + 'int.calendarConnectedAs': 'Connected as', + 'int.whichCalendars': 'Which calendars are shown', + 'int.noCalendars': 'The account has no calendars to show.', + 'int.calendarLastSync': 'Last fetched {when}.', + 'int.calendarNeverSynced': 'Not fetched yet.', + 'int.calendarFetchedOne': '{n} event fetched.', + 'int.calendarFetchedMany': '{n} events fetched.', + 'int.calendarNothingNew': 'No events in the window.', + 'int.calendarConnected': 'Google Calendar is connected.', + 'int.calendarState': 'Google’s reply did not match the attempt that started here. Try again.', + 'int.calendarExpired': 'The attempt expired on the way. Try again.', + 'int.calendarInvalid': 'Google’s reply could not be read. Try again.', + 'int.calendarFailed': 'Google refused to exchange the code.', + 'int.calendarNoRefresh': + 'Google sent no refresh token. Remove verdande from your Google account and connect again.', + 'int.calendarDenied': 'Access was refused at Google.', + 'int.calendarOrgInternal': + 'The Google app is registered as Internal and only accepts accounts inside the organisation. A private account cannot consent to it, whatever the screen says.', + 'int.calendarOther': 'Google replied: {what}', 'int.gmailFetchedOne': '{n} task fetched.', 'int.gmailFetchedMany': '{n} tasks fetched.', 'int.gmailNothingNew': 'No new messages.', @@ -595,6 +619,16 @@ export const en = { 'int.gmailDenied': 'Access was refused at Google.', 'int.gmailOther': 'Gmail replied: {what}', + // --- the calendar, with Google’s events laid over ------------------------------------ + 'cal.title': 'Calendar', + 'cal.notConnected': 'No calendar is connected yet. That is done under Settings → Integrations.', + 'cal.settings': 'Settings', + 'cal.allDay': 'All day', + 'cal.openInGoogle': 'Open in Google Calendar', + 'cal.beyondWindow': + 'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.', + 'cal.eventCannotMove': '{name} — an event in {calendar}. It cannot be moved from here.', + // --- a project ---------------------------------------------------------------------- 'project.name': 'The project’s name', 'project.clickToRename': 'Click to rename', @@ -736,6 +770,10 @@ export const en = { 'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.', 'error.gmailSaid': 'Gmail said: {what}', + 'error.calendarRefused': 'Google refused the call. Try connecting the calendar again.', + 'error.calendarSaid': 'Google replied: {what}', + 'int.disconnectCalendarQuestion': + 'Disconnect Google Calendar? The tokens are forgotten and the fetched copy of the events is deleted. Your own tasks are untouched.', 'error.mailboxRefused': 'The mailbox could not be read.', 'error.mailboxSaid': 'The mailbox answered: {what}',