diff --git a/README.md b/README.md index 7a2ff0d..16186b8 100644 --- a/README.md +++ b/README.md @@ -103,15 +103,16 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector. | Mail-to-task | Done, tested | | AI layer (Anthropic/OpenAI/Google/local) | Built, degrades when unconfigured | | Gmail: OAuth2 with PKCE, polling, one-way to tasks | Done, tested | +| Google Calendar: read-only, laid over the task calendar | Built, never run against Google | | Update notice for administrators | Done, tested | | Personal API tokens | Done, tested | | OpenAPI 3.1 spec, checked against the router | Done, tested | | End-to-end smoke tests (Playwright) | Done | | Documentation, landing page, licence | Done | -**Not finished:** Web Push, the AI providers and the Gmail API calls are -implemented against their specifications and have only ever been exercised there, -not against live services. Everything else has an interface: opening a task shows +**Not finished:** Web Push, the AI providers, and the Gmail and Google Calendar +API calls are implemented against their specifications and have only ever been +exercised there, not against live services. Everything else has an interface: opening a task shows its description, sub-tasks, comments, files and reminders, and the settings pages cover the account, notifications, integrations, AI, API tokens, import, export and templates. diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index b17f785..08e4f19 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.SendBeacon = api.SendBeacon runner.Start(jobCtx) diff --git a/docs/caldav.md b/docs/caldav.md index 195be47..1d4ff1d 100644 --- a/docs/caldav.md +++ b/docs/caldav.md @@ -1,13 +1,14 @@ # Calendar and CalDAV -Two ways to get your tasks into a calendar, and they are not the same thing. +Two ways to get your tasks into a calendar, one way to get a calendar into +verdande, and none of the three is the same thing. -| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | -|---|---|---| -| Direction | Read only | Two-way | -| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | -| You can tick things off | No | Yes | -| Setup | Paste a URL | Username and token | +| | [Feed](#subscribing-to-a-feed) | [CalDAV](#connecting-a-caldav-client) | [Google Calendar](#showing-a-google-calendar-in-verdande) | +|---|---|---|---| +| Direction | Tasks out, read only | Tasks out, two-way | Events **in**, read only | +| Works with | Anything that subscribes to a URL | Apple Reminders, Thunderbird, DAVx⁵ | A Google account | +| You can tick things off | No | Yes | No — an event is not a task | +| Setup | Paste a URL | Username and token | Connect, then pick calendars | ## Subscribing to a feed @@ -39,6 +40,42 @@ most people actually keep. **Settings → Calendar feed → Rotate** issues a new one and breaks the old immediately. +## Showing a Google calendar in verdande + +The two sections above send your tasks *out*. This is the other direction: your +Google calendar drawn underneath them, so one screen answers "what does this day +already have in it". + +**Settings → Integrations → Google Calendar → Connect**, then tick the calendars +you want and press Save. They appear in **Calendar** in the sidebar, in Google's +own colour for each one, beside your own tasks with due dates. + +It needs the same OAuth client Gmail uses, with the Calendar API enabled and the +second redirect URI registered — see [Configuration](configuration.md#google). + +!!! warning "Read only" + verdande never writes to your Google calendar. An event cannot be dragged, + edited or ticked off here; clicking one opens it in Google, which is where it + can be changed. Your own tasks in the same grid still drag to another day as + they always have. + + Writing would need the `calendar.events` scope and an answer to what should + happen when both sides moved the same meeting — which is a synchronisation + model rather than a scope, and it is deliberately not built yet. + +!!! note "The Internal client applies here too" + An OAuth client registered as *Internal* in a Google Workspace can only be + used by accounts in that workspace, calendars included. A personal + `@gmail.com` address is refused with `org_internal`. For one personal account + the shorter road is the calendar's own secret address: in Google Calendar, + **Settings → the calendar → Integrate calendar → Secret address in iCal + format**. Nothing in verdande subscribes to one yet — it is the next piece of + this feature. + +Events are kept for ninety days back and a year forward, refreshed every fifteen +minutes and whenever you press **Fetch now**. Paging the grid past that window +says so rather than showing an empty month. + ## Connecting a CalDAV client This is the one where ticking something off in Apple Reminders ticks it off here. diff --git a/docs/configuration.md b/docs/configuration.md index 22241ad..a6d1193 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,12 +39,24 @@ Any Go duration: `720h`, `30m`, `45s`. yourself, password resets are written to the log, and reminders arrive in the app and as Web Push rather than by email. -## Gmail +## Google -Only needed if anyone will connect a mailbox. Register an OAuth client in Google -Cloud (**APIs & Services → Credentials → OAuth client ID → Web application**) with -the redirect URI `https://your-address/oauth/gmail/callback`, enable the Gmail API, -and add the `gmail.readonly` scope. +Only needed if anyone will connect a Gmail mailbox or a Google calendar. One +registration covers both: register an OAuth client in Google Cloud +(**APIs & Services → Credentials → OAuth client ID → Web application**) and add +**both** redirect URIs: + +``` +https://your-address/oauth/gmail/callback +https://your-address/oauth/calendar/callback +``` + +Then enable the APIs and scopes for whichever you want: + +| For | Enable | Scope | +|---|---|---| +| Mailboxes | Gmail API | `gmail.readonly` | +| Calendar | Google Calendar API | `calendar.readonly` | | Variable | What it does | |---|---| @@ -52,8 +64,15 @@ and add the `gmail.readonly` scope. | `VERDANDE_GMAIL_CLIENT_SECRET` | The client secret. | One registration serves every user on the instance; each person authorises their -own mailbox from **Settings → Gmail**. The redirect URI is derived from -`VERDANDE_BASE_URL`, so it cannot drift out of step with what you registered. +own mailbox from **Settings → Integrations → Gmail** and their own calendar from +**Settings → Integrations → Google Calendar**. Both redirect URIs are derived from +`VERDANDE_BASE_URL`, so they cannot drift out of step with what you registered. + +!!! note "Two connections, two grants" + Connecting a calendar does not disturb a working Gmail connection, and + disconnecting one does not take the other with it. Google issues a refresh + token per authorisation and keeps up to a hundred of them live per account, + each carrying only the scopes it was granted with. ## Secrets diff --git a/docs/mailboxes.md b/docs/mailboxes.md index 6e58eaf..6a3a2dd 100644 --- a/docs/mailboxes.md +++ b/docs/mailboxes.md @@ -16,7 +16,7 @@ administrator who can read yours. **Settings → Integrations → Gmail → Connect.** Gmail needs an OAuth client, which is the instance's registration with Google -rather than anybody's account — see [Configuration](configuration.md#gmail). +rather than anybody's account — see [Configuration](configuration.md#google). Once it is set up, each person connects their own mailbox through it. Choose what makes a task: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 97056a7..dc7a95e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2654,6 +2654,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] @@ -3297,6 +3417,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] @@ -3479,6 +3619,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: @@ -3496,6 +3670,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 new file mode 100644 index 0000000..4b8f1c7 --- /dev/null +++ b/internal/gcal/gcal.go @@ -0,0 +1,339 @@ +// Package gcal reads a Google Calendar. It never writes one. +// +// The scope is calendar.readonly, and that is the whole of the decision: verdande +// already is a CalDAV server, so a Google event shown here is a second calendar +// laid over the first, not a copy Google and verdande now both have to agree +// about. Writing needs `calendar.events`, and it needs an answer to what happens +// when both sides moved the same meeting — which is a synchronisation model, not a +// scope. Read first; write later and to one calendar at a time. +// +// What the API gives back and what this returns are deliberately different shapes. +// Google answers with RFC 3339 instants for a timed event and bare dates for an +// all-day one, and with an *exclusive* end date for the second. A calendar grid +// asks one question — "which cells does this event cover" — so that is what an +// Event answers, in days, worked out once here rather than in every caller. +package gcal + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kristianwind/verdande/internal/google" +) + +// Scope is read-only, and only the calendar. +// +// Google also offers calendar.calendarlist.readonly and calendar.events.readonly, +// which together are strictly less than this — they leave out access rules and +// account settings, which verdande has no use for. They are the tighter pair and +// worth moving to; they are also newer, and this one has been documented and +// accepted unchanged for a decade. A consent screen that fails with `invalid_scope` +// is not a place to be adventurous on somebody else's Google account. +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 + +type Client struct { + accessToken string + // base is where the Calendar API is. A field rather than a constant so a test + // can point it at a server it controls; empty is Google. There is no other way + // to exercise a calendar that is slow, empty, or refuses. + base string + http *http.Client +} + +func NewClient(accessToken string) *Client { + return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}} +} + +// At points the client at another server. Tests only; the zero value is Google. +func (c *Client) At(base string) *Client { + c.base = base + return c +} + +func (c *Client) api() string { + if c.base != "" { + return c.base + } + return defaultAPIBase +} + +// Calendar is one entry in the person's calendar list. +type Calendar struct { + ID string + // Name is the summary Google holds. A calendar somebody renamed for themselves + // carries summaryOverride instead, and that is the name they will look for. + Name string + // Colour is Google's own hex value for the calendar, which is what makes two + // calendars in one grid tellable apart. Their own colour rather than one picked + // here: it is the colour they already know the calendar by. + Colour string + TimeZone string + Primary bool + // Owner means the connected account can write to it. Nothing writes today — + // it is stored so the day something does, the interface knows which calendars + // could even be offered. + Owner bool +} + +// Calendars lists what the connected account can see. +func (c *Client) Calendars(ctx context.Context) ([]Calendar, error) { + var out []Calendar + page := "" + // Bounded rather than "until Google stops": a page token that never empties is + // a loop that never ends, and the difference between a bug at Google and a + // hung sync should not be this server hanging. + for range 10 { + v := url.Values{} + v.Set("minAccessRole", "reader") + v.Set("maxResults", "250") + if page != "" { + v.Set("pageToken", page) + } + + var parsed struct { + Items []struct { + ID string `json:"id"` + Summary string `json:"summary"` + SummaryOverride string `json:"summaryOverride"` + BackgroundColor string `json:"backgroundColor"` + TimeZone string `json:"timeZone"` + Primary bool `json:"primary"` + AccessRole string `json:"accessRole"` + Deleted bool `json:"deleted"` + } `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + if err := c.get(ctx, c.api()+"/users/me/calendarList?"+v.Encode(), &parsed); err != nil { + return nil, err + } + + for _, item := range parsed.Items { + if item.Deleted { + continue + } + name := item.SummaryOverride + if name == "" { + name = item.Summary + } + out = append(out, Calendar{ + ID: item.ID, Name: name, Colour: item.BackgroundColor, + TimeZone: item.TimeZone, Primary: item.Primary, + Owner: item.AccessRole == "owner" || item.AccessRole == "writer", + }) + } + if parsed.NextPageToken == "" { + break + } + page = parsed.NextPageToken + } + return out, nil +} + +// Event is one occurrence, in the shape a calendar grid asks about. +type Event struct { + ID string + Summary string + // StartsAt and EndsAt are RFC 3339 for a timed event and empty for an all-day + // one — an all-day event has no instant, only a day, and inventing midnight for + // it is how an event moves a day every time a server changes time zone. + StartsAt string + EndsAt string + // StartDay and EndDay are the first and last day the event covers, inclusive at + // both ends. Google's all-day end date is exclusive; that is corrected here, + // once, rather than in every reader. + StartDay string + EndDay string + AllDay bool + Location string + // URL opens the event in Google's own interface. Same reasoning as the Gmail + // link on a task made from a mail: what verdande shows is a pointer to the + // event, not a second copy of it that somebody could try to edit. + URL string +} + +// Events lists what falls inside a window, one calendar at a time. +// +// `singleEvents` expands a recurrence into its occurrences, which is the only form +// a grid can draw: an RRULE is an instruction, and a Tuesday cell needs the Tuesday +// it produced. It also means verdande does not have to grow a second recurrence +// engine that agrees with Google's about Danish summer time. +// +// from and to are dates, inclusive. The window is the caller's business; this +// turns it into the half-open interval Google wants. +func (c *Client) Events(ctx context.Context, calendarID, from, to string) ([]Event, error) { + start, err := time.Parse(dateLayout, from) + if err != nil { + return nil, fmt.Errorf("gcal: from: %w", err) + } + end, err := time.Parse(dateLayout, to) + if err != nil { + return nil, fmt.Errorf("gcal: to: %w", err) + } + + var out []Event + page := "" + for range 20 { + v := url.Values{} + v.Set("singleEvents", "true") + v.Set("orderBy", "startTime") + v.Set("maxResults", "2500") + // UTC, and a day of slack at each end. An event at 23:30 on the last day in + // Copenhagen is 21:30 UTC that day, but one at 00:30 on the first day is + // 22:30 UTC the day *before* — so a window cut exactly at the requested + // dates loses the events at both edges. The extra day is thrown away again + // by the day fields below, which are computed from the event's own offset. + v.Set("timeMin", start.AddDate(0, 0, -1).Format(time.RFC3339)) + v.Set("timeMax", end.AddDate(0, 0, 2).Format(time.RFC3339)) + if page != "" { + v.Set("pageToken", page) + } + + var parsed struct { + Items []rawEvent `json:"items"` + NextPageToken string `json:"nextPageToken"` + } + url := c.api() + "/calendars/" + url.PathEscape(calendarID) + "/events?" + v.Encode() + if err := c.get(ctx, url, &parsed); err != nil { + return nil, err + } + + for _, item := range parsed.Items { + // A cancelled occurrence of a recurring event still comes back, so that + // a client keeping its own copy knows to drop it. verdande replaces the + // window wholesale, so it only has to not add it. + if item.Status == "cancelled" { + continue + } + event, ok := item.event() + if !ok { + continue + } + if event.EndDay < from || event.StartDay > to { + continue // the slack above, thrown away + } + out = append(out, event) + } + if parsed.NextPageToken == "" { + break + } + page = parsed.NextPageToken + } + return out, nil +} + +const dateLayout = "2006-01-02" + +type rawEvent struct { + ID string `json:"id"` + Status string `json:"status"` + Summary string `json:"summary"` + Location string `json:"location"` + HTMLLink string `json:"htmlLink"` + Start when `json:"start"` + End when `json:"end"` +} + +type when struct { + Date string `json:"date"` + DateTime string `json:"dateTime"` +} + +// event turns Google's two shapes into the one a grid can read. +// +// The second return is false for an event with neither a date nor a dateTime — +// which Google does return, for an event somebody is still being asked about. It +// belongs on no day, so there is no cell to draw it in. +func (r rawEvent) event() (Event, bool) { + e := Event{ + ID: r.ID, Summary: strings.TrimSpace(r.Summary), + Location: r.Location, URL: r.HTMLLink, + } + if e.Summary == "" { + // Google's own interface says this for an event with no title, and a blank + // chip in a grid reads as a rendering fault rather than as an empty title. + e.Summary = "(uden titel)" + } + + switch { + case r.Start.Date != "": + e.AllDay = true + e.StartDay = r.Start.Date + // Google's all-day end is the day *after* the last one, so a one-day event + // ends tomorrow. Subtracting a day is what makes "covers this cell" a + // comparison rather than a special case at every call site. + e.EndDay = r.Start.Date + if day, err := time.Parse(dateLayout, r.End.Date); err == nil { + if last := day.AddDate(0, 0, -1); !last.Before(mustDay(r.Start.Date)) { + e.EndDay = last.Format(dateLayout) + } + } + case r.Start.DateTime != "": + e.StartsAt = r.Start.DateTime + e.EndsAt = r.End.DateTime + // The day is the date part of the timestamp Google returned, which carries + // the event's own offset. Parsing it into a time.Time and formatting it + // back would ask *this process* what day it is — and a container running in + // UTC would then move a 23:30 meeting in Copenhagen to the day before. + e.StartDay = dayOf(r.Start.DateTime) + e.EndDay = dayOf(r.End.DateTime) + if e.EndDay == "" { + e.EndDay = e.StartDay + } + // An event ending at midnight ends the evening before, as far as a grid is + // concerned. Without this, every meeting that runs to 24:00 puts a chip in + // the following day as well. + if e.EndDay > e.StartDay && strings.Contains(r.End.DateTime, "T00:00:00") { + if day, err := time.Parse(dateLayout, e.EndDay); err == nil { + e.EndDay = day.AddDate(0, 0, -1).Format(dateLayout) + } + } + default: + return Event{}, false + } + if e.EndDay < e.StartDay { + e.EndDay = e.StartDay + } + return e, true +} + +// dayOf takes the date out of an RFC 3339 timestamp without interpreting it. +func dayOf(stamp string) string { + if len(stamp) < len(dateLayout) { + return "" + } + day := stamp[:len(dateLayout)] + if _, err := time.Parse(dateLayout, day); err != nil { + return "" + } + return day +} + +func mustDay(s string) time.Time { + day, _ := time.Parse(dateLayout, s) + return day +} + +func (c *Client) get(ctx context.Context, url string, out any) error { + return google.Get(ctx, c.http, c.accessToken, url, out) +} diff --git a/internal/gcal/gcal_test.go b/internal/gcal/gcal_test.go new file mode 100644 index 0000000..d47d20f --- /dev/null +++ b/internal/gcal/gcal_test.go @@ -0,0 +1,255 @@ +package gcal + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fakeGoogle answers the two calls this package makes, out of whatever the test +// hands it. There is no other way to exercise this: the real Calendar needs an +// account, a registered client and somebody's consent, and none of the three can be +// had inside a test — so the shapes Google returns are written down here and the +// parsing is checked against them. +func fakeGoogle(t *testing.T, calendarList any, events map[string]any) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasPrefix(r.URL.Path, "/users/me/calendarList"): + _ = json.NewEncoder(w).Encode(calendarList) + case strings.HasPrefix(r.URL.Path, "/calendars/"): + id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/calendars/"), "/events") + body, ok := events[id] + if !ok { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(body) + default: + http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return NewClient("token").At(srv.URL) +} + +func TestCalendarsPrefersTheNameThePersonGaveIt(t *testing.T) { + client := fakeGoogle(t, map[string]any{ + "items": []map[string]any{ + {"id": "primary", "summary": "kw@nolimit.dk", "backgroundColor": "#3f51b5", + "timeZone": "Europe/Copenhagen", "primary": true, "accessRole": "owner"}, + {"id": "fam", "summary": "Family", "summaryOverride": "Familien", + "backgroundColor": "#0b8043", "accessRole": "reader"}, + // A calendar removed from the list still arrives, flagged. + {"id": "gone", "summary": "Væk", "deleted": true, "accessRole": "reader"}, + }, + }, nil) + + got, err := client.Calendars(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d calendars, want 2 — a deleted one is not one you can show", len(got)) + } + if got[1].Name != "Familien" { + t.Errorf("name = %q; summaryOverride is the name the person will look for", got[1].Name) + } + if !got[0].Primary || !got[0].Owner { + t.Errorf("the primary calendar came back as %+v", got[0]) + } + if got[1].Owner { + t.Error("a reader was reported as an owner; nothing may offer to write to it") + } + if got[0].Colour != "#3f51b5" { + t.Errorf("colour = %q; two calendars in one grid are told apart by it", got[0].Colour) + } +} + +// The three shapes an event arrives in, and the one shape a grid can read. +func TestEventsBecomeDaysAGridCanAskAbout(t *testing.T) { + client := fakeGoogle(t, nil, map[string]any{ + "primary": map[string]any{ + "items": []map[string]any{ + { + "id": "timed", "summary": "Bestyrelsesmøde", "htmlLink": "https://cal/1", + "start": map[string]string{"dateTime": "2026-08-20T14:00:00+02:00"}, + "end": map[string]string{"dateTime": "2026-08-20T15:30:00+02:00"}, + }, + { + // All-day, one day. Google's end is the day after. + "id": "allday", "summary": "Fridag", + "start": map[string]string{"date": "2026-08-21"}, + "end": map[string]string{"date": "2026-08-22"}, + }, + { + // All-day, three days. + "id": "trip", "summary": "Aarhus", + "start": map[string]string{"date": "2026-08-24"}, + "end": map[string]string{"date": "2026-08-27"}, + }, + { + // Runs to midnight, which is the evening before as far as a + // grid is concerned. + "id": "late", "summary": "Nattevagt", + "start": map[string]string{"dateTime": "2026-08-22T20:00:00+02:00"}, + "end": map[string]string{"dateTime": "2026-08-23T00:00:00+02:00"}, + }, + { + // A cancelled occurrence of a recurring event. + "id": "off", "status": "cancelled", "summary": "Aflyst", + "start": map[string]string{"date": "2026-08-25"}, + "end": map[string]string{"date": "2026-08-26"}, + }, + { + // Neither a date nor a dateTime: belongs on no day at all. + "id": "nowhere", "summary": "Uden tid", + }, + }, + }, + }) + + got, err := client.Events(t.Context(), "primary", "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + + byID := map[string]Event{} + for _, e := range got { + byID[e.ID] = e + } + if len(got) != 4 { + t.Fatalf("got %d events, want 4: %+v", len(got), got) + } + if _, ok := byID["off"]; ok { + t.Error("a cancelled occurrence was kept; it is an event that is not happening") + } + if _, ok := byID["nowhere"]; ok { + t.Error("an event with no time was kept; there is no cell to draw it in") + } + + timed := byID["timed"] + if timed.AllDay || timed.StartDay != "2026-08-20" || timed.EndDay != "2026-08-20" { + t.Errorf("timed = %+v", timed) + } + if timed.StartsAt != "2026-08-20T14:00:00+02:00" { + t.Errorf("the instant was rewritten: %q", timed.StartsAt) + } + + allday := byID["allday"] + if !allday.AllDay || allday.StartDay != "2026-08-21" || allday.EndDay != "2026-08-21" { + t.Errorf("allday = %+v; Google's end date is exclusive and has to be corrected here", + allday) + } + if allday.StartsAt != "" { + t.Errorf("an all-day event was given an instant: %q — it has a day, not a moment", + allday.StartsAt) + } + + if trip := byID["trip"]; trip.StartDay != "2026-08-24" || trip.EndDay != "2026-08-26" { + t.Errorf("trip = %+v, want 24th to 26th inclusive", trip) + } + if late := byID["late"]; late.EndDay != "2026-08-22" { + t.Errorf("late ends on %q; an event running to midnight ends the evening before, "+ + "or every one of them puts a chip in the following day", late.EndDay) + } +} + +// An event with no title has to say so. A blank chip in a grid reads as a +// rendering fault rather than as an empty title. +func TestAnEventWithNoTitleStillSaysSomething(t *testing.T) { + client := fakeGoogle(t, nil, map[string]any{ + "c": map[string]any{"items": []map[string]any{{ + "id": "x", + "start": map[string]string{"date": "2026-08-20"}, + "end": map[string]string{"date": "2026-08-21"}, + }}}, + }) + got, err := client.Events(t.Context(), "c", "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Summary == "" { + t.Fatalf("got %+v", got) + } +} + +// The window is asked for with a day of slack at each end, because an event late +// in the evening in Copenhagen is the next day in UTC. The slack must not leak +// out: an event on the day before the window is not in the window. +func TestTheSlackAtTheEdgesIsThrownAwayAgain(t *testing.T) { + client := fakeGoogle(t, nil, map[string]any{ + "c": map[string]any{"items": []map[string]any{ + {"id": "before", "summary": "For tidligt", + "start": map[string]string{"date": "2026-08-09"}, + "end": map[string]string{"date": "2026-08-10"}}, + {"id": "inside", "summary": "Med", + "start": map[string]string{"date": "2026-08-10"}, + "end": map[string]string{"date": "2026-08-11"}}, + {"id": "after", "summary": "For sent", + "start": map[string]string{"date": "2026-08-21"}, + "end": map[string]string{"date": "2026-08-22"}}, + }}, + }) + got, err := client.Events(t.Context(), "c", "2026-08-10", "2026-08-20") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "inside" { + t.Fatalf("got %+v, want only the one inside the window", got) + } +} + +// A page token has to be followed, or a busy calendar silently shows its first +// page and nothing else — which is a calendar that lies about a day being clear. +func TestEveryPageIsRead(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + body := map[string]any{ + "items": []map[string]any{{"id": "a", "summary": "Første", + "start": map[string]string{"date": "2026-08-11"}, + "end": map[string]string{"date": "2026-08-12"}}}, + "nextPageToken": "more", + } + if r.URL.Query().Get("pageToken") == "more" { + body = map[string]any{"items": []map[string]any{{"id": "b", "summary": "Anden", + "start": map[string]string{"date": "2026-08-12"}, + "end": map[string]string{"date": "2026-08-13"}}}} + } + _ = json.NewEncoder(w).Encode(body) + })) + defer srv.Close() + + got, err := NewClient("t").At(srv.URL).Events(t.Context(), "c", "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d events, want both pages: %+v", len(got), got) + } +} + +// A 401 is not a failure to retry; it is a connection that has ended. Telling the +// two apart is what stops a revoked account being polled every ten minutes for ever. +func TestARefusedTokenIsItsOwnError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"invalid_credentials"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := NewClient("spent").At(srv.URL).Calendars(t.Context()) + if err != ErrUnauthorized { + t.Fatalf("err = %v, want ErrUnauthorized", err) + } +} + +// Read-only, and only the calendar. The consent screen is what somebody agrees to, +// and a scope that quietly grew is a promise quietly broken. +func TestTheScopeIsReadOnly(t *testing.T) { + if !strings.HasSuffix(Scope, "calendar.readonly") { + t.Errorf("scope = %q; nothing here writes a calendar", Scope) + } +} diff --git a/internal/gmail/gmail.go b/internal/gmail/gmail.go index a93613d..f112dfc 100644 --- a/internal/gmail/gmail.go +++ b/internal/gmail/gmail.go @@ -1,5 +1,5 @@ -// Package gmail implements the OAuth2 authorisation-code flow with PKCE against -// Google, and the small slice of the Gmail API verdande needs. +// Package gmail is the small slice of the Gmail API verdande needs, and the scope +// it asks for. // // The connection is one-way on purpose: a starred or labelled message becomes a // task, and nothing verdande does afterwards touches the mailbox. Unstarring the @@ -9,200 +9,51 @@ // // Scope is gmail.readonly. verdande never needs to send, modify or delete, and // asking for less is what makes the consent screen something a person can agree to. +// +// The OAuth flow itself moved to internal/google when Calendar needed the same one. +// The names below are aliases so that every call site — and this package's own +// tests — still read as Gmail's, which is what they are: one registration, one +// flow, one scope each. package gmail import ( "context" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" "fmt" "net/http" "net/url" "strings" "time" + + "github.com/kristianwind/verdande/internal/google" ) -const ( - authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" - defaultTokenEndpoint = "https://oauth2.googleapis.com/token" - defaultAPIBase = "https://gmail.googleapis.com/gmail/v1" +// Read-only, and only mail. Not profile, not contacts, not send. +const Scope = "https://www.googleapis.com/auth/gmail.readonly" - // Read-only, and only mail. Not profile, not contacts, not send. - Scope = "https://www.googleapis.com/auth/gmail.readonly" +const defaultAPIBase = "https://gmail.googleapis.com/gmail/v1" + +// The OAuth flow, which is Google's rather than Gmail's. +type ( + Config = google.Config + PKCE = google.PKCE + Token = google.Token ) -// Endpoints is where Google is. -// -// A field rather than a constant so a test can point it at a server it controls. -// There is no other way to exercise what happens when Gmail is slow or refuses — -// and "the sync comes back before whatever sits in front of this server gives up -// on it" is exactly the kind of promise worth a test and impossible to make -// against the real thing. -// -// The zero value is Google, so nothing outside a test has to know it exists. -type Endpoints struct { - Token string - API string -} - -func (e Endpoints) token() string { - if e.Token != "" { - return e.Token - } - return defaultTokenEndpoint -} - -func (e Endpoints) api() string { - if e.API != "" { - return e.API - } - return defaultAPIBase -} - -// Config is the OAuth client the operator registered in Google Cloud. It belongs to -// the instance rather than to a person: one registration, any number of users. -type Config struct { - ClientID string - ClientSecret string - RedirectURL string - // Endpoints is Google unless a test says otherwise. - Endpoints Endpoints -} - -func (c Config) Configured() bool { - return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != "" -} - -// PKCE is the proof-key pair for one authorisation attempt. -// -// PKCE on a server-side client is not strictly required — the client secret already -// authenticates the token exchange — but it costs nothing and closes the window in -// which an authorisation code intercepted from the redirect could be replayed -// before verdande gets to it. -type PKCE struct { - Verifier string - Challenge string - State string -} - -func NewPKCE() (PKCE, error) { - verifier, err := randomString(64) - if err != nil { - return PKCE{}, err - } - state, err := randomString(32) - if err != nil { - return PKCE{}, err - } - sum := sha256.Sum256([]byte(verifier)) - return PKCE{ - Verifier: verifier, - Challenge: base64.RawURLEncoding.EncodeToString(sum[:]), - State: state, - }, nil -} - -// AuthURL is where the browser is sent to ask the person for consent. -func (c Config) AuthURL(p PKCE) string { - v := url.Values{} - v.Set("client_id", c.ClientID) - v.Set("redirect_uri", c.RedirectURL) - v.Set("response_type", "code") - v.Set("scope", Scope) - v.Set("state", p.State) - v.Set("code_challenge", p.Challenge) - v.Set("code_challenge_method", "S256") - // Without both of these Google returns no refresh token on a re-authorisation, - // and the connection silently stops working an hour later. - v.Set("access_type", "offline") - v.Set("prompt", "consent") - return authEndpoint + "?" + v.Encode() -} - -type Token struct { - AccessToken string - RefreshToken string - ExpiresAt time.Time -} - -// Exchange trades the authorisation code for tokens. -func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) { - form := url.Values{} - form.Set("code", code) - form.Set("client_id", c.ClientID) - form.Set("client_secret", c.ClientSecret) - form.Set("redirect_uri", c.RedirectURL) - form.Set("grant_type", "authorization_code") - form.Set("code_verifier", p.Verifier) - - return c.token(ctx, form) -} - -// Refresh gets a fresh access token. Google does not return a new refresh token -// here, so the caller keeps the one it has. -func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) { - form := url.Values{} - form.Set("refresh_token", refreshToken) - form.Set("client_id", c.ClientID) - form.Set("client_secret", c.ClientSecret) - form.Set("grant_type", "refresh_token") - - token, err := c.token(ctx, form) - if err != nil { - return Token{}, err - } - token.RefreshToken = refreshToken - return token, nil -} - -func (c Config) token(ctx context.Context, form url.Values) (Token, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(), - strings.NewReader(form.Encode())) - if err != nil { - return Token{}, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) - if err != nil { - return Token{}, err - } - defer resp.Body.Close() - - var parsed struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return Token{}, fmt.Errorf("gmail: reading token response: %w", err) - } - if parsed.Error != "" { - return Token{}, fmt.Errorf("gmail: %s: %s", parsed.Error, parsed.ErrorDescription) - } - if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" { - return Token{}, fmt.Errorf("gmail: token exchange failed with %s", resp.Status) - } - - return Token{ - AccessToken: parsed.AccessToken, - RefreshToken: parsed.RefreshToken, - // A minute of slack, so a token that is about to expire is refreshed - // rather than used and rejected. - ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second), - }, nil -} +var ( + NewPKCE = google.NewPKCE + ErrUnauthorized = google.ErrUnauthorized +) // --- the API --------------------------------------------------------------------- type Client struct { accessToken string - endpoints Endpoints - http *http.Client + // base is where the Gmail API is. A field rather than a constant so a test can + // point it at a server it controls — and "the sync comes back before whatever + // sits in front of this server gives up on it" is exactly the kind of promise + // worth a test and impossible to make against the real thing. Empty is Google. + base string + http *http.Client } func NewClient(accessToken string) *Client { @@ -210,11 +61,18 @@ func NewClient(accessToken string) *Client { } // At points the client at another server. Tests only; the zero value is Google. -func (c *Client) At(e Endpoints) *Client { - c.endpoints = e +func (c *Client) At(base string) *Client { + c.base = base return c } +func (c *Client) api() string { + if c.base != "" { + return c.base + } + return defaultAPIBase +} + // Message is the part of a Gmail message a task is made from. type Message struct { ID string @@ -233,7 +91,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) { var parsed struct { EmailAddress string `json:"emailAddress"` } - if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil { + if err := c.get(ctx, c.api()+"/users/me/profile", &parsed); err != nil { return "", err } return parsed.EmailAddress, nil @@ -257,7 +115,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err ID string `json:"id"` } `json:"messages"` } - if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil { + if err := c.get(ctx, c.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil { return nil, err } @@ -288,7 +146,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) { } `json:"headers"` } `json:"payload"` } - if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil { + if err := c.get(ctx, c.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil { return Message{}, err } @@ -308,31 +166,9 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) { } func (c *Client) get(ctx context.Context, url string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.accessToken) - - resp, err := c.http.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusUnauthorized { - return ErrUnauthorized - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("gmail: %s", resp.Status) - } - return json.NewDecoder(resp.Body).Decode(out) + return google.Get(ctx, c.http, c.accessToken, url, out) } -// ErrUnauthorized means the tokens are no longer good — the person revoked access, -// or changed their password. The caller disconnects rather than retrying. -var ErrUnauthorized = fmt.Errorf("gmail: authorisation was refused") - // SenderName pulls "Anders Jensen" out of "Anders Jensen ", // falling back to the address when there is no display name. func SenderName(from string) string { @@ -375,11 +211,3 @@ func Query(trigger, label string) string { terms = append(terms, "newer_than:30d") return strings.Join(terms, " ") } - -func randomString(n int) (string, error) { - b := make([]byte, n) - if _, err := rand.Read(b); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b)[:n], nil -} diff --git a/internal/gmail/gmail_test.go b/internal/gmail/gmail_test.go index 59f1d26..b1d53bf 100644 --- a/internal/gmail/gmail_test.go +++ b/internal/gmail/gmail_test.go @@ -1,113 +1,19 @@ package gmail import ( - "crypto/sha256" - "encoding/base64" - "net/url" "strings" "testing" ) -// The challenge has to be the SHA-256 of the verifier, base64url without padding. -// Getting this wrong means Google refuses every exchange with a message that does -// not say which end is at fault. -func TestPKCEChallengeMatchesItsVerifier(t *testing.T) { - p, err := NewPKCE() - if err != nil { - t.Fatal(err) - } - - sum := sha256.Sum256([]byte(p.Verifier)) - want := base64.RawURLEncoding.EncodeToString(sum[:]) - if p.Challenge != want { - t.Errorf("challenge = %q, want %q", p.Challenge, want) - } - - // RFC 7636 requires 43–128 characters, and no padding. - if n := len(p.Verifier); n < 43 || n > 128 { - t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n) - } - if strings.ContainsAny(p.Verifier, "=+/") { - t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier) - } - if p.State == "" { - t.Error("no state was generated") - } - - // Two attempts must not share anything. - other, err := NewPKCE() - if err != nil { - t.Fatal(err) - } - if other.Verifier == p.Verifier || other.State == p.State { - t.Error("two authorisation attempts produced the same secrets") - } -} - -func TestAuthURL(t *testing.T) { - cfg := Config{ - ClientID: "client-123", - ClientSecret: "secret", - RedirectURL: "https://todo.example.dk/oauth/gmail/callback", - } - p, err := NewPKCE() - if err != nil { - t.Fatal(err) - } - - raw := cfg.AuthURL(p) - parsed, err := url.Parse(raw) - if err != nil { - t.Fatalf("the URL does not parse: %v", err) - } - q := parsed.Query() - - checks := map[string]string{ - "client_id": cfg.ClientID, - "redirect_uri": cfg.RedirectURL, - "response_type": "code", - "scope": Scope, - "code_challenge": p.Challenge, - "code_challenge_method": "S256", - "state": p.State, - // Without both of these Google returns no refresh token on a - // re-authorisation, and the connection dies silently an hour later. - "access_type": "offline", - "prompt": "consent", - } - for key, want := range checks { - if got := q.Get(key); got != want { - t.Errorf("%s = %q, want %q", key, got, want) - } - } - - // The verifier itself must never leave the server. - if strings.Contains(raw, p.Verifier) { - t.Error("the code verifier was put in the authorisation URL") - } - // Read-only, and only mail. +// The scope is the one thing verdande chooses about the connection, and it is the +// one the person reads on the consent screen. The flow itself is exercised in +// internal/google; what belongs here is that Gmail still asks for read-only mail. +func TestTheScopeIsReadOnlyMail(t *testing.T) { if !strings.HasSuffix(Scope, "gmail.readonly") { t.Errorf("scope = %q; verdande never needs to send or modify", Scope) } } -func TestConfigured(t *testing.T) { - full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"} - if !full.Configured() { - t.Error("a complete config is not reported as configured") - } - for _, partial := range []Config{ - {}, - {ClientID: "a"}, - {ClientID: "a", ClientSecret: "b"}, - {ClientSecret: "b", RedirectURL: "c"}, - } { - if partial.Configured() { - t.Errorf("%+v was reported as configured", partial) - } - } -} - func TestQuery(t *testing.T) { cases := []struct { trigger, label string diff --git a/internal/google/google.go b/internal/google/google.go new file mode 100644 index 0000000..af8dabe --- /dev/null +++ b/internal/google/google.go @@ -0,0 +1,224 @@ +// Package google implements the OAuth2 authorisation-code flow with PKCE against +// Google. +// +// It was written inside internal/gmail, because Gmail was the only thing that +// needed it. Calendar needs the same flow — the same endpoints, the same PKCE, the +// same refresh — and the only thing that differs between them is one string: the +// scope. A second copy would be a second place for "access_type=offline" to be +// forgotten, which is the mistake that makes a connection die silently an hour +// after it was made. +// +// What is *not* here is the API calls. Those differ completely, so gmail and gcal +// keep their own clients and share only the way in. +package google + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" + defaultTokenEndpoint = "https://oauth2.googleapis.com/token" +) + +// Config is the OAuth client the operator registered in Google Cloud, plus the +// scope one feature is asking for. +// +// The client belongs to the instance rather than to a person: one registration, +// any number of users. The scope belongs to the feature: Gmail and Calendar sign +// in through the same registration and ask for different things, and Google issues +// a refresh token per authorisation — so connecting one does not disturb the other. +type Config struct { + ClientID string + ClientSecret string + RedirectURL string + Scope string + + // TokenURL is where Google is. A field rather than a constant so a test can + // point it at a server it controls: there is no other way to exercise what + // happens when the token endpoint is slow or refuses. Empty is Google, so + // nothing outside a test has to know it exists. + TokenURL string +} + +func (c Config) Configured() bool { + return c.ClientID != "" && c.ClientSecret != "" && c.RedirectURL != "" +} + +func (c Config) tokenURL() string { + if c.TokenURL != "" { + return c.TokenURL + } + return defaultTokenEndpoint +} + +// PKCE is the proof-key pair for one authorisation attempt. +// +// PKCE on a server-side client is not strictly required — the client secret already +// authenticates the token exchange — but it costs nothing and closes the window in +// which an authorisation code intercepted from the redirect could be replayed +// before verdande gets to it. +type PKCE struct { + Verifier string + Challenge string + State string +} + +func NewPKCE() (PKCE, error) { + verifier, err := randomString(64) + if err != nil { + return PKCE{}, err + } + state, err := randomString(32) + if err != nil { + return PKCE{}, err + } + sum := sha256.Sum256([]byte(verifier)) + return PKCE{ + Verifier: verifier, + Challenge: base64.RawURLEncoding.EncodeToString(sum[:]), + State: state, + }, nil +} + +// AuthURL is where the browser is sent to ask the person for consent. +func (c Config) AuthURL(p PKCE) string { + v := url.Values{} + v.Set("client_id", c.ClientID) + v.Set("redirect_uri", c.RedirectURL) + v.Set("response_type", "code") + v.Set("scope", c.Scope) + v.Set("state", p.State) + v.Set("code_challenge", p.Challenge) + v.Set("code_challenge_method", "S256") + // Without both of these Google returns no refresh token on a re-authorisation, + // and the connection silently stops working an hour later. + v.Set("access_type", "offline") + v.Set("prompt", "consent") + return authEndpoint + "?" + v.Encode() +} + +type Token struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// Exchange trades the authorisation code for tokens. +func (c Config) Exchange(ctx context.Context, code string, p PKCE) (Token, error) { + form := url.Values{} + form.Set("code", code) + form.Set("client_id", c.ClientID) + form.Set("client_secret", c.ClientSecret) + form.Set("redirect_uri", c.RedirectURL) + form.Set("grant_type", "authorization_code") + form.Set("code_verifier", p.Verifier) + + return c.token(ctx, form) +} + +// Refresh gets a fresh access token. Google does not return a new refresh token +// here, so the caller keeps the one it has. +func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) { + form := url.Values{} + form.Set("refresh_token", refreshToken) + form.Set("client_id", c.ClientID) + form.Set("client_secret", c.ClientSecret) + form.Set("grant_type", "refresh_token") + + token, err := c.token(ctx, form) + if err != nil { + return Token{}, err + } + token.RefreshToken = refreshToken + return token, nil +} + +func (c Config) token(ctx context.Context, form url.Values) (Token, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(), + strings.NewReader(form.Encode())) + if err != nil { + return Token{}, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return Token{}, err + } + defer resp.Body.Close() + + var parsed struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return Token{}, fmt.Errorf("google: reading token response: %w", err) + } + if parsed.Error != "" { + return Token{}, fmt.Errorf("google: %s: %s", parsed.Error, parsed.ErrorDescription) + } + if resp.StatusCode != http.StatusOK || parsed.AccessToken == "" { + return Token{}, fmt.Errorf("google: token exchange failed with %s", resp.Status) + } + + return Token{ + AccessToken: parsed.AccessToken, + RefreshToken: parsed.RefreshToken, + // A minute of slack, so a token that is about to expire is refreshed + // rather than used and rejected. + ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn-60) * time.Second), + }, nil +} + +// ErrUnauthorized means the tokens are no longer good — the person revoked access, +// or changed their password. The caller disconnects rather than retrying. +var ErrUnauthorized = fmt.Errorf("google: authorisation was refused") + +// Get performs an authorised GET and decodes the JSON body into out. +// +// Shared by the two API clients because the interesting part is the same in both: +// a 401 is not a failure to retry, it is a connection that has ended, and telling +// those apart is what stops a revoked account being polled every ten minutes for +// ever. +func Get(ctx context.Context, client *http.Client, accessToken, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return ErrUnauthorized + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("google: %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func randomString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b)[:n], nil +} diff --git a/internal/google/google_test.go b/internal/google/google_test.go new file mode 100644 index 0000000..9df67ba --- /dev/null +++ b/internal/google/google_test.go @@ -0,0 +1,127 @@ +package google + +import ( + "crypto/sha256" + "encoding/base64" + "net/url" + "strings" + "testing" +) + +// The challenge has to be the SHA-256 of the verifier, base64url without padding. +// Getting this wrong means Google refuses every exchange with a message that does +// not say which end is at fault. +func TestPKCEChallengeMatchesItsVerifier(t *testing.T) { + p, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + + sum := sha256.Sum256([]byte(p.Verifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if p.Challenge != want { + t.Errorf("challenge = %q, want %q", p.Challenge, want) + } + + // RFC 7636 requires 43–128 characters, and no padding. + if n := len(p.Verifier); n < 43 || n > 128 { + t.Errorf("verifier is %d characters, outside the 43–128 the spec allows", n) + } + if strings.ContainsAny(p.Verifier, "=+/") { + t.Errorf("verifier %q contains characters that are not URL-safe", p.Verifier) + } + if p.State == "" { + t.Error("no state was generated") + } + + // Two attempts must not share anything. + other, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + if other.Verifier == p.Verifier || other.State == p.State { + t.Error("two authorisation attempts produced the same secrets") + } +} + +func TestAuthURL(t *testing.T) { + cfg := Config{ + ClientID: "client-123", + ClientSecret: "secret", + RedirectURL: "https://todo.example.dk/oauth/gmail/callback", + Scope: "https://www.googleapis.com/auth/gmail.readonly", + } + p, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + + raw := cfg.AuthURL(p) + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("the URL does not parse: %v", err) + } + q := parsed.Query() + + checks := map[string]string{ + "client_id": cfg.ClientID, + "redirect_uri": cfg.RedirectURL, + "response_type": "code", + "scope": cfg.Scope, + "code_challenge": p.Challenge, + "code_challenge_method": "S256", + "state": p.State, + // Without both of these Google returns no refresh token on a + // re-authorisation, and the connection dies silently an hour later. + "access_type": "offline", + "prompt": "consent", + } + for key, want := range checks { + if got := q.Get(key); got != want { + t.Errorf("%s = %q, want %q", key, got, want) + } + } + + // The verifier itself must never leave the server. + if strings.Contains(raw, p.Verifier) { + t.Error("the code verifier was put in the authorisation URL") + } +} + +// The scope is the one thing that differs between the two features signing in +// through this, so it has to come out of the config rather than out of a constant +// somebody could forget to change. +func TestTheScopeComesFromTheConfig(t *testing.T) { + p, err := NewPKCE() + if err != nil { + t.Fatal(err) + } + calendar := "https://www.googleapis.com/auth/calendar.readonly" + raw := Config{ClientID: "a", Scope: calendar}.AuthURL(p) + + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + if got := parsed.Query().Get("scope"); got != calendar { + t.Errorf("scope = %q, want %q — asking for the wrong one is a consent screen "+ + "that grants the wrong thing", got, calendar) + } +} + +func TestConfigured(t *testing.T) { + full := Config{ClientID: "a", ClientSecret: "b", RedirectURL: "c"} + if !full.Configured() { + t.Error("a complete config is not reported as configured") + } + for _, partial := range []Config{ + {}, + {ClientID: "a"}, + {ClientID: "a", ClientSecret: "b"}, + {ClientSecret: "b", RedirectURL: "c"}, + } { + if partial.Configured() { + t.Errorf("%+v was reported as configured", partial) + } + } +} 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 b5b403d..ef86e7b 100644 --- a/internal/httpapi/gmail_handlers.go +++ b/internal/httpapi/gmail_handlers.go @@ -25,18 +25,16 @@ const defaultGmailSyncBudget = 25 * time.Second // to redeploy a container to paste a client id. // gmailClient is the API client, pointed wherever the config says Google is. func (s *Server) gmailClient(accessToken string) *gmail.Client { - return gmail.NewClient(accessToken).At(gmail.Endpoints{ - Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL, - }) + return gmail.NewClient(accessToken).At(s.cfg.GmailAPIURL) } func (s *Server) gmailConfig(ctx context.Context) gmail.Config { - endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL} cfg := gmail.Config{ ClientID: s.cfg.GmailClientID, ClientSecret: s.cfg.GmailClientSecret, RedirectURL: s.cfg.GmailRedirectURL(), - Endpoints: endpoints, + Scope: gmail.Scope, + TokenURL: s.cfg.GoogleTokenURL, } if cfg.ClientID != "" && cfg.ClientSecret != "" { return cfg @@ -271,7 +269,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 6c87541..a341e25 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 @@ -250,8 +250,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 5c00c29..33b8bac 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) @@ -333,6 +339,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 e02b8a6..19cd1b2 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) @@ -74,6 +78,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) // Checked hourly, sent at most daily — the same shape as the backup, and for // the same reason: a container asleep at the scheduled minute would otherwise // skip the day. SendBeacon decides whether a day has passed. @@ -342,6 +353,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/internal/store/calendars.go b/internal/store/calendars.go new file mode 100644 index 0000000..4e1ed00 --- /dev/null +++ b/internal/store/calendars.go @@ -0,0 +1,380 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// CalendarAccount is one connected account belonging to one person. +type CalendarAccount struct { + ID string `json:"id"` + UserID string `json:"-"` + Provider string `json:"provider"` + Account string `json:"account,omitempty"` + + RefreshToken string `json:"-"` // never leaves the server + AccessToken string `json:"-"` + ExpiresAt time.Time `json:"-"` + + LastSyncAt time.Time `json:"last_sync_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Calendar is one calendar inside an account, shown or not. +type Calendar struct { + ID string `json:"id"` + AccountID string `json:"-"` + RemoteID string `json:"remote_id"` + Name string `json:"name"` + Colour string `json:"colour,omitempty"` + TimeZone string `json:"time_zone,omitempty"` + Primary bool `json:"primary"` + // Writable says 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. + Writable bool `json:"writable"` + Shown bool `json:"shown"` + SortOrder int `json:"-"` +} + +// CalendarEvent is one occurrence, in the shape a grid asks about: days at both +// ends, inclusive. +type CalendarEvent struct { + ID string `json:"id"` + CalendarID string `json:"calendar_id"` + RemoteID string `json:"-"` + Summary string `json:"summary"` + StartsAt string `json:"starts_at,omitempty"` + EndsAt string `json:"ends_at,omitempty"` + StartDay string `json:"start_day"` + EndDay string `json:"end_day"` + AllDay bool `json:"all_day"` + Location string `json:"location,omitempty"` + URL string `json:"url,omitempty"` + + // CalendarName and Colour ride along with the event rather than being looked up + // per row by the caller. Same reasoning as the assignee names on a task list: + // the chip has to be drawn in the calendar's own colour, and a second request + // per event to learn it is a request per event. + CalendarName string `json:"calendar_name,omitempty"` + Colour string `json:"colour,omitempty"` +} + +const calendarAccountColumns = `id, user_id, provider, account, refresh_token, + access_token, expires_at, last_sync_at, created_at` + +// CalendarAccountFor returns a person's connection to one provider, or nil. +func (db *DB) CalendarAccountFor(ctx context.Context, userID, provider string) (*CalendarAccount, error) { + row := db.QueryRowContext(ctx, `SELECT `+calendarAccountColumns+` + FROM calendar_accounts WHERE user_id = ? AND provider = ?`, userID, provider) + a, err := db.scanCalendarAccount(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &a, nil +} + +// SaveCalendarAccount writes one, sealing the two columns that must not travel +// with a backup. Called by hand rather than by the settings tables' own seal, +// which does not reach this table. +func (db *DB) SaveCalendarAccount(ctx context.Context, a *CalendarAccount) error { + if a.ID == "" { + a.ID = NewID() + } + if a.Provider == "" { + a.Provider = "google" + } + if a.CreatedAt.IsZero() { + a.CreatedAt = time.Now() + } + + refresh, err := db.sealValue(a.RefreshToken) + if err != nil { + return fmt.Errorf("seal refresh_token: %w", err) + } + access, err := db.sealValue(a.AccessToken) + if err != nil { + return fmt.Errorf("seal access_token: %w", err) + } + + // The conflict is on the pair, not on the id: a reconnect builds a fresh + // struct with no id in it, and matching on the id would insert a second row + // that the unique index then refuses — which reads to the person as "connecting + // failed" when what happened is that it already had. + _, err = db.ExecContext(ctx, ` + INSERT INTO calendar_accounts (`+calendarAccountColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, provider) DO UPDATE SET + account = excluded.account, + refresh_token = excluded.refresh_token, + access_token = excluded.access_token, + expires_at = excluded.expires_at, + last_sync_at = excluded.last_sync_at`, + a.ID, a.UserID, a.Provider, a.Account, refresh, access, + unixOrZero(a.ExpiresAt), unixOrZero(a.LastSyncAt), a.CreatedAt.Unix()) + if err != nil { + return err + } + + // The row that is actually there wins. On a reconnect the insert above updated + // an existing row, so the id in hand is one nothing points at — and the + // calendars are hung off the id, so writing them against it would attach them + // to nothing. + return db.QueryRowContext(ctx, + `SELECT id FROM calendar_accounts WHERE user_id = ? AND provider = ?`, + a.UserID, a.Provider).Scan(&a.ID) +} + +// MarkCalendarSynced records how far a run got. Written separately from the rest +// so a sync never rewrites the credentials it is holding in memory. +func (db *DB) MarkCalendarSynced(ctx context.Context, accountID string, at time.Time) error { + _, err := db.ExecContext(ctx, + `UPDATE calendar_accounts SET last_sync_at = ? WHERE id = ?`, at.Unix(), accountID) + return err +} + +// DeleteCalendarAccount disconnects one. The calendars and their events go with it +// — 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 has no meaning without the account. +func (db *DB) DeleteCalendarAccount(ctx context.Context, userID, id string) error { + _, err := db.ExecContext(ctx, + `DELETE FROM calendar_accounts WHERE id = ? AND user_id = ?`, id, userID) + return err +} + +// UsersWithCalendars returns everybody the sweep has to visit. +func (db *DB) UsersWithCalendars(ctx context.Context) ([]string, error) { + rows, err := db.QueryContext(ctx, `SELECT DISTINCT user_id FROM calendar_accounts`) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// Calendars returns what one account holds, in the order the provider listed them. +func (db *DB) Calendars(ctx context.Context, accountID string) ([]Calendar, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, account_id, remote_id, name, colour, time_zone, primary_one, + writable, shown, sort_order + FROM calendars WHERE account_id = ? ORDER BY sort_order, name`, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Calendar + for rows.Next() { + var c Calendar + if err := rows.Scan(&c.ID, &c.AccountID, &c.RemoteID, &c.Name, &c.Colour, + &c.TimeZone, &c.Primary, &c.Writable, &c.Shown, &c.SortOrder); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// ReplaceCalendars writes the list the provider just gave, keeping what the person +// chose. +// +// The choice survives because it is not in the list: Google says which calendars +// exist and what they are called, and only verdande knows which of them somebody +// wants to look at. Merged rather than deleted and rewritten, so ticking a calendar +// on does not come undone the next time the sweep runs. +// +// A calendar that has disappeared from the account is removed, and its events go +// with it — a calendar somebody was unshared from should stop being drawn, not +// stay behind as a frozen copy of what it held on the last good day. +func (db *DB) ReplaceCalendars(ctx context.Context, accountID string, list []Calendar) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + keep := map[string]bool{} + for i, c := range list { + keep[c.RemoteID] = true + // The insert cannot carry `shown`: on conflict it would overwrite the + // choice with the default. A new calendar starts hidden — an account + // with a dozen shared calendars in it should not fill somebody's grid + // the moment they connect it. + if _, err := tx.ExecContext(ctx, ` + INSERT INTO calendars + (id, account_id, remote_id, name, colour, time_zone, primary_one, + writable, shown, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (account_id, remote_id) DO UPDATE SET + name = excluded.name, + colour = excluded.colour, + time_zone = excluded.time_zone, + primary_one = excluded.primary_one, + writable = excluded.writable, + sort_order = excluded.sort_order`, + NewID(), accountID, c.RemoteID, c.Name, c.Colour, c.TimeZone, + c.Primary, c.Writable, c.Shown, i); err != nil { + return err + } + } + + rows, err := tx.QueryContext(ctx, + `SELECT id, remote_id FROM calendars WHERE account_id = ?`, accountID) + if err != nil { + return err + } + var gone []string + for rows.Next() { + var id, remote string + if err := rows.Scan(&id, &remote); err != nil { + rows.Close() + return err + } + if !keep[remote] { + gone = append(gone, id) + } + } + // Closed before the next statement runs on this transaction. An open result + // set with a query behind it is the deadlock the pool comment in store.go + // exists to make slow rather than fatal. + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + for _, id := range gone { + if _, err := tx.ExecContext(ctx, `DELETE FROM calendars WHERE id = ?`, id); err != nil { + return err + } + } + return nil + }) +} + +// ShowCalendars sets which of an account's calendars are drawn. +// +// The whole set in one write rather than one flip at a time, for the reason the +// projects' order is written the same way: 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. +func (db *DB) ShowCalendars(ctx context.Context, accountID string, shown []string) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, + `UPDATE calendars SET shown = 0 WHERE account_id = ?`, accountID); err != nil { + return err + } + for _, id := range shown { + // Scoped in the SQL rather than checked first: an id read in one step + // and written in the next is a window, and the account is the whole of + // the permission here. + if _, err := tx.ExecContext(ctx, + `UPDATE calendars SET shown = 1 WHERE id = ? AND account_id = ?`, + id, accountID); err != nil { + return err + } + } + // Events of a calendar nobody is looking at are dead weight in a table the + // grid queries. They come back with the next sync if it is turned on again. + _, err := tx.ExecContext(ctx, ` + DELETE FROM calendar_events WHERE calendar_id IN + (SELECT id FROM calendars WHERE account_id = ? AND shown = 0)`, accountID) + return err + }) +} + +// ReplaceCalendarEvents swaps one calendar's events inside a window. +// +// Replaced rather than merged, and no sync token. Google offers incremental sync, +// and it would save bytes verdande is not short of while costing the one thing it +// cannot afford: a token that has to be right, forever, for the grid not to be +// quietly wrong. A window that is deleted and rewritten cannot drift — an event +// somebody moved out of the window disappears because it is not in the answer, not +// because verdande remembered to delete it. +// +// Bounded by the window at both ends, so a sync of September does not throw away +// August. +func (db *DB) ReplaceCalendarEvents(ctx context.Context, calendarID, from, to string, events []CalendarEvent) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM calendar_events + WHERE calendar_id = ? AND end_day >= ? AND start_day <= ?`, + calendarID, from, to); err != nil { + return err + } + for _, e := range events { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO calendar_events + (id, calendar_id, remote_id, summary, starts_at, ends_at, + start_day, end_day, all_day, location, url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + NewID(), calendarID, e.RemoteID, e.Summary, e.StartsAt, e.EndsAt, + e.StartDay, e.EndDay, e.AllDay, e.Location, e.URL); err != nil { + return err + } + } + return nil + }) +} + +// CalendarEvents returns everything one person can see between two days, +// inclusive at both ends. +// +// Scoped to the caller in the join rather than checked afterwards: the events of +// somebody else's calendar are somebody else's, and a filter applied after the read +// is a filter that can be forgotten. +func (db *DB) CalendarEvents(ctx context.Context, userID, from, to string) ([]CalendarEvent, error) { + rows, err := db.QueryContext(ctx, ` + SELECT e.id, e.calendar_id, e.remote_id, e.summary, e.starts_at, e.ends_at, + e.start_day, e.end_day, e.all_day, e.location, e.url, c.name, c.colour + FROM calendar_events e + JOIN calendars c ON c.id = e.calendar_id + JOIN calendar_accounts a ON a.id = c.account_id + WHERE a.user_id = ? AND c.shown = 1 AND e.end_day >= ? AND e.start_day <= ? + ORDER BY e.start_day, e.all_day DESC, e.starts_at`, userID, from, to) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []CalendarEvent + for rows.Next() { + var e CalendarEvent + if err := rows.Scan(&e.ID, &e.CalendarID, &e.RemoteID, &e.Summary, &e.StartsAt, + &e.EndsAt, &e.StartDay, &e.EndDay, &e.AllDay, &e.Location, &e.URL, + &e.CalendarName, &e.Colour); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (db *DB) scanCalendarAccount(row scanner) (CalendarAccount, error) { + var a CalendarAccount + var expires, lastSync, created int64 + err := row.Scan(&a.ID, &a.UserID, &a.Provider, &a.Account, &a.RefreshToken, + &a.AccessToken, &expires, &lastSync, &created) + if err != nil { + return a, err + } + for _, field := range []*string{&a.RefreshToken, &a.AccessToken} { + plain, err := db.unsealValue(*field) + if err != nil { + return a, err + } + *field = plain + } + a.ExpiresAt = timeOrZero(expires) + a.LastSyncAt = timeOrZero(lastSync) + a.CreatedAt = time.Unix(created, 0) + return a, nil +} diff --git a/internal/store/calendars_test.go b/internal/store/calendars_test.go new file mode 100644 index 0000000..1f80ec1 --- /dev/null +++ b/internal/store/calendars_test.go @@ -0,0 +1,358 @@ +package store + +import ( + "context" + "strings" + "testing" + "time" +) + +func connectedAccount(t *testing.T, db *DB, userID string) *CalendarAccount { + t.Helper() + a := &CalendarAccount{ + UserID: userID, Provider: "google", Account: "kw@nolimit.dk", + RefreshToken: "1//0-a-real-looking-refresh-token", AccessToken: "ya29.access", + ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.SaveCalendarAccount(context.Background(), a); err != nil { + t.Fatal(err) + } + return a +} + +// The same claim the mailbox tokens have to meet: what is written must not be +// readable in the file a backup copies. +func TestCalendarTokensAreNotStoredInTheClear(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + + var refresh, access string + if err := db.QueryRowContext(ctx, + `SELECT refresh_token, access_token FROM calendar_accounts WHERE id = ?`, + a.ID).Scan(&refresh, &access); err != nil { + t.Fatal(err) + } + if strings.Contains(refresh, "real-looking") || strings.Contains(access, "ya29") { + t.Fatalf("a token is in the row as written: %q %q", refresh, access) + } + // The address stays readable, which is the reason for sealing named columns + // rather than the whole row. + var account string + if err := db.QueryRowContext(ctx, + `SELECT account FROM calendar_accounts WHERE id = ?`, a.ID).Scan(&account); err != nil { + t.Fatal(err) + } + if account != "kw@nolimit.dk" { + t.Errorf("the address was sealed too: %q", account) + } + + back, err := db.CalendarAccountFor(ctx, userID, "google") + if err != nil { + t.Fatal(err) + } + if back.RefreshToken != a.RefreshToken || back.AccessToken != a.AccessToken { + t.Errorf("the tokens did not survive the round trip: %+v", back) + } +} + +// Connecting again must replace what was there, not fail. It is the same OAuth +// flow run a second time, and the person's word for it is "connect", not "add". +func TestConnectingAgainReplacesTheAccount(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + first := connectedAccount(t, db, userID) + + second := &CalendarAccount{ + UserID: userID, Provider: "google", Account: "kw@nolimit.dk", + RefreshToken: "1//0-fresh", AccessToken: "ya29.fresh", + } + if err := db.SaveCalendarAccount(ctx, second); err != nil { + t.Fatalf("connecting a second time failed: %v", err) + } + // And the id in hand is the row that is actually there. The calendars hang off + // it, so an id nothing points at would attach them to nothing. + if second.ID != first.ID { + t.Errorf("the second connect came back with id %q, want the existing %q", + second.ID, first.ID) + } + + back, err := db.CalendarAccountFor(ctx, userID, "google") + if err != nil { + t.Fatal(err) + } + if back.RefreshToken != "1//0-fresh" { + t.Errorf("the token came back as %q", back.RefreshToken) + } +} + +// Google says which calendars exist; only verdande knows which ones somebody wants +// to look at. A refresh that came back with the same list must not undo the choice. +func TestRefreshingTheListKeepsWhatWasChosen(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + + list := []Calendar{ + {RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true}, + {RemoteID: "fam", Name: "Familien", Colour: "#0b8043"}, + {RemoteID: "old", Name: "Et delt"}, + } + if err := db.ReplaceCalendars(ctx, a.ID, list); err != nil { + t.Fatal(err) + } + + // Nothing is shown to begin with: an account with a dozen shared calendars in + // it should not fill somebody's grid the moment they connect it. + stored, err := db.Calendars(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if len(stored) != 3 { + t.Fatalf("got %d calendars, want 3", len(stored)) + } + for _, c := range stored { + if c.Shown { + t.Errorf("%q was shown without anybody asking for it", c.Name) + } + } + + var famID string + for _, c := range stored { + if c.RemoteID == "fam" { + famID = c.ID + } + } + if err := db.ShowCalendars(ctx, a.ID, []string{famID}); err != nil { + t.Fatal(err) + } + + // The sweep runs again, and one calendar has been renamed and one has gone. + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{ + {RemoteID: "primary", Name: "kw@nolimit.dk", Colour: "#3f51b5", Primary: true}, + {RemoteID: "fam", Name: "Familien og hunden", Colour: "#0b8043"}, + }); err != nil { + t.Fatal(err) + } + + stored, err = db.Calendars(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if len(stored) != 2 { + t.Fatalf("got %d calendars; one that has left the account should stop being drawn", + len(stored)) + } + for _, c := range stored { + switch c.RemoteID { + case "fam": + if !c.Shown { + t.Error("the choice was undone by a refresh of the list") + } + if c.Name != "Familien og hunden" { + t.Errorf("the new name did not arrive: %q", c.Name) + } + if c.ID != famID { + t.Error("the row was rebuilt rather than updated; its id is what was chosen") + } + case "primary": + if c.Shown { + t.Error("a calendar nobody chose became shown") + } + } + } +} + +// A window is replaced, not merged. An event moved out of it has to disappear +// because it is absent from the answer — and the months on either side must not go +// with it. +func TestReplacingAWindowLeavesTheMonthsAroundItAlone(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "primary", Name: "Min"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + cal := stored[0] + if err := db.ShowCalendars(ctx, a.ID, []string{cal.ID}); err != nil { + t.Fatal(err) + } + + july := CalendarEvent{RemoteID: "j", Summary: "Juli", StartDay: "2026-07-15", EndDay: "2026-07-15"} + august := CalendarEvent{RemoteID: "a", Summary: "August", StartDay: "2026-08-15", EndDay: "2026-08-15"} + if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-07-01", "2026-07-31", + []CalendarEvent{july}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", + []CalendarEvent{august}); err != nil { + t.Fatal(err) + } + + got, err := db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d events, want both months: %+v", len(got), got) + } + + // August is synced again and the meeting has been cancelled. July stays. + if err := db.ReplaceCalendarEvents(ctx, cal.ID, "2026-08-01", "2026-08-31", nil); err != nil { + t.Fatal(err) + } + got, err = db.CalendarEvents(ctx, userID, "2026-07-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Summary != "Juli" { + t.Fatalf("got %+v; syncing August threw away July", got) + } +} + +// The grid asks "which cells does this cover", so an event that straddles the edge +// of the question has to come back. +func TestAnEventThatStraddlesTheWindowIsFound(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min", Colour: "#0b8043"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-09-30", + []CalendarEvent{ + {RemoteID: "trip", Summary: "Aarhus", StartDay: "2026-08-28", EndDay: "2026-09-02", AllDay: true}, + }); err != nil { + t.Fatal(err) + } + + // A week that contains neither end of it. + got, err := db.CalendarEvents(ctx, userID, "2026-08-31", "2026-09-01") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("got %+v; an event covering the whole week was not found", got) + } + // The calendar's own colour rides along, or the chip has to ask for it per row. + if got[0].Colour != "#0b8043" || got[0].CalendarName != "Min" { + t.Errorf("the calendar did not come with the event: %+v", got[0]) + } +} + +// Somebody else's calendar is somebody else's. +func TestOnlyTheOwnersEventsComeBack(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + + other := &User{Email: "andreas@example.dk", Name: "Andreas"} + if err := db.CreateUser(ctx, other, "Indbakke"); err != nil { + t.Fatal(err) + } + theirs := &CalendarAccount{UserID: other.ID, Provider: "google", RefreshToken: "r"} + if err := db.SaveCalendarAccount(ctx, theirs); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendars(ctx, theirs.ID, []Calendar{{RemoteID: "c", Name: "Deres"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, theirs.ID) + if err := db.ShowCalendars(ctx, theirs.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31", + []CalendarEvent{{RemoteID: "x", Summary: "Privat", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil { + t.Fatal(err) + } + + got, err := db.CalendarEvents(ctx, userID, "2026-08-01", "2026-08-31") + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("somebody else's calendar came back: %+v", got) + } +} + +// A calendar turned off stops being drawn, and stops costing the grid anything. +func TestTurningACalendarOffRemovesItsEvents(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31", + []CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil { + t.Fatal(err) + } + + if err := db.ShowCalendars(ctx, a.ID, nil); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM calendar_events`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Errorf("%d events of a calendar nobody is looking at are still in the table", count) + } +} + +// Disconnecting takes the calendars and the events with it. 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. +func TestDisconnectingTakesEverythingWithIt(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + a := connectedAccount(t, db, userID) + if err := db.ReplaceCalendars(ctx, a.ID, []Calendar{{RemoteID: "c", Name: "Min"}}); err != nil { + t.Fatal(err) + } + stored, _ := db.Calendars(ctx, a.ID) + if err := db.ShowCalendars(ctx, a.ID, []string{stored[0].ID}); err != nil { + t.Fatal(err) + } + if err := db.ReplaceCalendarEvents(ctx, stored[0].ID, "2026-08-01", "2026-08-31", + []CalendarEvent{{RemoteID: "x", Summary: "Møde", StartDay: "2026-08-10", EndDay: "2026-08-10"}}); err != nil { + t.Fatal(err) + } + + if err := db.DeleteCalendarAccount(ctx, userID, a.ID); err != nil { + t.Fatal(err) + } + for _, table := range []string{"calendar_accounts", "calendars", "calendar_events"} { + var count int + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM `+table).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Errorf("%s still holds %d rows after disconnecting", table, count) + } + } +} + +// The sweep finds people by the presence of an account, so it has to keep working +// now that the credential in it is ciphertext. +func TestTheSweepFindsAConnectedCalendar(t *testing.T) { + db, userID := sealedStore(t) + connectedAccount(t, db, userID) + + users, err := db.UsersWithCalendars(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(users) != 1 || users[0] != userID { + t.Fatalf("the sweep found %v", users) + } +} diff --git a/internal/store/migrations/0023_calendars.sql b/internal/store/migrations/0023_calendars.sql new file mode 100644 index 0000000..e143a45 --- /dev/null +++ b/internal/store/migrations/0023_calendars.sql @@ -0,0 +1,111 @@ +-- Calendars read from somewhere else: the account, the calendars in it, and the +-- events they hold. +-- +-- Not in `mailboxes`, and it was the first thing considered. The two look alike +-- from a distance — a person connects a Google account over OAuth, tokens are +-- sealed, a sweep polls it — and every column past that disagrees. `trigger_kind`, +-- `seen`, `last_uid` and `project_id` all answer "what turns a message into a +-- task", and nothing here turns into a task. What is here instead is a *set*: +-- one account holds many calendars, and a person picks which of them to look at. +-- `mailboxes` has no room for a one-to-many, and giving it one would push the +-- other kind's columns onto rows that can never use them. +-- +-- The decisive part is smaller and more practical. `mailboxes.kind` carries +-- `CHECK (kind IN ('gmail', 'imap'))`, and SQLite cannot alter a CHECK — adding a +-- third value means rebuilding the table with foreign keys off, which is the +-- expensive, careful thing migration 0021 had to do for a reason. Paying that to +-- store something that shares no column with the table is the wrong trade. +-- +-- Hence no CHECK on `provider` here. CalDAV is the next source to read, and it +-- should cost a row, not a rebuild. + +-- One connected account per person per provider. The tokens live here rather than +-- on each calendar: a refresh token copied onto ten rows is ten copies of one +-- secret, ten refreshes racing each other, and ten places to miss when access is +-- revoked. +-- +-- `refresh_token` and `access_token` are written sealed; see internal/secret. The +-- store's own seal/unseal only covers the settings tables, so this table's methods +-- call them by hand — the same fact worth knowing that migration 0014 wrote down. +CREATE TABLE calendar_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT 'google', + -- The address the account reports, so the settings page can say which one is + -- connected rather than only that one is. + account TEXT NOT NULL DEFAULT '', + + refresh_token TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + expires_at INTEGER NOT NULL DEFAULT 0, + + last_sync_at INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- Connecting again replaces what was there, the way Gmail's does: the OAuth flow +-- is one account at a time, so a second row for the same provider would be a +-- second set of tokens for the same grant and no way to say which is live. +CREATE UNIQUE INDEX calendar_accounts_once ON calendar_accounts (user_id, provider); + +-- One row per calendar the account can see, kept whether or not it is shown. +-- +-- `shown` rather than deleting the ones that are not: a person who turns a calendar +-- off and on again should get the same row back, and the list is refreshed from +-- Google on every sync — so a calendar that only existed while it was ticked would +-- lose its choice every time somebody added a new one. +-- +-- `colour` is Google's own hex for the calendar. Not one of verdande's ten named +-- colours, which is the rule everywhere else in this database: 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, and +-- that colour belongs to Google. +CREATE TABLE calendars ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES calendar_accounts (id) ON DELETE CASCADE, + -- The id Google knows it by. Long, and an address for the primary calendar. + remote_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + colour TEXT NOT NULL DEFAULT '', + time_zone TEXT NOT NULL DEFAULT '', + primary_one INTEGER NOT NULL DEFAULT 0, + writable INTEGER NOT NULL DEFAULT 0, + shown INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX calendars_once ON calendars (account_id, remote_id); + +-- The events themselves, cached. +-- +-- Cached rather than fetched per view, and the reason is what a month grid does: +-- it pages. Asking Google on every arrow press means a token refresh, a round trip +-- and a spinner for a question that was answered ninety seconds ago — and a +-- calendar that is slower than the calendar it is showing is not worth having. +-- The window is refreshed by the sweep and by the "fetch now" button. +-- +-- Days rather than instants, and both ends inclusive. A grid asks one question — +-- "which cells does this cover" — and `start_day <= ? AND end_day >= ?` is that +-- question. `starts_at` is kept as Google wrote it, offset and all, because it is +-- what the chip shows and reparsing it here would ask *this process* what time +-- zone it is in. An all-day event has neither: it has a day, not a moment, and +-- inventing midnight for it is how an event moves a day when a container moves +-- time zone. +CREATE TABLE calendar_events ( + id TEXT PRIMARY KEY, + calendar_id TEXT NOT NULL REFERENCES calendars (id) ON DELETE CASCADE, + remote_id TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + starts_at TEXT NOT NULL DEFAULT '', + ends_at TEXT NOT NULL DEFAULT '', + start_day TEXT NOT NULL, + end_day TEXT NOT NULL, + all_day INTEGER NOT NULL DEFAULT 0, + location TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '' +); + +-- The one query there is: everything a person can see, between two days. Ordered +-- by the account so the join has somewhere to start, then by the days the grid +-- compares against. +CREATE INDEX calendar_events_by_day ON calendar_events (calendar_id, start_day, end_day); diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 2dd4b5c..4cf6da4 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -979,6 +979,211 @@ test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ( * asserting against a project the admin can already see would pass with the old * per-project endpoint behind it. */ +/** + * Kalender: Google's events and verdande's own tasks in one grid, and the rule + * that tells them apart. + * + * The Google half is stubbed at the network layer rather than connected. There are + * no credentials in a test run and there never will be — an OAuth consent screen is + * a human pressing a button on somebody else's website — so what can be proved here + * is what the grid does with events once it has them, which is the part that has a + * rule in it: a task is draggable and reschedules, an event is not and cannot. + * + * The date is read out of the *page*, not out of this process. Playwright pins the + * browser to Europe/Copenhagen and node is pinned to nothing, so for two hours of + * every day the two disagree about what today is — which is how a green suite + * starts failing at half past midnight. Today is also the one day guaranteed to be + * in the month grid whatever the weekday, so nothing here has to page. + */ +test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven kan trækkes', async ({ + page +}) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + // A day at least a week out, whose next day is in the same Monday-to-Sunday + // week — so the drag below crosses one cell and not one grid. + // + // Worked out in the browser, not here. Playwright pins the page to + // Europe/Copenhagen and this process is pinned to nothing, so for two hours of + // every day the two disagree about what today is. A week out also keeps this + // test's day clear of the ones the rest of the suite dates things on. + const { day, next } = await page.evaluate(() => { + const iso = (d) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate() + ).padStart(2, '0')}`; + const monday = (d) => { + const m = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + m.setDate(m.getDate() - ((m.getDay() + 6) % 7)); + return iso(m); + }; + const cursor = new Date(); + cursor.setDate(cursor.getDate() + 7); + for (let i = 0; i < 14; i++) { + cursor.setDate(cursor.getDate() + 1); + const after = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1); + if (monday(cursor) === monday(after)) return { day: iso(cursor), next: iso(after) }; + } + throw new Error('no two consecutive days inside one week in three weeks'); + }); + + // A calendar that is connected and holds two events that day: one with a clock + // and one that lasts the whole day. The window is deliberately wide, so the + // notice about having paged past it stays out of the way. + await page.route('**/api/v1/calendar/events*', (route) => + route.fulfill({ + json: { + from: '2000-01-01', + to: '2099-12-31', + events: [ + { + id: 'ev-1', + calendar_id: 'c1', + summary: 'Bestyrelsesmøde', + starts_at: `${day}T14:00:00+02:00`, + ends_at: `${day}T15:30:00+02:00`, + start_day: day, + end_day: day, + all_day: false, + url: 'https://calendar.google.com/event?eid=abc', + calendar_name: 'Arbejde', + colour: '#0b8043' + }, + { + id: 'ev-2', + calendar_id: 'c1', + summary: 'Feriedag', + start_day: day, + end_day: day, + all_day: true, + calendar_name: 'Familien', + colour: '#3f51b5' + } + ] + } + }) + ); + await page.route('**/api/v1/calendar', (route) => + route.fulfill({ + json: { + connected: true, + account: 'kw@nolimit.dk', + read_only: true, + has_client: true, + redirect_uri: 'http://localhost/oauth/calendar/callback', + calendars: [ + { id: 'c1', remote_id: 'fam', name: 'Arbejde', colour: '#0b8043', shown: true } + ] + } + }) + ); + + // verdande's own half of the grid: a task on that day, made the way anybody + // would make it. The date is written out rather than typed as "i morgen", so + // the assertion does not depend on the parser as well. + const box = page.getByLabel('Ny opgave'); + await box.fill(`vande blomster ${day}`); + await box.press('Enter'); + // Not asserted here: this is the Today view and the task is dated a week out, + // so it is correctly absent. The grid below is where it has to turn up. + await expect(box).toHaveValue(''); + + // The menu item, which is the thing the sidebar gained. + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.locator('a[href="/kalender"]').click(); + await page.waitForURL('**/kalender'); + await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible(); + + // The week, not the month, and for a reason worth writing down: a month cell + // truncates at three chips and this suite has other tests putting tasks on + // other days. A week cell holds ten, so what is asserted below is what the view + // does with events rather than how full somebody else left the month. + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + + // Forward a week at a time until the row is the one holding that day. Bounded, + // because a loop that pages for ever on a broken button is a timeout with no + // explanation in it. + const cell = page.locator(`[data-date="${day}"]`); + const forward = page.getByRole('button', { name: 'Næste uge' }); + for (let i = 0; i < 6 && (await cell.count()) === 0; i++) { + await forward.click(); + } + await expect(cell, `uge-visningen nåede aldrig frem til ${day}`).toBeVisible(); + + // Connected, with a calendar ticked — so neither of the two "there is nothing + // here" notices belongs on the screen. They exist so an empty grid is never + // unexplained, and an explanation for a state you are not in is noise. + await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden(); + await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden(); + + // Both kinds are in the same cell, which is the whole point of the view. + const event = cell.locator('.event').filter({ hasText: 'Bestyrelsesmøde' }); + const allDay = cell.locator('.event').filter({ hasText: 'Feriedag' }); + const task = cell.locator('.chip').filter({ hasText: 'vande blomster' }); + await expect(event).toBeVisible(); + await expect(allDay).toBeVisible(); + await expect(task).toBeVisible(); + + // A timed event carries its clock; an all-day one has none to carry. + await expect(event).toContainText('14:00'); + await expect(allDay).not.toContainText(':'); + + // The rule. An event is read-only, so it must not offer to be moved — the drop + // targets would refuse it anyway, but a chip that lifts off the page and then + // will not land reads as a bug rather than as a rule. + await expect(event).toHaveAttribute('draggable', 'false'); + await expect(allDay).toHaveAttribute('draggable', 'false'); + await expect(task).toHaveAttribute('draggable', 'true'); + + // And it cannot be ticked off either: there is nothing to complete about + // somebody else's meeting. + expect(await event.evaluate((el) => el.tagName)).not.toBe('BUTTON'); + + // It does link back to Google, which is what makes it a pointer to the event + // rather than a second copy of it. + await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//); + + // The task still reschedules by drag, in a grid that now has events in it. + await task.dragTo(page.locator(`[data-date="${next}"]`)); + await expect(page.locator(`[data-date="${next}"]`).getByText('vande blomster')).toBeVisible(); + await expect(cell.getByText('vande blomster')).toBeHidden(); + // The events stayed where they were; a rescheduled task moves nothing else. + await expect(cell.locator('.event')).toHaveCount(2); + + expect(trouble).toEqual([]); +}); + +/** + * The view without a calendar connected, which is what every fresh instance is. + * + * It has to draw anyway. The grid is verdande's own tasks with due dates — the + * thing this view was before anything was laid over it — and a Google account that + * is not there must not take it down with it. + */ +test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/kalender'); + + await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible(); + // Seven weekday columns and a grid under them, whatever day of the week it is. + await expect(page.locator('.calendar .grid .day').first()).toBeVisible(); + + // It says so rather than looking like a calendar with nothing in it, and it + // says where to go about it. + await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeVisible(); + await expect( + page.locator('a[href="/indstillinger/integrationer"]').first() + ).toBeVisible(); + + // The week is the same grid with a different span, and switching to it must not + // lose the events prop on the way. + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + await expect(page.locator('.calendar.week')).toBeVisible(); + + expect(trouble).toEqual([]); +}); + test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({ browser, page diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 96f988e..bd9440c 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); @@ -365,6 +372,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/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index c880643..b00a6f1 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -49,7 +49,19 @@ * flight swallows the click that would page. A week that straddles the * 31st and the 1st has both days in the same row. */ - span = 'month' + span = 'month', + /** + * Events read from somewhere else, laid over the same grid. + * + * An event is not a task, and the cell has to say so. It has a span of time + * rather than a day, it cannot be ticked off, and — while the connection is + * read-only — it cannot be moved: verdande holds a copy of somebody else's + * calendar, and a chip that lets itself be dragged is a promise the server + * has no way to keep. + * + * Empty everywhere but Kalender, so no other view pays for it. + */ + events = [] } = $props(); let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date())); @@ -137,6 +149,66 @@ (!projectId || t.project_id === projectId) ); + /** + * Which cells an event covers, rather than where it starts. A trip from Friday + * to Monday is in all four, and asking only about the start day would draw it + * once and leave three days looking clear. + * + * Ordered here rather than trusted from the caller. The server does sort, but a + * cell that shows 14:00 above 09:15 does not look like a list in the wrong + * order — it looks like the clock on the chip is wrong, which is a far worse + * thing to have to rule out. All-day first, because an all-day event is a band + * over the whole day rather than something that happens at a point in it. + */ + const eventsOn = (date) => { + const day = iso(date); + return events + .filter((e) => e.start_day <= day && e.end_day >= day) + .toSorted((a, b) => { + if (a.all_day !== b.all_day) return a.all_day ? -1 : 1; + return (a.starts_at ?? '').localeCompare(b.starts_at ?? ''); + }); + }; + + /** + * The time on the chip, and only where it means something. + * + * An all-day event has no time. A timed one shows its start, and only on the + * day it starts: a meeting that runs past midnight would otherwise claim to + * begin at 20:00 on both days. + * + * The clock is read out of the string rather than parsed into a Date. Google + * writes the event's own offset into it, and handing that to the browser asks + * *this device* what time it is — so a phone in another time zone would show a + * Copenhagen meeting an hour out. + */ + function timeOn(event, date) { + if (event.all_day || !event.starts_at) return ''; + if (event.start_day !== iso(date)) return ''; + return event.starts_at.slice(11, 16); + } + + /** + * The calendar's own colour, or nothing. + * + * Checked rather than trusted, even though it comes from Google: this goes into + * an inline `style`, and a value that is not a colour is a value that can close + * the declaration and open another. A hex triple is the whole of what Google + * sends, so anything else is not a colour that failed to render — it is a + * string that has no business being here. + */ + const HEX = /^#[0-9a-f]{3,8}$/i; + const swatch = (colour) => (HEX.test(colour ?? '') ? colour : ''); + + /** + * Whether the event links back to Google. + * + * `https:` and nothing else. An href is where a `javascript:` URL becomes a + * click that runs, and the address in this field was written by a service, not + * by the person looking at it. + */ + const linkOf = (event) => (String(event.url ?? '').startsWith('https://') ? event.url : ''); + const monthName = $derived( cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' }) ); @@ -232,7 +304,14 @@
{#each grid as date (date.toISOString())} - {@const tasks = tasksOn(date)} + {@const dayEvents = eventsOn(date)} + + {@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))} + {@const hidden = + tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)} {@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()} @@ -254,7 +333,36 @@ {/if} {date.getDate()} - {#each tasks.slice(0, chipLimit) as task (task.id)} + {#each dayEvents.slice(0, chipLimit) as event (event.id)} + {@const link = linkOf(event)} + {@const label = t('cal.eventCannotMove', { + name: event.summary, + calendar: event.calendar_name || t('cal.title') + })} + + + {#if timeOn(event, date)}{timeOn(event, date)}{/if} + {event.summary} + + {/each} + + {#each tasks as task (task.id)} + {#if app.user?.is_admin} +
+ + +

{t('int.calendarSameClient')}

+
+ {:else if !calendar.has_client} +

{t('int.noClient')}

+ {/if} + {:else} +

+ {t('int.calendarConnectedAs')} + {calendar.account || t('int.unknownAccount')}. +

+ +
+ {#if calendar.calendars.length} +
+ {t('int.whichCalendars')} + {#each calendar.calendars as c (c.id)} + + {/each} +
+ {:else} +

{t('int.noCalendars')}

+ {/if} + +

+ {calendar.last_sync_at + ? t('int.calendarLastSync', { + when: new Date(calendar.last_sync_at).toLocaleString(tag()) + }) + : t('int.calendarNeverSynced')} +

+ +
+ + {#if calendarsSaved}{t('int.saved')}{/if} + + +
+
+ {/if} + +

{t('int.feed')}

@@ -529,6 +724,38 @@ border-color: var(--danger); } + fieldset { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: var(--s3); + display: flex; + flex-direction: column; + gap: var(--s2); + } + + legend { + font-size: var(--text-sm); + color: var(--ink-muted); + padding: 0 var(--s1); + } + + .pick { + display: flex; + align-items: center; + gap: var(--s2); + font-size: var(--text-sm); + } + + /* A solid mark beside a label that carries the meaning, so it only has to be + tellable from its neighbours and visible on both near-black and white — the + same job the project colours' dots do. */ + .swatch { + width: 10px; + height: 10px; + border-radius: var(--radius-full); + flex: none; + } + a { color: var(--accent); } diff --git a/web/src/routes/kalender/+page.svelte b/web/src/routes/kalender/+page.svelte new file mode 100644 index 0000000..2d55f0a --- /dev/null +++ b/web/src/routes/kalender/+page.svelte @@ -0,0 +1,195 @@ + + +
+
+

{t('cal.title')}

+
+ {#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]} + + {/each} +
+
+ + {#if connected === false} + +

+ {t('cal.notConnected')} + {t('cal.settings')} +

+ {/if} + + + {#if connected && !anyShown} +

+ {t('cal.noneChosen')} + {t('cal.settings')} +

+ {/if} + + {#if beyond} +

{t('cal.beyondWindow', { from: window_.from, to: window_.to })}

+ {/if} + + {#if calendarView.mode === 'week'} + + {:else} + + {/if} +
+ +