feat(calendar): en læst Google-kalender, med sit eget endpoint og sin egen sweep
Samme registrering som Gmail, et andet scope og en anden redirect-URI. Google udsteder en refresh-token pr. godkendelse og holder op til hundrede i live pr. konto, hver med sine egne scopes — så en forbundet kalender rører ikke en Gmail, der virker, og at afbryde den ene tager ikke den anden med. Callbacken har sin egen sti. Én callback, der ud fra gemt tilstand afgjorde, hvilket af de to flows der kom tilbage, ville lægge valget af *hvilket token-lager der skrives* bag et opslag — og går det galt, går det galt ved at overskrive en virkende Gmail-forbindelse med kalender-tokens. Begivenhederne caches i et vindue på 90 dage tilbage og et år frem. Et månedsgitter bladrer, og et kald til Google pr. piletast er en kalender, der er langsommere end den kalender, den viser. Vinduet har en kant, og endpointet siger hvor den er: et tomt svar uden forklaring er en kalender, der lydløst påstår, at en dag er fri.
This commit is contained in:
@@ -75,6 +75,7 @@ func run() error {
|
||||
runner := jobs.New(cfg, db, log, api.Mail(), api.Hub())
|
||||
runner.SyncGmail = api.SyncGmail
|
||||
runner.SyncMailbox = api.SyncMailbox
|
||||
runner.SyncCalendars = api.SyncCalendars
|
||||
runner.Push = api.PushToUser
|
||||
runner.Start(jobCtx)
|
||||
|
||||
|
||||
@@ -2507,6 +2507,126 @@ paths:
|
||||
created: { type: integer }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The Google Calendar connection and the calendars in it.
|
||||
description: |
|
||||
Read-only. verdande shows a Google calendar beside its own dated tasks and
|
||||
never writes one back — writing needs the `calendar.events` scope and an
|
||||
answer to what happens when both sides moved the same meeting, which is a
|
||||
synchronisation model rather than a scope.
|
||||
|
||||
The list of calendars is fetched from Google when it is empty, so an
|
||||
account that connected during a hiccup corrects itself on the next look
|
||||
rather than showing an empty panel for ever.
|
||||
responses:
|
||||
"200": { $ref: "#/components/responses/Calendar" }
|
||||
delete:
|
||||
tags: [integrations]
|
||||
summary: Disconnect the calendar and forget its tokens.
|
||||
description: >
|
||||
The cached events go too. Unlike the tasks a mailbox made, none of this is
|
||||
the person's own work — it is a copy of somebody else's calendar, and it
|
||||
means nothing without the account.
|
||||
responses:
|
||||
"204": { description: Disconnected. }
|
||||
|
||||
/api/v1/calendar/calendars:
|
||||
put:
|
||||
tags: [integrations]
|
||||
summary: Choose which calendars are drawn.
|
||||
description: >
|
||||
The whole set in one write rather than one flip at a time, for the reason
|
||||
the projects' order is written the same way: the question is "these ones",
|
||||
and a list built out of several requests can land half applied. A calendar
|
||||
turned off has its cached events removed; they come back if it is turned
|
||||
on again.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
shown:
|
||||
type: array
|
||||
items: { type: string }
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/calendar/authorize:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Begin the OAuth flow for a calendar.
|
||||
description: |
|
||||
The same registration Gmail signs in through, asking for
|
||||
`calendar.readonly` and coming back to `/oauth/calendar/callback`. Google
|
||||
issues a refresh token per authorisation, so connecting a calendar does not
|
||||
disturb a working Gmail connection.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
"409":
|
||||
$ref: "#/components/responses/Conflict"
|
||||
|
||||
/api/v1/calendar/sync:
|
||||
post:
|
||||
tags: [integrations]
|
||||
summary: Refresh the chosen calendars now.
|
||||
description: >
|
||||
Refreshes the list of calendars and replaces the cached window of events
|
||||
for the ones that are shown. `partial` means the budget ran out before the
|
||||
last calendar; what was read is kept, and the rest arrive on the next run.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events: { type: integer }
|
||||
partial: { type: boolean }
|
||||
"502": { $ref: "#/components/responses/Upstream" }
|
||||
|
||||
/api/v1/calendar/events:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The events of the chosen calendars, between two days.
|
||||
description: |
|
||||
Both ends inclusive, and an event that merely overlaps the range comes
|
||||
back — a grid asks which cells an event covers, not where it begins.
|
||||
|
||||
`from` and `to` in the answer are the window verdande holds a copy of,
|
||||
which is not the window that was asked for. A grid paged past it gets no
|
||||
events, and an empty answer with no explanation is a calendar quietly
|
||||
claiming a day is clear.
|
||||
parameters:
|
||||
- { name: from, in: query, required: true, schema: { type: string, format: date } }
|
||||
- { name: to, in: query, required: true, schema: { type: string, format: date } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/CalendarEvent" }
|
||||
from: { type: string, format: date }
|
||||
to: { type: string, format: date }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/export/notes.zip:
|
||||
get:
|
||||
tags: [notes]
|
||||
@@ -3150,6 +3270,26 @@ paths:
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/oauth/calendar/callback:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: Where Google sends the browser after consent to a calendar.
|
||||
description: |
|
||||
A URI of its own rather than a reuse of Gmail's. One callback deciding from
|
||||
stored state which of the two flows had come back would put the choice of
|
||||
which token store to write behind a lookup — and when that goes wrong it
|
||||
goes wrong by overwriting a working Gmail connection with calendar tokens.
|
||||
|
||||
Answers with a redirect to `/indstillinger/integrationer`, not with JSON.
|
||||
An `org_internal` error here means the OAuth app is registered as Internal
|
||||
and the account is outside the organisation.
|
||||
parameters:
|
||||
- { name: code, in: query, schema: { type: string } }
|
||||
- { name: state, in: query, schema: { type: string } }
|
||||
- { name: error, in: query, schema: { type: string } }
|
||||
responses:
|
||||
"302": { description: "Back into the app, with the outcome as a query parameter." }
|
||||
|
||||
/mcp:
|
||||
post:
|
||||
tags: [integrations]
|
||||
@@ -3332,6 +3472,40 @@ components:
|
||||
email: { type: string }
|
||||
trigger: { type: string, enum: ["", starred, label, both] }
|
||||
label: { type: string }
|
||||
Calendar:
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
connected: { type: boolean }
|
||||
account:
|
||||
type: string
|
||||
description: >
|
||||
The address of the connected account, taken from the id of its
|
||||
primary calendar — which is where Google puts it, so no profile
|
||||
scope has to be asked for to learn it.
|
||||
read_only:
|
||||
type: boolean
|
||||
description: >
|
||||
Always true today. Stated rather than assumed: an interface that
|
||||
quietly looks editable is one somebody will try to edit.
|
||||
has_client:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether an OAuth client is registered on this instance at all, so
|
||||
the page can explain rather than offer a button that answers 409.
|
||||
redirect_uri:
|
||||
type: string
|
||||
description: >
|
||||
What has to be registered with Google, spelled out. Derived from
|
||||
VERDANDE_BASE_URL and matched exactly — the single most likely
|
||||
thing to be wrong, and the error Google gives names neither value.
|
||||
last_sync_at: { type: string, format: date-time }
|
||||
calendars:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Calendar" }
|
||||
ImportResult:
|
||||
description: Created
|
||||
content:
|
||||
@@ -3349,6 +3523,62 @@ components:
|
||||
items: { type: string }
|
||||
|
||||
schemas:
|
||||
Calendar:
|
||||
type: object
|
||||
description: >
|
||||
One calendar inside a connected account. Kept whether or not it is shown,
|
||||
so turning one off and on again gives back the same row.
|
||||
properties:
|
||||
id: { type: string }
|
||||
remote_id: { type: string, description: "The id Google knows it by; an address for the primary calendar." }
|
||||
name: { type: string }
|
||||
colour:
|
||||
type: string
|
||||
description: >
|
||||
Google's own hex value, not one of verdande's ten named colours. Those
|
||||
are names so the interface can restyle them per theme and they are
|
||||
verdande's palette; a Google calendar is told apart by the colour the
|
||||
person already knows it by.
|
||||
time_zone: { type: string }
|
||||
primary: { type: boolean }
|
||||
writable:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the connected account could write to it. Nothing does — the
|
||||
scope is read-only — and it is stored so the day something can, the
|
||||
interface knows which calendars could even be offered.
|
||||
shown: { type: boolean }
|
||||
|
||||
CalendarEvent:
|
||||
type: object
|
||||
description: >
|
||||
One occurrence, in the shape a grid asks about. A recurrence is expanded by
|
||||
Google into its occurrences, so what arrives here is a day, not a rule.
|
||||
properties:
|
||||
id: { type: string }
|
||||
calendar_id: { type: string }
|
||||
summary: { type: string }
|
||||
starts_at:
|
||||
type: string
|
||||
description: >
|
||||
RFC 3339 as Google wrote it, offset and all. Empty for an all-day
|
||||
event, which has a day rather than a moment — inventing midnight for it
|
||||
is how an event moves a day when a container moves time zone.
|
||||
ends_at: { type: string }
|
||||
start_day: { type: string, format: date }
|
||||
end_day:
|
||||
type: string
|
||||
format: date
|
||||
description: >
|
||||
The last day the event covers, inclusive. Google's all-day end date is
|
||||
exclusive and is corrected on the way in, once, rather than by every
|
||||
reader.
|
||||
all_day: { type: boolean }
|
||||
location: { type: string }
|
||||
url: { type: string, description: "Opens the event in Google's own interface." }
|
||||
calendar_name: { type: string }
|
||||
colour: { type: string }
|
||||
|
||||
Note:
|
||||
type: object
|
||||
description: A page of Markdown. The body is the stored form and the exported form.
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
@@ -100,6 +109,7 @@ func Load() (*Config, error) {
|
||||
|
||||
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", ""),
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,17 @@ const Scope = "https://www.googleapis.com/auth/calendar.readonly"
|
||||
|
||||
const defaultAPIBase = "https://www.googleapis.com/calendar/v3"
|
||||
|
||||
// The OAuth flow, which is Google's rather than the calendar's — the same one
|
||||
// Gmail signs in through, asking for the scope above. Aliased so a caller reads
|
||||
// one package for one feature.
|
||||
type (
|
||||
Config = google.Config
|
||||
PKCE = google.PKCE
|
||||
Token = google.Token
|
||||
)
|
||||
|
||||
var NewPKCE = google.NewPKCE
|
||||
|
||||
// ErrUnauthorized means the tokens are no longer good — access was revoked, or the
|
||||
// password changed. Re-exported so a caller need not know the flow lives elsewhere.
|
||||
var ErrUnauthorized = google.ErrUnauthorized
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
Scope: gmail.Scope,
|
||||
TokenURL: s.cfg.GmailTokenURL,
|
||||
TokenURL: s.cfg.GoogleTokenURL,
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||
return cfg
|
||||
@@ -258,7 +258,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
// The same guard as the mailboxes read over IMAP, for the same reason: a
|
||||
// press of "Fetch now" during the sweep would otherwise make two runs from
|
||||
// one marker.
|
||||
unlock := s.lockMailbox(box.ID)
|
||||
unlock := s.lockSync(box.ID)
|
||||
defer unlock()
|
||||
if fresh, err := s.db.MailboxOfKind(ctx, user.ID, "gmail"); err == nil && fresh != nil {
|
||||
box = fresh
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
// One reader of this mailbox at a time. Held for the whole run, marker and all:
|
||||
// releasing before the marker is written would leave exactly the gap this is
|
||||
// here to close.
|
||||
unlock := s.lockMailbox(m.ID)
|
||||
unlock := s.lockSync(m.ID)
|
||||
defer unlock()
|
||||
|
||||
// Re-read under the lock. The row that came in may have been fetched before the
|
||||
@@ -231,8 +231,14 @@ func (s *Server) SyncMailbox(ctx context.Context, user *store.User, m *store.Mai
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// lockMailbox serialises one mailbox against itself and returns the release.
|
||||
func (s *Server) lockMailbox(id string) func() {
|
||||
// lockSync serialises one thing that polls against itself and returns the release.
|
||||
//
|
||||
// A mailbox to begin with; a calendar connection now takes the same lock under a
|
||||
// key of its own. Two runs at once — somebody pressing "fetch now" while the sweep
|
||||
// is in the middle of the same thing — both start from the state as it was, and
|
||||
// the loser writes last. For a mailbox that made forty copies of one mail in a
|
||||
// second; for a calendar it would be a window deleted after it was refilled.
|
||||
func (s *Server) lockSync(id string) func() {
|
||||
value, _ := s.syncing.LoadOrStore(id, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -133,6 +133,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/oauth/gmail/callback", s.handleGmailCallback)
|
||||
// The calendar comes back on a URI of its own rather than sharing Gmail's.
|
||||
// One callback deciding from stored state which of the two flows had
|
||||
// returned would put the choice of *which token store to write* behind a
|
||||
// lookup — and when that goes wrong it goes wrong by overwriting a working
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
|
||||
@@ -316,6 +322,19 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
})
|
||||
|
||||
// A calendar read from somewhere else, laid under verdande's own dated
|
||||
// tasks. Read-only, so there is no write here to gate: the events are a
|
||||
// copy of somebody else's calendar, and the only thing a person decides
|
||||
// is which of them to look at.
|
||||
r.Route("/calendar", func(r chi.Router) {
|
||||
r.Get("/", s.handleGetCalendar)
|
||||
r.Put("/calendars", s.handleSetCalendars)
|
||||
r.Delete("/", s.handleDisconnectCalendar)
|
||||
r.Post("/authorize", s.handleCalendarAuthorize)
|
||||
r.Post("/sync", s.handleCalendarSyncNow)
|
||||
r.Get("/events", s.handleCalendarEvents)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
|
||||
@@ -41,6 +41,10 @@ type Runner struct {
|
||||
// reason as SyncGmail: it owns turning a message into a task.
|
||||
SyncMailbox func(ctx context.Context, user *store.User, m *store.Mailbox) (int, error)
|
||||
|
||||
// SyncCalendars refreshes one person's connected calendars. Supplied the same
|
||||
// way and for the same reason: the HTTP layer owns the token refresh.
|
||||
SyncCalendars func(ctx context.Context, user *store.User) (int, error)
|
||||
|
||||
// Push delivers a notification to somebody's devices. Supplied by the HTTP
|
||||
// layer, which owns the VAPID keys and the subscription list.
|
||||
Push func(userID, title, body, projectID string)
|
||||
@@ -69,6 +73,13 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||
// starred and slow enough not to look like abuse.
|
||||
r.every(ctx, "mailboxes", 10*time.Minute, r.syncMailboxes)
|
||||
// Fifteen minutes rather than ten. A calendar is not a mailbox: nothing here
|
||||
// becomes a task, so being a quarter of an hour behind on a meeting next
|
||||
// Tuesday costs nothing — and the "fetch now" button covers the one case where
|
||||
// it matters, which is somebody who has just moved something and is looking at
|
||||
// the grid to check. A person who has just connected does not wait for this
|
||||
// either; connecting fetches the list, and the first sync is one click away.
|
||||
r.every(ctx, "calendars", 15*time.Minute, r.syncCalendars)
|
||||
}
|
||||
|
||||
func (r *Runner) Wait() { r.wg.Wait() }
|
||||
@@ -322,6 +333,44 @@ func (r *Runner) syncMailboxes(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- calendars ------------------------------------------------------------------------
|
||||
|
||||
// syncCalendars refreshes everybody's connected calendars.
|
||||
//
|
||||
// Its own sweep rather than a branch inside the mailbox one. They share nothing
|
||||
// past the word "poll": that loop asks each mailbox how far it has read and makes
|
||||
// tasks, this one replaces a window of somebody else's events. Folding them
|
||||
// together would mean one interval for two questions that do not want the same
|
||||
// answer, and a Google that is down would count against a mailbox that is not.
|
||||
//
|
||||
// Nobody is waiting, so no budget of its own — the request handler has one because
|
||||
// a person is watching a spinner. One account that has gone quiet costs this run
|
||||
// its own timeout and nothing else: the loop moves on per person.
|
||||
func (r *Runner) syncCalendars(ctx context.Context) error {
|
||||
if r.SyncCalendars == nil {
|
||||
return nil
|
||||
}
|
||||
userIDs, err := r.db.UsersWithCalendars(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
user, err := r.db.UserByID(ctx, id)
|
||||
if err != nil || user == nil {
|
||||
continue
|
||||
}
|
||||
written, err := r.SyncCalendars(ctx, user)
|
||||
if err != nil {
|
||||
r.log.Warn("calendar sync", "err", err, "user", id)
|
||||
continue
|
||||
}
|
||||
if written > 0 {
|
||||
r.log.Info("calendar refreshed", "user", id, "events", written)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- housekeeping -----------------------------------------------------------------
|
||||
|
||||
func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||
|
||||
@@ -61,6 +61,10 @@ const MESSAGES = {
|
||||
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
||||
// this when it did not.
|
||||
mailbox_failed: 'error.mailboxRefused',
|
||||
// And for a calendar read from Google. Its own code, because the sentence this
|
||||
// is wrapped in names where it came from — and "Gmail said no" about a calendar
|
||||
// sends somebody to the wrong panel.
|
||||
calendar_failed: 'error.calendarRefused',
|
||||
last_admin: 'error.lastAdmin'
|
||||
};
|
||||
|
||||
@@ -84,6 +88,9 @@ export function humanMessage(err) {
|
||||
if (err.code === 'gmail_failed' && err.message) {
|
||||
return t('error.gmailSaid', { what: err.message });
|
||||
}
|
||||
if (err.code === 'calendar_failed' && err.message) {
|
||||
return t('error.calendarSaid', { what: err.message });
|
||||
}
|
||||
|
||||
const key = MESSAGES[err.code];
|
||||
if (key) return t(key);
|
||||
@@ -361,6 +368,16 @@ export const api = {
|
||||
authorizeGmail: () => post('/gmail/authorize'),
|
||||
syncGmail: () => post('/gmail/sync'),
|
||||
|
||||
// The Google Calendar connection. Read-only: there is nothing here that writes
|
||||
// an event, only which calendars to look at.
|
||||
getCalendar: () => get('/calendar'),
|
||||
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
||||
disconnectCalendar: () => del('/calendar'),
|
||||
authorizeCalendar: () => post('/calendar/authorize'),
|
||||
syncCalendar: () => post('/calendar/sync'),
|
||||
calendarEvents: (from, to) =>
|
||||
get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
|
||||
|
||||
// Notes. Listing and searching are one endpoint because they answer the same
|
||||
// question with different words.
|
||||
notes: (params = {}) => {
|
||||
|
||||
@@ -65,6 +65,7 @@ export const da = {
|
||||
'nav.upcoming': 'Kommende',
|
||||
'nav.delegated': 'Venter på andre',
|
||||
'nav.inbox': 'Indbakke',
|
||||
'nav.calendar': 'Kalender',
|
||||
'nav.settings': 'Indstillinger',
|
||||
'nav.signOut': 'Log ud',
|
||||
'nav.main': 'Hovedmenu',
|
||||
@@ -586,6 +587,30 @@ export const da = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||
'int.calendar': 'Google Kalender',
|
||||
'int.calendarHint':
|
||||
'Læses, aldrig skrives. Begivenhederne vises i Kalender ved siden af dine egne opgaver med forfaldsdato — de kan ikke flyttes eller lukkes herfra, for de hører til i Google.',
|
||||
'int.connectCalendar': 'Forbind Google Kalender',
|
||||
'int.calendarConnectedAs': 'Forbundet som',
|
||||
'int.whichCalendars': 'Hvilke kalendere vises',
|
||||
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
|
||||
'int.calendarLastSync': 'Sidst hentet {when}.',
|
||||
'int.calendarNeverSynced': 'Ikke hentet endnu.',
|
||||
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
||||
'int.calendarFetchedMany': '{n} begivenheder hentet.',
|
||||
'int.calendarNothingNew': 'Ingen begivenheder i vinduet.',
|
||||
'int.calendarConnected': 'Google Kalender er forbundet.',
|
||||
'int.calendarState':
|
||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||
'int.calendarExpired': 'Forsøget udløb undervejs. Prøv igen.',
|
||||
'int.calendarInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.',
|
||||
'int.calendarFailed': 'Google afviste ombytningen af koden.',
|
||||
'int.calendarNoRefresh':
|
||||
'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.',
|
||||
'int.calendarDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.calendarOrgInternal':
|
||||
'Google-appen er registreret som Internal og tager kun imod konti i organisationen. En privat konto kan ikke godkende den, uanset hvad der står på skærmen.',
|
||||
'int.calendarOther': 'Google svarede: {what}',
|
||||
'int.gmailFetchedOne': '{n} opgave hentet.',
|
||||
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||
@@ -600,6 +625,17 @@ export const da = {
|
||||
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.gmailOther': 'Gmail svarede: {what}',
|
||||
|
||||
// --- kalenderen, med Googles begivenheder lagt over ---------------------------------
|
||||
'cal.title': 'Kalender',
|
||||
'cal.notConnected':
|
||||
'Ingen kalender er forbundet endnu. Det gøres under Indstillinger → Integrationer.',
|
||||
'cal.settings': 'Indstillinger',
|
||||
'cal.allDay': 'Hele dagen',
|
||||
'cal.openInGoogle': 'Åbn i Google Kalender',
|
||||
'cal.beyondWindow':
|
||||
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
||||
'cal.eventCannotMove': '{name} — en begivenhed i {calendar}. Den kan ikke flyttes herfra.',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'Projektets navn',
|
||||
'project.clickToRename': 'Klik for at omdøbe',
|
||||
@@ -741,6 +777,10 @@ export const da = {
|
||||
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
|
||||
|
||||
'error.gmailSaid': 'Gmail svarede: {what}',
|
||||
'error.calendarRefused': 'Google afviste kaldet. Prøv at forbinde kalenderen igen.',
|
||||
'error.calendarSaid': 'Google svarede: {what}',
|
||||
'int.disconnectCalendarQuestion':
|
||||
'Afbryd forbindelsen til Google Kalender? Tokens bliver glemt, og den hentede kopi af begivenhederne bliver slettet. Dine egne opgaver rører den ikke.',
|
||||
|
||||
'error.mailboxRefused': 'Postkassen kunne ikke læses.',
|
||||
'error.mailboxSaid': 'Postkassen svarede: {what}',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const en = {
|
||||
'nav.upcoming': 'Upcoming',
|
||||
'nav.delegated': 'Waiting on others',
|
||||
'nav.inbox': 'Inbox',
|
||||
'nav.calendar': 'Calendar',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.signOut': 'Sign out',
|
||||
'nav.main': 'Main menu',
|
||||
@@ -582,6 +583,29 @@ export const en = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'The username is your email. The password is an API token — not your real password.',
|
||||
'int.calendar': 'Google Calendar',
|
||||
'int.calendarHint':
|
||||
'Read, never written. The events appear in Calendar beside your own tasks with due dates — they cannot be moved or completed from here, because they belong to Google.',
|
||||
'int.connectCalendar': 'Connect Google Calendar',
|
||||
'int.calendarConnectedAs': 'Connected as',
|
||||
'int.whichCalendars': 'Which calendars are shown',
|
||||
'int.noCalendars': 'The account has no calendars to show.',
|
||||
'int.calendarLastSync': 'Last fetched {when}.',
|
||||
'int.calendarNeverSynced': 'Not fetched yet.',
|
||||
'int.calendarFetchedOne': '{n} event fetched.',
|
||||
'int.calendarFetchedMany': '{n} events fetched.',
|
||||
'int.calendarNothingNew': 'No events in the window.',
|
||||
'int.calendarConnected': 'Google Calendar is connected.',
|
||||
'int.calendarState': 'Google’s reply did not match the attempt that started here. Try again.',
|
||||
'int.calendarExpired': 'The attempt expired on the way. Try again.',
|
||||
'int.calendarInvalid': 'Google’s reply could not be read. Try again.',
|
||||
'int.calendarFailed': 'Google refused to exchange the code.',
|
||||
'int.calendarNoRefresh':
|
||||
'Google sent no refresh token. Remove verdande from your Google account and connect again.',
|
||||
'int.calendarDenied': 'Access was refused at Google.',
|
||||
'int.calendarOrgInternal':
|
||||
'The Google app is registered as Internal and only accepts accounts inside the organisation. A private account cannot consent to it, whatever the screen says.',
|
||||
'int.calendarOther': 'Google replied: {what}',
|
||||
'int.gmailFetchedOne': '{n} task fetched.',
|
||||
'int.gmailFetchedMany': '{n} tasks fetched.',
|
||||
'int.gmailNothingNew': 'No new messages.',
|
||||
@@ -595,6 +619,16 @@ export const en = {
|
||||
'int.gmailDenied': 'Access was refused at Google.',
|
||||
'int.gmailOther': 'Gmail replied: {what}',
|
||||
|
||||
// --- the calendar, with Google’s events laid over ------------------------------------
|
||||
'cal.title': 'Calendar',
|
||||
'cal.notConnected': 'No calendar is connected yet. That is done under Settings → Integrations.',
|
||||
'cal.settings': 'Settings',
|
||||
'cal.allDay': 'All day',
|
||||
'cal.openInGoogle': 'Open in Google Calendar',
|
||||
'cal.beyondWindow':
|
||||
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
||||
'cal.eventCannotMove': '{name} — an event in {calendar}. It cannot be moved from here.',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'The project’s name',
|
||||
'project.clickToRename': 'Click to rename',
|
||||
@@ -736,6 +770,10 @@ export const en = {
|
||||
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
|
||||
|
||||
'error.gmailSaid': 'Gmail said: {what}',
|
||||
'error.calendarRefused': 'Google refused the call. Try connecting the calendar again.',
|
||||
'error.calendarSaid': 'Google replied: {what}',
|
||||
'int.disconnectCalendarQuestion':
|
||||
'Disconnect Google Calendar? The tokens are forgotten and the fetched copy of the events is deleted. Your own tasks are untouched.',
|
||||
|
||||
'error.mailboxRefused': 'The mailbox could not be read.',
|
||||
'error.mailboxSaid': 'The mailbox answered: {what}',
|
||||
|
||||
Reference in New Issue
Block a user