Disconnect deleted the connection but left every pushed plan synced with its stored event ids, though no calendar was chosen any more. It now deletes the connection and runs the same invalidation as choosing another calendar (forgetEvents: MarkCalendarPlansEdited, ClearCalendarEvents) in one transaction, under the account lock. A refused revoke still changes nothing. Tests push three weeks, disconnect, and check each is changed with no event id; then connect again, choose a calendar and push the whole week to it. Also: a refused revoke keeps every week synced, and a disconnect during a push waits for its record and then forgets it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
10 KiB
Go
275 lines
10 KiB
Go
package gcal
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
const (
|
|
// calendarPageSize is the calendarList page size: Google's maximum.
|
|
calendarPageSize = 250
|
|
// maxCalendarPages caps how many calendarList pages a listing reads.
|
|
maxCalendarPages = 20
|
|
// maxCalendarBody caps how much of one calendarList page is read.
|
|
maxCalendarBody = 1 << 20
|
|
)
|
|
|
|
var (
|
|
// ErrReconnect means Google no longer accepts the stored grant: the
|
|
// household revoked MealQuest's access in their Google account, or the
|
|
// refresh token expired. Only connecting again restores it.
|
|
ErrReconnect = errors.New("gcal: Google no longer accepts MealQuest's access; connect Google Calendar again")
|
|
// ErrUnknownCalendar means the calendar chosen is not one the connected
|
|
// account can push to.
|
|
ErrUnknownCalendar = errors.New("gcal: not a calendar MealQuest can add dinners to")
|
|
)
|
|
|
|
// Calendar is one calendar the connected account can push to.
|
|
type Calendar struct {
|
|
ID string `json:"id"`
|
|
Summary string `json:"summary"`
|
|
Primary bool `json:"primary"`
|
|
AccessRole string `json:"accessRole"`
|
|
}
|
|
|
|
// Selection is the calendar MealQuest pushes to.
|
|
type Selection struct {
|
|
CalendarID string `json:"calendar_id"`
|
|
CalendarName string `json:"calendar_name"`
|
|
}
|
|
|
|
// CalendarError is a Calendar API or token refresh request Google refused
|
|
// or that failed in transit. Its text never holds a token or Google's
|
|
// response body.
|
|
type CalendarError struct {
|
|
msg string
|
|
}
|
|
|
|
func (e *CalendarError) Error() string { return e.msg }
|
|
|
|
// Calendars lists the connected account's calendars it can push to: those
|
|
// it is a writer or owner of, in Google's order.
|
|
func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
|
|
s.account.Lock()
|
|
defer s.account.Unlock()
|
|
return s.calendars(ctx)
|
|
}
|
|
|
|
// SelectCalendar makes id the calendar MealQuest pushes to, storing its id
|
|
// and name. id must be among Calendars at the time of the call; anything
|
|
// else, including a calendar chosen earlier that has since been deleted or
|
|
// is no longer writable, is ErrUnknownCalendar and leaves the stored
|
|
// choice as it was. Choosing another calendar than the one chosen returns
|
|
// every plan pushed to that one to not synced; see saveSelection.
|
|
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
|
|
s.account.Lock()
|
|
defer s.account.Unlock()
|
|
cals, err := s.calendars(ctx)
|
|
if err != nil {
|
|
return Selection{}, err
|
|
}
|
|
for _, c := range cals {
|
|
if c.ID != id {
|
|
continue
|
|
}
|
|
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
|
|
if err := s.saveSelection(ctx, sel); err != nil {
|
|
return Selection{}, err
|
|
}
|
|
return sel, nil
|
|
}
|
|
return Selection{}, ErrUnknownCalendar
|
|
}
|
|
|
|
// saveSelection stores sel as the chosen calendar. Choosing another calendar
|
|
// than the one stored also forgets every plan day's calendar event id, in
|
|
// the same transaction, since those events are on the calendar no longer
|
|
// chosen (forgetEvents): each plan with one is edited, so a synced plan
|
|
// becomes changed and its next push writes every day to sel. The caller
|
|
// holds s.account, which a push also holds from its event writes through
|
|
// recording them, so a push never records events written to another
|
|
// calendar than the chosen one.
|
|
func (s *Service) saveSelection(ctx context.Context, sel Selection) error {
|
|
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
row, err := q.GetIntegrationsGoogle(ctx)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
// The lock is held since the connection was read, so only a
|
|
// change outside this service gets here.
|
|
return ErrNotConnected
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("gcal: read Google integration: %w", err)
|
|
}
|
|
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
|
if _, err := q.SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
|
|
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
|
|
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
return fmt.Errorf("gcal: save calendar: %w", err)
|
|
}
|
|
if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID {
|
|
return nil
|
|
}
|
|
return forgetEvents(ctx, q, now)
|
|
})
|
|
}
|
|
|
|
// forgetEvents records, in the caller's transaction q, that no calendar
|
|
// event id stored is on the chosen calendar any more: every plan with one is
|
|
// edited (a synced plan becomes changed, and a push that read it before is
|
|
// refused), then every id is cleared. Every change that ends the chosen
|
|
// calendar — choosing another (saveSelection) or disconnecting
|
|
// (Disconnect) — runs it in the transaction that makes the change.
|
|
func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
|
|
if err := q.MarkCalendarPlansEdited(ctx, now); err != nil {
|
|
return fmt.Errorf("gcal: mark plans pushed to the earlier calendar edited: %w", err)
|
|
}
|
|
if err := q.ClearCalendarEvents(ctx, now); err != nil {
|
|
return fmt.Errorf("gcal: forget events on the earlier calendar: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// calendars lists the writable calendars, each once, with the stored refresh
|
|
// token. The caller holds s.account.
|
|
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
|
|
row, err := s.connection(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
access, err := s.accessToken(ctx, row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cals := []Calendar{}
|
|
// listed holds the ids in cals: pages can overlap, and a calendar is
|
|
// listed once, as Google first lists it.
|
|
listed := map[string]bool{}
|
|
pageToken := ""
|
|
for range maxCalendarPages {
|
|
page, err := s.calendarPage(ctx, access, pageToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range page.Items {
|
|
if item.ID == "" {
|
|
return nil, &CalendarError{msg: "gcal: Google listed a calendar without an id"}
|
|
}
|
|
if (item.AccessRole == "writer" || item.AccessRole == "owner") && !listed[item.ID] {
|
|
listed[item.ID] = true
|
|
cals = append(cals, Calendar{ID: item.ID, Summary: item.Summary, Primary: item.Primary, AccessRole: item.AccessRole})
|
|
}
|
|
}
|
|
if page.NextPageToken == "" {
|
|
return cals, nil
|
|
}
|
|
pageToken = page.NextPageToken
|
|
}
|
|
return nil, &CalendarError{msg: fmt.Sprintf("gcal: Google listed more than %d pages of calendars", maxCalendarPages)}
|
|
}
|
|
|
|
// connection reads the connected account, or ErrNotConnected.
|
|
func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) {
|
|
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return db.IntegrationsGoogle{}, ErrNotConnected
|
|
}
|
|
if err != nil {
|
|
return db.IntegrationsGoogle{}, fmt.Errorf("gcal: read Google integration: %w", err)
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// accessToken trades row's refresh token for an access token. A grant
|
|
// Google reports as invalid_grant is ErrReconnect.
|
|
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
|
|
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
|
|
if err != nil {
|
|
return "", fmt.Errorf("gcal: open refresh token: %w", err)
|
|
}
|
|
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
|
|
&oauth2.Token{RefreshToken: string(refresh)}).Token()
|
|
if err != nil {
|
|
var retrieve *oauth2.RetrieveError
|
|
var transport *url.Error
|
|
switch {
|
|
case errors.As(err, &retrieve) && retrieve.ErrorCode == "invalid_grant":
|
|
return "", ErrReconnect
|
|
case errors.As(err, &retrieve):
|
|
return "", &CalendarError{msg: fmt.Sprintf("gcal: Google refused the token refresh (HTTP %d)", retrieve.Response.StatusCode)}
|
|
case errors.As(err, &transport):
|
|
// The URL is dropped: after a refused redirect it is the
|
|
// Google-supplied Location.
|
|
return "", &CalendarError{msg: fmt.Sprintf("gcal: token refresh failed: %s: %v", transport.Op, transport.Err)}
|
|
default:
|
|
return "", &CalendarError{msg: "gcal: token refresh returned an unusable response"}
|
|
}
|
|
}
|
|
return token.AccessToken, nil
|
|
}
|
|
|
|
// calendarListPage is the part of a calendarList page MealQuest reads.
|
|
type calendarListPage struct {
|
|
Items []struct {
|
|
ID string `json:"id"`
|
|
Summary string `json:"summary"`
|
|
Primary bool `json:"primary"`
|
|
AccessRole string `json:"accessRole"`
|
|
} `json:"items"`
|
|
NextPageToken string `json:"nextPageToken"`
|
|
}
|
|
|
|
// calendarPage fetches the calendarList page at pageToken ("" for the
|
|
// first) with access. HTTP 401 means Google no longer accepts the grant.
|
|
func (s *Service) calendarPage(ctx context.Context, access, pageToken string) (calendarListPage, error) {
|
|
q := url.Values{"maxResults": {fmt.Sprint(calendarPageSize)}}
|
|
if pageToken != "" {
|
|
q.Set("pageToken", pageToken)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
|
|
if err != nil {
|
|
return calendarListPage{}, fmt.Errorf("gcal: build calendar list request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+access)
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
var transport *url.Error
|
|
if errors.As(err, &transport) {
|
|
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list request failed: %s: %v", transport.Op, transport.Err)}
|
|
}
|
|
return calendarListPage{}, &CalendarError{msg: "gcal: calendar list request failed"}
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxCalendarBody+1))
|
|
if err != nil {
|
|
return calendarListPage{}, &CalendarError{msg: "gcal: read calendar list failed"}
|
|
}
|
|
switch {
|
|
case resp.StatusCode == http.StatusUnauthorized:
|
|
return calendarListPage{}, ErrReconnect
|
|
case resp.StatusCode != http.StatusOK:
|
|
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", resp.StatusCode)}
|
|
case len(body) > maxCalendarBody:
|
|
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list page exceeds %d bytes", maxCalendarBody)}
|
|
}
|
|
var page calendarListPage
|
|
if err := json.Unmarshal(body, &page); err != nil {
|
|
return calendarListPage{}, &CalendarError{msg: "gcal: Google returned an unreadable calendar list"}
|
|
}
|
|
return page, nil
|
|
}
|