Skip to content

Commit e7fae16

Browse files
feat: add context propagation, security fixes, and new features
Drive: - Add context propagation to all API calls - Add path traversal security fix in download Gmail: - Add context propagation to labels and thread commands - Simplify MIME building (remove unused ReplyTo, BodyHTML) - Add --from flag for send-as aliases in send and drafts - Simplify base64 decoding - Add path traversal security fix in attachments Calendar: - Add needsAction status support to respond command - Add --comment flag for response comments - Add organizer check to prevent self-response Auth: - Add browser-based account management command (auth manage) - Add web UI for managing connected accounts Maintenance: - Update golangci-lint config for v2 compatibility
1 parent d2be673 commit e7fae16

23 files changed

Lines changed: 2916 additions & 799 deletions

.golangci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
version: 2
2+
13
run:
24
timeout: 5m
35

@@ -7,7 +9,6 @@ linters:
79
- govet
810
- ineffassign
911
- staticcheck
10-
- typecheck
1112
- unused
1213

1314
linters-settings:

internal/cmd/auth.go

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ import (
1717
"github.com/steipete/gogcli/internal/ui"
1818
)
1919

20-
var (
21-
openSecretsStore = secrets.OpenDefault
22-
authorizeGoogle = googleauth.Authorize
23-
)
20+
var openSecretsStore = secrets.OpenDefault
2421

