Deduplicate by calendar id as the pages are collected, keeping the first occurrence in Google's order (MQ-126-01), with a test serving two overlapping pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
7.9 KiB
Go
224 lines
7.9 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.
|
|
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}
|
|
n, err := db.New(s.st.Writer).SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
|
|
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
|
|
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
|
|
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
|
|
})
|
|
if err != nil {
|
|
return Selection{}, fmt.Errorf("gcal: save calendar: %w", err)
|
|
}
|
|
if n != 1 {
|
|
// The lock is held since the connection was read, so only a
|
|
// change outside this service gets here.
|
|
return Selection{}, ErrNotConnected
|
|
}
|
|
return sel, nil
|
|
}
|
|
return Selection{}, ErrUnknownCalendar
|
|
}
|
|
|
|
// 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) {
|
|
access, err := s.accessToken(ctx)
|
|
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)}
|
|
}
|
|
|
|
// accessToken trades the stored refresh token for an access token. A grant
|
|
// Google reports as invalid_grant is ErrReconnect.
|
|
func (s *Service) accessToken(ctx context.Context) (string, error) {
|
|
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotConnected
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("gcal: read Google integration: %w", err)
|
|
}
|
|
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
|
|
}
|