fix(docs): harden tab export landing
Co-authored-by: Ben Lewis <johnbenjaminlewis@gmail.com>
This commit is contained in:
parent
26e01cfa97
commit
1763b3905f
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user