2522
func newAuthCmd() *cobra.Command {
2623
cmd := &cobra.Command{
@@ -33,6 +30,7 @@ func newAuthCmd() *cobra.Command {
3330
cmd.AddCommand(newAuthListCmd())
3431
cmd.AddCommand(newAuthRemoveCmd())
3532
cmd.AddCommand(newAuthTokensCmd())
33+
cmd.AddCommand(newAuthManageCmd())
3634
return cmd
3735
}
3836

@@ -179,7 +177,7 @@ func newAuthTokensExportCmd() *cobra.Command {
179177
if openErr != nil {
180178
return openErr
181179
}
182-
defer f.Close()
180+
defer func() { _ = f.Close() }()
183181

184182
type export struct {
185183
Email string `json:"email"`
@@ -335,7 +333,7 @@ func newAuthAddCmd() *cobra.Command {
335333
return err
336334
}
337335

338-
refreshToken, err := authorizeGoogle(cmd.Context(), googleauth.AuthorizeOptions{
336+
refreshToken, err := googleauth.Authorize(cmd.Context(), googleauth.AuthorizeOptions{
339337
Services: services,
340338
Scopes: scopes,
341339
Manual: manual,
@@ -378,7 +376,7 @@ func newAuthAddCmd() *cobra.Command {
378376

379377
cmd.Flags().BoolVar(&manual, "manual", false, "Browserless auth flow (paste redirect URL)")
380378
cmd.Flags().BoolVar(&forceConsent, "force-consent", false, "Force consent screen to obtain a refresh token")
381-
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts,tasks,people")
379+
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts,sheets")
382380
return cmd
383381
}
384382

@@ -463,3 +461,47 @@ func newAuthRemoveCmd() *cobra.Command {
463461
},
464462
}
465463
}
464+
465+
func newAuthManageCmd() *cobra.Command {
466+
var forceConsent bool
467+
var servicesCSV string
468+
var timeout time.Duration
469+
470+
cmd := &cobra.Command{
471+
Use: "manage",
472+
Short: "Open accounts manager in browser",
473+
Long: "Opens a browser-based UI to manage Google accounts, add new accounts, set defaults, and remove accounts.",
474+
Args: cobra.NoArgs,
475+
RunE: func(cmd *cobra.Command, _ []string) error {
476+
var services []googleauth.Service
477+
if strings.EqualFold(strings.TrimSpace(servicesCSV), "") || strings.EqualFold(strings.TrimSpace(servicesCSV), "all") {
478+
services = googleauth.AllServices()
479+
} else {
480+
parts := strings.Split(servicesCSV, ",")
481+
seen := make(map[googleauth.Service]struct{})
482+
for _, p := range parts {
483+
svc, err := googleauth.ParseService(p)
484+
if err != nil {
485+
return err
486+
}
487+
if _, ok := seen[svc]; ok {
488+
continue
489+
}
490+
seen[svc] = struct{}{}
491+
services = append(services, svc)
492+
}
493+
}
494+
495+
return googleauth.StartManageServer(cmd.Context(), googleauth.ManageServerOptions{
496+
Timeout: timeout,
497+
Services: services,
498+
ForceConsent: forceConsent,
499+
})
500+
},
501+
}
502+
503+
cmd.Flags().BoolVar(&forceConsent, "force-consent", false, "Force consent screen when adding accounts")
504+
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts,sheets")
505+
cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Minute, "Server timeout duration")
506+
return cmd
507+
}

internal/cmd/auth_cmd_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ func (s *memSecretsStore) ListTokens() ([]secrets.Token, error) {
7979
return out, nil
8080
}
8181

82+
func (s *memSecretsStore) GetDefaultAccount() (string, error) {
83+
return "", nil
84+
}
85+
86+
func (s *memSecretsStore) SetDefaultAccount(email string) error {
87+
return nil
88+
}
89+
8290
func TestAuthTokens_ExportImportRoundtrip_JSON(t *testing.T) {
8391
origOpen := openSecretsStore
8492
t.Cleanup(func() { openSecretsStore = origOpen })

internal/cmd/calendar_respond.go

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,21 @@ import (
1313

1414
func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
1515
var status string
16-
var sendUpdates string
16+
var comment string
1717

1818
cmd := &cobra.Command{
1919
Use: "respond <calendarId> <eventId>",
20-
Short: "Respond to a meeting invitation (accept/decline/tentative)",
21-
Args: cobra.ExactArgs(2),
20+
Short: "Respond to a calendar event invitation",
21+
Long: `Respond to a calendar event invitation with accepted, declined, tentative, or needsAction.
22+
23+
Status values:
24+
- accepted: Accept the invitation
25+
- declined: Decline the invitation
26+
- tentative: Mark as tentative (maybe)
27+
- needsAction: Reset to needs action
28+
29+
You can optionally include a comment with your response.`,
30+
Args: cobra.ExactArgs(2),
2231
RunE: func(cmd *cobra.Command, args []string) error {
2332
u := ui.FromContext(cmd.Context())
2433
account, err := requireAccount(flags)
@@ -28,52 +37,61 @@ func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
2837
calendarID := args[0]
2938
eventID := args[1]
3039

40+
// Validate status
3141
status = strings.TrimSpace(status)
32-
switch status {
33-
case "accepted", "declined", "tentative":
34-
default:
35-
return fmt.Errorf("invalid --status: %q (expected accepted|declined|tentative)", status)
42+
validStatuses := []string{"accepted", "declined", "tentative", "needsAction"}
43+
isValid := false
44+
for _, v := range validStatuses {
45+
if status == v {
46+
isValid = true
47+
break
48+
}
3649
}
37-
38-
sendUpdates = strings.TrimSpace(sendUpdates)
39-
switch sendUpdates {
40-
case "all", "none", "externalOnly":
41-
default:
42-
return fmt.Errorf("invalid --send-updates: %q (expected all|none|externalOnly)", sendUpdates)
50+
if !isValid {
51+
return fmt.Errorf("invalid status %q; must be one of: %s", status, strings.Join(validStatuses, ", "))
4352
}
4453

4554
svc, err := newCalendarService(cmd.Context(), account)
4655
if err != nil {
4756
return err
4857
}
4958

50-
e, err := svc.Events.Get(calendarID, eventID).Do()
59+
// Get the event
60+
event, err := svc.Events.Get(calendarID, eventID).Do()
5161
if err != nil {
5262
return err
5363
}
54-
if e == nil || len(e.Attendees) == 0 {
64+
65+
// Find the authenticated user in attendees
66+
if len(event.Attendees) == 0 {
5567
return errors.New("event has no attendees")
5668
}
5769

58-
updatedAny := false
59-
for _, a := range e.Attendees {
60-
if a == nil {
61-
continue
62-
}
63-
if a.Self || strings.EqualFold(a.Email, account) {
64-
a.ResponseStatus = status
65-
updatedAny = true
70+
var selfAttendee *int
71+
for i, a := range event.Attendees {
72+
if a.Self {
73+
selfAttendee = &i
74+
break
6675
}
6776
}
68-
if !updatedAny {
69-
return errors.New("no attendee matches the authenticated user")
77+
78+
if selfAttendee == nil {
79+
return errors.New("you are not an attendee of this event")
80+
}
81+
82+
// Check if user is the organizer
83+
if event.Attendees[*selfAttendee].Organizer {
84+
return errors.New("cannot respond to your own event (you are the organizer)")
7085
}
7186

72-
call := svc.Events.Update(calendarID, eventID, e)
73-
if sendUpdates != "none" {
74-
call = call.SendUpdates(sendUpdates)
87+
// Update the attendee's response status
88+
event.Attendees[*selfAttendee].ResponseStatus = status
89+
if strings.TrimSpace(comment) != "" {
90+
event.Attendees[*selfAttendee].Comment = comment
7591
}
76-
updated, err := call.Do()
92+
93+
// Patch the event with updated attendees
94+
updated, err := svc.Events.Patch(calendarID, eventID, event).Do()
7795
if err != nil {
7896
return err
7997
}
@@ -83,17 +101,21 @@ func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
83101
}
84102

85103
u.Out().Printf("id\t%s", updated.Id)
86-
u.Out().Printf("status\t%s", status)
87-
u.Out().Printf("send_updates\t%s", sendUpdates)
104+
u.Out().Printf("summary\t%s", orEmpty(updated.Summary, "(no title)"))
105+
u.Out().Printf("response_status\t%s", status)
106+
if strings.TrimSpace(comment) != "" {
107+
u.Out().Printf("comment\t%s", comment)
108+
}
88109
if updated.HtmlLink != "" {
89110
u.Out().Printf("link\t%s", updated.HtmlLink)
90111
}
91112
return nil
92113
},
93114
}
94115

95-
cmd.Flags().StringVar(&status, "status", "", "Response status: accepted|declined|tentative (required)")
116+
cmd.Flags().StringVar(&status, "status", "", "Response status (accepted, declined, tentative, needsAction) (required)")
117+
cmd.Flags().StringVar(&comment, "comment", "", "Optional comment/note to include with response")
96118
_ = cmd.MarkFlagRequired("status")
97-
cmd.Flags().StringVar(&sendUpdates, "send-updates", "none", "Send updates: all|none|externalOnly (default: none)")
119+
98120
return cmd
99121
}

0 commit comments

Comments
 (0)