fix(docs): harden tab export landing

Co-authored-by: Ben Lewis <johnbenjaminlewis@gmail.com>
This commit is contained in:
Peter Steinberger 2026-04-28 06:30:38 +01:00
parent 26e01cfa97
commit 1763b3905f
No known key found for this signature in database
6 changed files with 95 additions and 5 deletions

View File

@ -12,6 +12,7 @@
- Backup: push Gmail checkpoint commits through a single ordered background queue so cached fetches continue while GitHub uploads run.
- Calendar: add `--start-timezone` / `--end-timezone` to `calendar create` and `calendar update` for preserving named IANA event timezones when RFC3339 inputs only carry numeric offsets. (#422)
- Drive: add `drive search --drive` and `--parent` for scoping search to a shared drive or folder. (#525) — thanks @LeanSheng.
- Docs: add experimental `docs export --tab` / `drive download --tab` to export a single Google Docs tab as PDF, DOCX, text, Markdown, or HTML. (#535) — thanks @johnbenjaminlewis.
- Gmail: add `gmail messages search --body-format html` for returning HTML message bodies when `--include-body` is used. (#520) — thanks @alexknowshtml.
- Slides: add `slides insert-text` and `slides replace-text` for editing existing slide text elements and replacing template tokens. (#521) — thanks @chrissanchez-iops.
- Slides docs: document the Markdown structure accepted by `slides create-from-markdown`. (#497)

View File

@ -1055,6 +1055,7 @@ gog docs create "My Doc" --file ./doc.md # Import markdown
gog docs create "My Doc" --pageless
gog docs copy <docId> "My Doc Copy"
gog docs export <docId> --format pdf --out ./doc.pdf
gog docs export <docId> --tab "Notes" --format pdf --out ./notes.pdf
gog docs export <docId> --format txt --out - > doc.txt
gog docs list-tabs <docId>
gog docs cat <docId> --tab "Notes"
@ -1441,6 +1442,7 @@ gog docs export <docId> --format txt --out ./doc.txt
gog docs export <docId> --format md --out ./doc.md
gog docs export <docId> --format html --out ./doc.html
gog docs export <docId> --format txt --out - > doc.txt
gog docs export <docId> --tab "Notes" --format md --out ./notes.md
# Sed-style regex editing with Markdown formatting (sedmat)
gog docs sed <docId> 's/pattern/replacement/g'

View File

@ -215,7 +215,7 @@ Generated from `gog schema --json`.
- `gog docs (doc) create (add,new) <title> [flags]` - Create a Google Doc
- `gog docs (doc) delete --start=INT-64 --end=INT-64 <docId> [flags]` - Delete text range from document
- `gog docs (doc) edit <docId> <find> <replace> [flags]` - Find and replace text in a Google Doc
- `gog docs (doc) export (download,dl) <docId> [flags]` - Export a Google Doc (pdf|docx|txt|md)
- `gog docs (doc) export (download,dl) <docId> [flags]` - Export a Google Doc (pdf|docx|txt|md|html)
- `gog docs (doc) find-replace <docId> <find> [<replace>] [flags]` - Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence.
- `gog docs (doc) info (get,show) <docId>` - Get Google Doc metadata
- `gog docs (doc) insert <docId> [<content>] [flags]` - Insert text at a specific position
@ -268,8 +268,8 @@ Generated from `gog schema --json`.
- `gog gmail (mail,email) archive [<messageId> ...] [flags]` - Archive messages (remove from inbox)
- `gog gmail (mail,email) attachment <messageId> <attachmentId> [flags]` - Download a single attachment
- `gog gmail (mail,email) autoreply <query> ... [flags]` - Reply once to matching messages
- `gog gmail (mail,email) batch <command>` - Batch operations
- `gog gmail (mail,email) batch delete (rm,del,remove) <messageId> ...` - Permanently delete multiple messages
- `gog gmail (mail,email) batch <command>` - Batch operations (permanent delete requires broader Gmail scope; use gmail trash for normal trashing)
- `gog gmail (mail,email) batch delete (rm,del,remove) <messageId> ...` - Permanently delete multiple messages; use 'gmail trash' to move messages to trash with the default gmail.modify scope
- `gog gmail (mail,email) batch modify (update,edit,set) <messageId> ... [flags]` - Modify labels on multiple messages
- `gog gmail (mail,email) drafts (draft) <command>` - Draft operations
- `gog gmail (mail,email) drafts (draft) create (add,new) [flags]` - Create a draft

View File

@ -20,6 +20,8 @@ Each service command is a thin wrapper:
- `gog slides export <presentationId> --format pdf|pptx`
- `gog sheets export <spreadsheetId> --format pdf|xlsx|csv`
Exception: `gog docs export --tab <title-or-id>` exports a single Google Docs tab through the undocumented Docs web export endpoint because Drive `files.export` only exports the whole document. Keep it marked experimental, preserve `--out -` raw-byte semantics, and reject auth redirects to Google sign-in hosts before writing bytes.
## Conventions
- Arg is always the Drive file id (Doc/Sheet/Slides id).

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
@ -42,6 +43,9 @@ func googleExportRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return errors.New("too many redirects")
}
if len(via) > 0 && isGoogleAuthHost(req.URL.Host) {
return fmt.Errorf("refusing redirect from %s to Google sign-in host %s (try re-authenticating with 'gog auth login')", via[0].URL.Host, req.URL.Host)
}
if len(via) > 0 && !isGoogleHost(req.URL.Host) {
return fmt.Errorf("refusing redirect from %s to non-Google host %s (possible auth redirect; try re-authenticating)", via[0].URL.Host, req.URL.Host)
}
@ -49,6 +53,7 @@ func googleExportRedirectPolicy(req *http.Request, via []*http.Request) error {
}
func isGoogleHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(host, "."))
for _, suffix := range []string{".google.com", ".googleusercontent.com", ".googleapis.com"} {
if host == suffix[1:] || strings.HasSuffix(host, suffix) {
return true
@ -57,6 +62,11 @@ func isGoogleHost(host string) bool {
return false
}
func isGoogleAuthHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(host, "."))
return host == "accounts.google.com" || host == "myaccount.google.com"
}
type tabExportParams struct {
DocID string
OutFlag string
@ -76,6 +86,7 @@ func sanitizeFilenameComponent(s string) string {
const formatHTML = "html"
func tabExportFormatParam(format string) (string, error) {
format = strings.ToLower(strings.TrimSpace(format))
switch format {
case "pdf", "docx", "txt", formatHTML:
return format, nil
@ -153,6 +164,7 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
if format == "" || format == formatAuto {
format = "pdf"
}
format = strings.ToLower(strings.TrimSpace(format))
formatParam, fmtErr := tabExportFormatParam(format)
if fmtErr != nil {
@ -163,6 +175,9 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
if pathErr != nil {
return pathErr
}
if outfmt.IsJSON(ctx) && isStdoutPath(outPath) {
return usage("can't combine --json with --out -")
}
if dryErr := dryRunExit(ctx, flags, "docs.tab-export", map[string]any{
"docID": p.DocID,
@ -208,6 +223,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
return checkErr
}
if isStdoutPath(outPath) {
_, copyErr := io.Copy(os.Stdout, resp.Body)
return copyErr
}
f, outPath, writeErr := createUserOutputFile(outPath)
if writeErr != nil {
return writeErr
@ -229,7 +249,11 @@ func runDocsTabExport(ctx context.Context, flags *RootFlags, p tabExportParams)
func checkTabExportResponse(resp *http.Response, format string) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if ct := resp.Header.Get("Content-Type"); format != formatHTML && strings.HasPrefix(ct, "text/html") {
mediaType := strings.ToLower(resp.Header.Get("Content-Type"))
if parsed, _, err := mime.ParseMediaType(mediaType); err == nil {
mediaType = parsed
}
if format != formatHTML && mediaType == "text/html" {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("tab export returned unexpected text/html (possible auth redirect; try 'gog auth login'): %s", strings.TrimSpace(string(snippet)))
}

View File

@ -79,6 +79,7 @@ func TestTabExportFormatParam(t *testing.T) {
wantErr bool
}{
{"pdf", "pdf", false},
{"PDF", "pdf", false},
{"docx", "docx", false},
{"txt", "txt", false},
{"md", "markdown", false},
@ -114,6 +115,7 @@ func TestGoogleExportRedirectPolicy(t *testing.T) {
{"same host", "https://docs.google.com/other", ""},
{"googleusercontent", "https://doc-04-0k-docstext.googleusercontent.com/export/abc", ""},
{"googleapis", "https://storage.googleapis.com/bucket/file", ""},
{"google sign-in", "https://accounts.google.com/v3/signin/identifier", "Google sign-in host"},
{"non-google host", "https://evil.example.com/steal", "non-Google host"},
}
for _, tt := range tests {
@ -274,7 +276,7 @@ func TestRunDocsTabExport(t *testing.T) {
func TestRunDocsTabExport_HTMLRedirectGuard(t *testing.T) {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Type", "Text/HTML; charset=utf-8")
_, _ = w.Write([]byte("<html>Sign in</html>"))
}))
@ -290,6 +292,65 @@ func TestRunDocsTabExport_HTMLRedirectGuard(t *testing.T) {
}
}
func TestRunDocsTabExport_OutStdout(t *testing.T) {
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("tab text\n"))
}))
t.Chdir(t.TempDir())
ctx := newDocsCmdContext(t)
stdout := captureStdout(t, func() {
_ = captureStderr(t, func() {
err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, tabExportParams{
DocID: "doc1",
OutFlag: "-",
Format: "txt",
TabQuery: "First Tab",
})
if err != nil {
t.Fatalf("runDocsTabExport: %v", err)
}
})
})
if stdout != "tab text\n" {
t.Fatalf("stdout=%q, want raw export bytes", stdout)
}
if _, statErr := os.Stat("-"); !os.IsNotExist(statErr) {
t.Fatalf("expected no file named -, stat=%v", statErr)
}
}
func TestRunDocsTabExport_OutStdoutJSONRejected(t *testing.T) {
called := false
stubTabExportDeps(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("tab text\n"))
}))
ctx := newDocsJSONContext(t)
stdout := captureStdout(t, func() {
_ = captureStderr(t, func() {
err := runDocsTabExport(ctx, &RootFlags{Account: "test@example.com"}, tabExportParams{
DocID: "doc1",
OutFlag: "-",
Format: "txt",
TabQuery: "First Tab",
})
if err == nil || !strings.Contains(err.Error(), "can't combine --json with --out -") {
t.Fatalf("unexpected error: %v", err)
}
})
})
if stdout != "" {
t.Fatalf("stdout=%q, want empty", stdout)
}
if called {
t.Fatal("export request should not be called")
}
}
func TestRunDocsTabExport_HTTPErrors(t *testing.T) {
tests := []struct {
name string