diff --git a/README.md b/README.md index 9c1e4449..4a5b4244 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,9 @@ cfl search --cql "space = DEV AND type = page" # Copy a page cfl page copy 123456 --title "Copy of Page" +# Export a page to PDF +cfl page export 123456 -O handoff.pdf + # Spaces cfl space list cfl space view DEV diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index eda591c0..04f40e53 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -61,6 +61,23 @@ cfl [resource] [action] [ID] [flags] | `cfl page copy PAGE_ID --title "Copy" --no-labels` | Copy without labels | | `cfl page delete PAGE_ID` | Delete page (with confirmation) | | `cfl page delete PAGE_ID --force` | Delete without confirmation | +| `cfl page export PAGE_ID` | Export page as PDF (filename from the page title) | +| `cfl page export PAGE_ID -O handoff.pdf` | Export to a specific file | +| `cfl page export PAGE_ID --force` | Overwrite existing file without warning | +| `cfl page export PAGE_ID --timeout 10m` | Allow longer for a large page to render | + +### Page Export Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--format` | | `pdf` | Export format; `pdf` is the only value | +| `--output-file` | `-O` | (from page title) | Output file path | +| `--force` | `-f` | `false` | Overwrite existing file without warning | +| `--timeout` | | `5m` | How long to wait for Confluence to render | + +Confluence renders the document server-side, so the command starts an export +task, polls it, and downloads the result once it finishes. Progress is written +to stderr; stdout carries only the success block. ### Create/Edit Flags diff --git a/skills/Confluence/Workflows/ManagePage.md b/skills/Confluence/Workflows/ManagePage.md index dea3bd91..28b54606 100644 --- a/skills/Confluence/Workflows/ManagePage.md +++ b/skills/Confluence/Workflows/ManagePage.md @@ -14,6 +14,7 @@ Create, edit, copy, move, and delete Confluence pages. | "move page", "reparent" | Move | `cfl page edit PAGE_ID --parent NEW_PARENT_ID` | | "copy page", "duplicate" | Copy | `cfl page copy PAGE_ID --title "Copy Title"` | | "delete page", "remove page" | Delete | `cfl page delete PAGE_ID` | +| "export page", "save as PDF", "send it as a PDF" | Export | `cfl page export PAGE_ID` | ### Content Source Mapping (for create/edit) @@ -131,6 +132,34 @@ Or do it as two explicit steps (capture ID from the table output, then edit). cfl page delete PAGE_ID ``` +### Export Page to PDF + +Use this when a page has to leave Confluence as a document — an attachment to +send to someone outside the site, for instance. + +```bash +# Filename comes from the page title +cfl page export PAGE_ID + +# Name the file yourself +cfl page export PAGE_ID -O handoff.pdf + +# Overwrite an existing file +cfl page export PAGE_ID -O handoff.pdf --force + +# A large page can take longer to render +cfl page export PAGE_ID --timeout 10m +``` + +Confluence renders the PDF server-side, so the command waits on an export task +and reports completion on stderr while it does. Stdout carries only the success +block, so the path is scriptable: + +```bash +# A title-derived filename can contain spaces, so take the rest of the line +PDF=$(cfl page export PAGE_ID 2>/dev/null | sed -n 's/^Exported: //p') +``` + ## Post-Action After any action: @@ -138,4 +167,5 @@ After any action: 2. For edits: confirm what was changed (content, title, parent) 3. For copies: show the new page ID and location 4. For deletes: confirm which page was deleted +5. For exports: show the output path and size 5. For moves: confirm old and new parent diff --git a/tools/cfl/CHANGELOG.md b/tools/cfl/CHANGELOG.md index be1c606d..1f57cbb9 100644 --- a/tools/cfl/CHANGELOG.md +++ b/tools/cfl/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added +- `page export` command to export a page as a PDF, waiting on the server-side render and writing the result to a file ([#481](https://github.com/open-cli-collective/atlassian-cli/pull/481)) - `page edit --message`/`-m` sets the page version comment, which was always "Updated via cfl". Omitting the flag keeps that default; an empty value sends no comment. Thanks to @dmoruzzi for the feature. ([#473](https://github.com/open-cli-collective/atlassian-cli/pull/473)) - Service account support with bearer auth (`--auth-method bearer`) for scoped API tokens ([#171](https://github.com/open-cli-collective/atlassian-cli/pull/171)) - Wiki-link syntax `[[Page Title]]` and `[[SPACE:Page Title]]` for internal Confluence page links ([#129](https://github.com/open-cli-collective/atlassian-cli/pull/129)) diff --git a/tools/cfl/README.md b/tools/cfl/README.md index 70c56ea5..ee3854f1 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -8,6 +8,7 @@ A command-line interface for Atlassian Confluence Cloud, inspired by [jira-cli]( - **Markdown-first**: Write and view pages in markdown, auto-converted to/from Confluence format - List and browse spaces - Create, view, edit, copy, and delete pages +- Export a page to PDF - Inspect page history and view specific page versions - **Search content** using CQL (Confluence Query Language) - Upload, download, list, and delete attachments @@ -505,6 +506,39 @@ cfl page delete 12345 --force --- +### `cfl page export ` + +Export a page as a PDF. + +```bash +cfl page export 12345 # Filename from the page title +cfl page export 12345 -O handoff.pdf +cfl page export 12345 -O handoff.pdf --force +cfl page export 12345 --timeout 10m # Allow longer for a large page +``` + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--format` | | `pdf` | Export format; `pdf` is the only supported value | +| `--output-file` | `-O` | (from page title) | Output file path | +| `--force` | `-f` | `false` | Overwrite existing file without warning | +| `--timeout` | | `5m` | How long to wait for Confluence to render the export | + +**Arguments:** +- `` - The page ID (**required**) + +Confluence renders the PDF server-side, so the command starts an export task, +polls it until it finishes, and then downloads the result. Completion updates +go to stderr, leaving stdout to carry only the success block, so redirecting +stdout captures the two-line result and nothing else. + +Without `--output-file` the page title names the file. Titles are free text and +can carry path separators, so the name is reduced to a single file in the +working directory, falling back to the page ID when a title leaves nothing +usable. + +--- + ### `cfl search [query]` Search for pages, blog posts, attachments, and comments across Confluence. diff --git a/tools/cfl/api/export.go b/tools/cfl/api/export.go new file mode 100644 index 00000000..ade3de1b --- /dev/null +++ b/tools/cfl/api/export.go @@ -0,0 +1,293 @@ +package api //nolint:revive // package name is intentional + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + sharederrors "github.com/open-cli-collective/atlassian-go/errors" +) + +// Export task states reported by Confluence. A task that is neither +// finished nor failed reports some other value while it runs; only these +// two decide the outcome. +const ( + ExportStateSucceeded = "SUCCEEDED" + ExportStateFailed = "FAILED" +) + +// ErrExportTaskMissing reports that Confluence answered the export request +// without naming a task. The export cannot be polled, so there is nothing +// to wait for. +var ErrExportTaskMissing = errors.New("no export task in Confluence response") + +// ErrExportFailed reports that Confluence ran the export and it did not +// succeed. +var ErrExportFailed = errors.New("export failed on the Confluence side") + +// ErrExportNotPDF reports that the download returned something other than a +// PDF. A rejected credential is answered with a sign-in page carrying HTTP +// 200, so the status code alone does not establish that a document arrived. +var ErrExportNotPDF = errors.New("export download was not a PDF") + +// pdfMagic opens every PDF document. +const pdfMagic = "%PDF-" + +// PDFExport is a started export, identified by the task Confluence +// created for it. +type PDFExport struct { + // TaskID addresses the task in the progress endpoint. + TaskID string + // V3 selects both the progress endpoint and how Result is read once + // the task finishes. Confluence declares which generation served the + // request; it is not a choice this client makes. + V3 bool +} + +// PDFExportProgress is one reading of an export task. +type PDFExportProgress struct { + Progress int `json:"progress"` + State string `json:"state"` + // Result is empty until the task finishes. It is a download URL under + // V3 and a URL yielding one otherwise. + Result string `json:"result"` + // EstimatedTimeRemaining is milliseconds, as Confluence reports it. + EstimatedTimeRemaining int `json:"estimatedTimeRemaining"` + TimeElapsed int `json:"timeElapsed"` +} + +// Done reports that the task will not progress further. +func (p *PDFExportProgress) Done() bool { + return p.State == ExportStateSucceeded || p.Progress >= 100 +} + +// Failed reports that the task ended without producing a document. +func (p *PDFExportProgress) Failed() bool { + return p.State == ExportStateFailed +} + +var ( + metaTagRe = regexp.MustCompile(`(?i)]*>`) + metaNameRe = regexp.MustCompile(`(?i)\bname="ajs-([a-zA-Z0-9_.-]+)"`) + metaContentRe = regexp.MustCompile(`(?i)\bcontent="([^"]*)"`) +) + +// ajsMeta extracts the ajs-* metadata Confluence embeds in the page it +// returns. Attribute order is the server's to choose, so name and content +// are read independently rather than as one fixed pattern. +func ajsMeta(html string) map[string]string { + meta := make(map[string]string) + for _, tag := range metaTagRe.FindAllString(html, -1) { + name := metaNameRe.FindStringSubmatch(tag) + if name == nil { + continue + } + content := metaContentRe.FindStringSubmatch(tag) + if content == nil { + continue + } + meta[name[1]] = content[1] + } + return meta +} + +// StartPDFExport asks Confluence to render a page as PDF and returns the +// task it created. +// +// Confluence Cloud exposes no JSON endpoint that starts this task. The +// browser navigates to an action that both starts the export and returns a +// progress page, and the task identifier is only ever published as metadata +// inside that page, so the identifier is read from the returned HTML. The +// XSRF header is what separates an accepted request from a 403. +func (c *Client) StartPDFExport(ctx context.Context, pageID string) (*PDFExport, error) { + path := fmt.Sprintf("/spaces/flyingpdf/pdfpageexport.action?pageId=%s", url.QueryEscape(pageID)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.GetBaseURL()+path, nil) + if err != nil { + return nil, fmt.Errorf("creating export request: %w", err) + } + req.Header.Set("Authorization", c.GetAuthHeader()) + req.Header.Set("X-Atlassian-Token", "no-check") + + resp, err := c.GetHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("starting export: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading export response: %w", err) + } + if resp.StatusCode >= http.StatusBadRequest { + return nil, fmt.Errorf("starting export of page %s: %w", pageID, sharederrors.ParseAPIError(resp.StatusCode, body)) + } + + meta := ajsMeta(string(body)) + taskID := meta["taskId"] + if taskID == "" { + return nil, ErrExportTaskMissing + } + + return &PDFExport{TaskID: taskID, V3: meta["isV3"] == "true"}, nil +} + +// progressPath addresses the task in whichever progress endpoint served +// the export. +func (e *PDFExport) progressPath() string { + if e.V3 { + return "/api/v2/pdfexporttask/progress/" + e.TaskID + } + return "/services/api/v1/task/" + e.TaskID + "/progress" +} + +// GetPDFExportProgress reads the current state of an export task. +func (c *Client) GetPDFExportProgress(ctx context.Context, export *PDFExport) (*PDFExportProgress, error) { + body, err := c.Get(ctx, export.progressPath()) + if err != nil { + return nil, fmt.Errorf("getting export progress: %w", err) + } + + var progress PDFExportProgress + if err := json.Unmarshal(body, &progress); err != nil { + return nil, fmt.Errorf("parsing export progress response: %w", err) + } + + return &progress, nil +} + +// OpenPDFExport opens the finished document for reading. +// +// Under V3 the result is already the document URL. Otherwise it addresses a +// resource whose body is the URL to fetch, which is why the indirection is +// resolved here rather than by the caller. +func (c *Client) OpenPDFExport(ctx context.Context, export *PDFExport, progress *PDFExportProgress) (io.ReadCloser, error) { + if progress.Result == "" { + return nil, errors.New("export finished without a download URL") + } + + downloadURL := progress.Result + if !export.V3 { + resolved, err := c.resolveExportDownloadURL(ctx, downloadURL) + if err != nil { + return nil, err + } + downloadURL = resolved + } + + resp, err := c.getExportResource(ctx, downloadURL) + if err != nil { + return nil, fmt.Errorf("downloading export: %w", err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("downloading export: status %d", resp.StatusCode) + } + + return pdfBody(resp.Body) +} + +// pdfBody returns the response body only once it starts like a PDF, so a +// sign-in page served with HTTP 200 is not written out as a document. The +// inspected bytes stay in the stream. +func pdfBody(body io.ReadCloser) (io.ReadCloser, error) { + buffered := bufio.NewReader(body) + + magic, err := buffered.Peek(len(pdfMagic)) + if err != nil && !errors.Is(err, io.EOF) { + _ = body.Close() + return nil, fmt.Errorf("reading export download: %w", err) + } + if string(magic) != pdfMagic { + _ = body.Close() + return nil, ErrExportNotPDF + } + + return readCloser{Reader: buffered, Closer: body}, nil +} + +// readCloser reads from the buffered view while closing the underlying +// response body. +type readCloser struct { + io.Reader + io.Closer +} + +// resolveExportDownloadURL follows the pre-V3 indirection, where the task +// result names a resource whose body is the document URL. +func (c *Client) resolveExportDownloadURL(ctx context.Context, resultURL string) (string, error) { + resp, err := c.getExportResource(ctx, resultURL) + if err != nil { + return "", fmt.Errorf("resolving export download URL: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading export download URL: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("resolving export download URL: status %d", resp.StatusCode) + } + + resolved := strings.TrimSpace(strings.Trim(strings.TrimSpace(string(body)), `"`)) + if resolved == "" { + return "", errors.New("export download URL was empty") + } + + return resolved, nil +} + +// getExportResource fetches a URL named by the export task. +// +// A task names its result either as a path on the site or as an absolute +// URL on the media host, and the two differ in what they accept: the media +// URL is signed and carries its own access, whereas a path on the site +// needs the caller's credential. Sending the credential is therefore +// decided by where the URL points, which also keeps it off any host it does +// not belong to. +func (c *Client) getExportResource(ctx context.Context, rawURL string) (*http.Response, error) { + target, onSite, err := c.resolveExportURL(rawURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, fmt.Errorf("creating download request: %w", err) + } + if onSite { + req.Header.Set("Authorization", c.GetAuthHeader()) + } + + resp, err := c.GetHTTPClient().Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} + +// resolveExportURL expands a task result against the configured site and +// reports whether it addresses that site. +func (c *Client) resolveExportURL(rawURL string) (string, bool, error) { + base, err := url.Parse(c.GetBaseURL()) + if err != nil { + return "", false, fmt.Errorf("parsing base URL: %w", err) + } + ref, err := url.Parse(rawURL) + if err != nil { + return "", false, fmt.Errorf("parsing export URL: %w", err) + } + + resolved := base.ResolveReference(ref) + + return resolved.String(), base.Host != "" && strings.EqualFold(base.Host, resolved.Host), nil +} diff --git a/tools/cfl/api/export_test.go b/tools/cfl/api/export_test.go new file mode 100644 index 00000000..51c02239 --- /dev/null +++ b/tools/cfl/api/export_test.go @@ -0,0 +1,302 @@ +package api //nolint:revive // package name is intentional + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + sharederrors "github.com/open-cli-collective/atlassian-go/errors" + "github.com/open-cli-collective/atlassian-go/testutil" +) + +const exportTaskID = "module-11111111-2222-3333-4444-555555555555" + +func TestClient_StartPDFExport(t *testing.T) { + t.Parallel() + fixture := loadTestData(t, "pdf_export_start.html") + + var gotAuth, gotToken, gotPath, gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotToken = r.Header.Get("X-Atlassian-Token") + gotPath = r.URL.Path + gotQuery = r.URL.Query().Get("pageId") + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + export, err := client.StartPDFExport(context.Background(), "123456") + + testutil.RequireNoError(t, err) + testutil.Equal(t, exportTaskID, export.TaskID) + testutil.True(t, export.V3, "fixture declares isV3") + testutil.Equal(t, "/spaces/flyingpdf/pdfpageexport.action", gotPath) + testutil.Equal(t, "123456", gotQuery) + testutil.NotEmpty(t, gotAuth) + // Without the XSRF header Confluence refuses the request with 403. + testutil.Equal(t, "no-check", gotToken) +} + +// TestClient_StartPDFExport_ErrorStatus pins that the start leg reports +// failures with the same typed errors as the rest of the client, so callers +// can tell a missing page from a refused one without matching message text. +func TestClient_StartPDFExport_ErrorStatus(t *testing.T) { + t.Parallel() + tests := []struct { + name string + status int + fixture string + want error + }{ + {"not found", http.StatusNotFound, "pdf_export_start_not_found.html", sharederrors.ErrNotFound}, + {"forbidden", http.StatusForbidden, "pdf_export_start_forbidden.html", sharederrors.ErrForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := loadTestData(t, tt.fixture) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.WriteHeader(tt.status) + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + _, err := client.StartPDFExport(context.Background(), "999999999") + + testutil.RequireError(t, err) + testutil.True(t, errors.Is(err, tt.want), "want "+tt.want.Error()+", got "+err.Error()) + testutil.ErrorContains(t, err, "999999999") + }) + } +} + +func TestClient_StartPDFExport_NoTaskID(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + _, err := client.StartPDFExport(context.Background(), "123456") + + testutil.RequireError(t, err) + testutil.True(t, errors.Is(err, ErrExportTaskMissing), "want ErrExportTaskMissing, got "+err.Error()) +} + +// TestAJSMeta_AttributeOrder pins that metadata is read regardless of the +// order the server writes the attributes in. +func TestAJSMeta_AttributeOrder(t *testing.T) { + t.Parallel() + tests := []struct { + name string + html string + }{ + {"name first", ``}, + {"content first", ``}, + {"extra attributes", ``}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + testutil.Equal(t, "task-1", ajsMeta(tt.html)["taskId"]) + }) + } +} + +func TestPDFExport_ProgressPath(t *testing.T) { + t.Parallel() + v3 := &PDFExport{TaskID: "task-1", V3: true} + testutil.Equal(t, "/api/v2/pdfexporttask/progress/task-1", v3.progressPath()) + + legacy := &PDFExport{TaskID: "task-1"} + testutil.Equal(t, "/services/api/v1/task/task-1/progress", legacy.progressPath()) +} + +func TestClient_GetPDFExportProgress(t *testing.T) { + t.Parallel() + running := loadTestData(t, "pdf_export_progress_running.json") + succeeded := loadTestData(t, "pdf_export_progress_succeeded.json") + + tests := []struct { + name string + fixture []byte + wantDone bool + wantState string + }{ + {"running", running, false, "IN_PROGRESS"}, + {"succeeded", succeeded, true, ExportStateSucceeded}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, "/api/v2/pdfexporttask/progress/"+exportTaskID, r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(tt.fixture) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + progress, err := client.GetPDFExportProgress(context.Background(), &PDFExport{TaskID: exportTaskID, V3: true}) + + testutil.RequireNoError(t, err) + testutil.Equal(t, tt.wantState, progress.State) + testutil.Equal(t, tt.wantDone, progress.Done()) + testutil.False(t, progress.Failed()) + }) + } +} + +func TestPDFExportProgress_Failed(t *testing.T) { + t.Parallel() + progress := &PDFExportProgress{State: ExportStateFailed, Progress: 40} + testutil.True(t, progress.Failed(), "FAILED state") + testutil.False(t, progress.Done()) +} + +func TestClient_OpenPDFExport_SignedURLGetsNoCredential(t *testing.T) { + t.Parallel() + // The media host stands in for the signed URL Confluence hands back. + var mediaAuth string + var mediaCalled bool + media := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaCalled = true + mediaAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("%PDF-1.4\nbody")) + })) + defer media.Close() + + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("the site must not be called for an absolute result URL") + w.WriteHeader(http.StatusInternalServerError) + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + reader, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + &PDFExportProgress{Result: media.URL + "/file/abc/binary?token=signed"}, + ) + testutil.RequireNoError(t, err) + defer func() { _ = reader.Close() }() + + content, err := io.ReadAll(reader) + testutil.RequireNoError(t, err) + testutil.Equal(t, "%PDF-1.4\nbody", string(content)) + testutil.True(t, mediaCalled, "media host was called") + testutil.Equal(t, "", mediaAuth) +} + +func TestClient_OpenPDFExport_SiteURLGetsCredential(t *testing.T) { + t.Parallel() + var gotAuth string + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, "/download/export/page.pdf", r.URL.Path) + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("%PDF-1.4\nbody")) + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + reader, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + // A result naming a path on the site rather than an absolute URL. + &PDFExportProgress{Result: "/download/export/page.pdf"}, + ) + testutil.RequireNoError(t, err) + defer func() { _ = reader.Close() }() + + _, err = io.ReadAll(reader) + testutil.RequireNoError(t, err) + testutil.NotEmpty(t, gotAuth) +} + +// TestClient_OpenPDFExport_RejectsNonPDF pins the failure that a status +// code cannot catch: a refused credential is answered with a sign-in page +// carrying HTTP 200, which would otherwise be written out as the document. +func TestClient_OpenPDFExport_RejectsNonPDF(t *testing.T) { + t.Parallel() + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Log in to continue")) + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + _, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + &PDFExportProgress{Result: "/download/export/page.pdf"}, + ) + + testutil.RequireError(t, err) + testutil.True(t, errors.Is(err, ErrExportNotPDF), "want ErrExportNotPDF, got "+err.Error()) +} + +func TestClient_OpenPDFExport_NoResult(t *testing.T) { + t.Parallel() + client := NewClient("https://example.atlassian.net/wiki", "user@example.com", "token") + _, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + &PDFExportProgress{Progress: 100, State: ExportStateSucceeded}, + ) + + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "download URL") +} + +// TestClient_OpenPDFExport_LegacyIndirection covers the pre-V3 shape, where +// the task result addresses a resource whose body is the document URL. +func TestClient_OpenPDFExport_LegacyIndirection(t *testing.T) { + t.Parallel() + var documentServed bool + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/download/link": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("/download/export/page.pdf\n")) + case "/download/export/page.pdf": + documentServed = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("%PDF-1.4\nbody")) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + reader, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID}, + &PDFExportProgress{Result: "/download/link"}, + ) + testutil.RequireNoError(t, err) + defer func() { _ = reader.Close() }() + + content, err := io.ReadAll(reader) + testutil.RequireNoError(t, err) + testutil.True(t, documentServed, "document was fetched through the indirection") + testutil.True(t, strings.HasPrefix(string(content), pdfMagic), "content is a PDF") +} diff --git a/tools/cfl/api/testdata/pdf_export_progress_running.json b/tools/cfl/api/testdata/pdf_export_progress_running.json new file mode 100644 index 00000000..74a50f7f --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_progress_running.json @@ -0,0 +1,7 @@ +{ + "progress": 0, + "state": "IN_PROGRESS", + "result": "", + "estimatedTimeRemaining": 30000, + "timeElapsed": 4 +} diff --git a/tools/cfl/api/testdata/pdf_export_progress_succeeded.json b/tools/cfl/api/testdata/pdf_export_progress_succeeded.json new file mode 100644 index 00000000..e5fc6263 --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_progress_succeeded.json @@ -0,0 +1,7 @@ +{ + "progress": 100, + "state": "SUCCEEDED", + "result": "https://api.media.example.com/file/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/binary?token=signed-token&client=example-client&name=Quarterly%20Handoff.pdf", + "estimatedTimeRemaining": 30000, + "timeElapsed": 35 +} diff --git a/tools/cfl/api/testdata/pdf_export_start.html b/tools/cfl/api/testdata/pdf_export_start.html new file mode 100644 index 00000000..3a877c89 --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_start.html @@ -0,0 +1,37 @@ + + + + Export to PDF + + + + + + + + + + + +
+

Exporting to PDF

+
+ 0% complete + + +
+ + + diff --git a/tools/cfl/api/testdata/pdf_export_start_forbidden.html b/tools/cfl/api/testdata/pdf_export_start_forbidden.html new file mode 100644 index 00000000..bd5157a2 --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_start_forbidden.html @@ -0,0 +1,10 @@ + + + + Forbidden - Confluence + + +

XSRF Security Token Missing

+

This action could not be completed due to a missing form token.

+ + diff --git a/tools/cfl/api/testdata/pdf_export_start_not_found.html b/tools/cfl/api/testdata/pdf_export_start_not_found.html new file mode 100644 index 00000000..08a483d8 --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_start_not_found.html @@ -0,0 +1,10 @@ + + + + Page Not Found - Confluence + + +

Page Not Found

+

The page you were trying to reach could not be found. This could be because the page does not exist, or you do not have permission to view it.

+ + diff --git a/tools/cfl/integration-tests.md b/tools/cfl/integration-tests.md index f83bef89..f848a700 100644 --- a/tools/cfl/integration-tests.md +++ b/tools/cfl/integration-tests.md @@ -142,6 +142,38 @@ All cfl commands should work with both auth methods (no scope limitations for Co --- +### page export + +Confluence Cloud publishes no REST endpoint for PDF export, so this command +drives the same undocumented flow the Confluence UI uses: an action that starts +a server-side render, a progress endpoint, and a download. Atlassian has moved +the progress endpoint before without notice, so these cases are the tripwire for +that happening again, and they are worth re-running after any Confluence Cloud +change that breaks export in the browser. + +| Test Case | Command | Expected Result | +|-----------|---------|-----------------| +| Default filename | `cfl page export ` | Writes `.pdf`; `Exported:` and `Size:` on stdout | +| File is a real PDF | `file '.pdf'` | Reports a PDF document, not HTML | +| Progress is visible | `cfl page export -O out.pdf` | `Exporting: N% complete` on stderr while it renders | +| Stdout is clean | `cfl page export -O out.pdf 2>/dev/null` | Only the `Exported:`/`Size:` block, no progress lines | +| Custom output path | `cfl page export -O handoff.pdf` | Writes `handoff.pdf` | +| Existing file refused | `cfl page export -O handoff.pdf` again | Error: file already exists, suggests `--force` | +| Overwrite | `cfl page export -O handoff.pdf --force` | File replaced | +| Invalid format | `cfl page export --format docx` | Error: invalid export format, valid formats: pdf | +| Invalid timeout | `cfl page export --timeout 0` | Error: invalid `--timeout`, must be greater than zero | +| Non-existent page | `cfl page export 99999999999` | Error: `getting page: resource not found` | +| Non-existent page, explicit path | `cfl page export 99999999999 -O missing.pdf` | Error: `starting export of page 99999999999: resource not found`; no `missing.pdf` written | +| Title needing sanitizing | Export a page whose title contains `/` or `:` | Writes one file in the working directory, separators replaced | +| Large page | `cfl page export --timeout 10m` | Completes, or fails naming `--timeout` rather than hanging | + +**Bearer auth is unverified for this command.** The export is driven through a +Confluence web action rather than a REST endpoint, and whether the +`api.atlassian.com` gateway proxies that action has not been established. Run +the default-filename case under bearer auth and record the result here. + +--- + ## Attachment Operations ### attachment list diff --git a/tools/cfl/internal/cmd/OUTPUT_SPEC.md b/tools/cfl/internal/cmd/OUTPUT_SPEC.md index d73bd1f3..b0543964 100644 --- a/tools/cfl/internal/cmd/OUTPUT_SPEC.md +++ b/tools/cfl/internal/cmd/OUTPUT_SPEC.md @@ -380,6 +380,35 @@ Success: Deleted page: (ID: <id>) ``` +## `page export <page-id>` + +Success: + +```text +Exported: <output-path> +Size: <human-readable size> +``` + +Confluence renders the document server-side, so the export runs as a task that +is polled until it finishes. Each change in reported completion emits one +stderr line, which keeps a wait distinguishable from a stall: + +```text +Exporting: <percent>% complete +``` + +Notes: +- Progress is stderr only; stdout carries the success block and nothing else. +- `--format` accepts `pdf` and rejects anything else with + `invalid export format: "<value>" (valid formats: pdf)`. +- Without `--output-file` the filename derives from the page title, reduced to a + single path element, falling back to the page ID when the title leaves nothing + usable. +- An existing output file is refused unless `--force` is passed, and the refusal + precedes the export so no render is spent on it. +- A wait that exceeds `--timeout` fails naming the flag rather than the deadline + alone. + ## `attachment list --page <page-id>` Default columns: diff --git a/tools/cfl/internal/cmd/page/export.go b/tools/cfl/internal/cmd/page/export.go new file mode 100644 index 00000000..348357da --- /dev/null +++ b/tools/cfl/internal/cmd/page/export.go @@ -0,0 +1,269 @@ +package page + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/cmd/root" + cflpresent "github.com/open-cli-collective/confluence-cli/internal/present" +) + +// exportFormatPDF is the only representation Confluence renders for a +// single page through its export task. +const exportFormatPDF = "pdf" + +// pollInterval paces the progress reads while Confluence renders. It is a +// variable so tests can poll without waiting in real time. +var pollInterval = 2 * time.Second + +type exportOptions struct { + *root.Options + format string + outputFile string + force bool + timeout time.Duration +} + +func newExportCmd(rootOpts *root.Options) *cobra.Command { + opts := &exportOptions{Options: rootOpts} + + cmd := &cobra.Command{ + Use: "export <page-id>", + Short: "Export a page to a file", + Long: `Export a Confluence page as a PDF. + +Confluence renders the document server-side, so the export runs as a task +that is polled until it finishes and the result is then downloaded.`, + Example: ` # Export to a file named after the page + cfl page export 123456 + + # Export to a specific file + cfl page export 123456 -O handoff.pdf + + # Overwrite an existing file + cfl page export 123456 -O handoff.pdf --force + + # Allow longer for a large page + cfl page export 123456 --timeout 10m`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runExport(cmd.Context(), args[0], opts) + }, + } + + cmd.Flags().StringVar(&opts.format, "format", exportFormatPDF, "Export format: pdf") + cmd.Flags().StringVarP(&opts.outputFile, "output-file", "O", "", "Output file path (default: derived from the page title)") + cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Overwrite existing file without warning") + cmd.Flags().DurationVar(&opts.timeout, "timeout", 5*time.Minute, "How long to wait for Confluence to render the export") + + return cmd +} + +func runExport(ctx context.Context, pageID string, opts *exportOptions) error { + if err := validateExportFormat(opts.format); err != nil { + return err + } + if opts.timeout <= 0 { + return fmt.Errorf("invalid --timeout: %s (must be greater than zero)", opts.timeout) + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + outputPath, err := exportOutputPath(ctx, client, pageID, opts) + if err != nil { + return err + } + + // Check before starting so a refusal costs no server-side render. + if !opts.force { + if _, err := os.Stat(outputPath); err == nil { + return fmt.Errorf("file already exists: %s (use --force to overwrite)", outputPath) + } + } + + export, err := client.StartPDFExport(ctx, pageID) + if err != nil { + return err + } + + progress, err := awaitExport(ctx, client, export, opts) + if err != nil { + return err + } + + reader, err := client.OpenPDFExport(ctx, export, progress) + if err != nil { + return err + } + defer func() { _ = reader.Close() }() + + bytesWritten, err := writeExport(outputPath, reader) + if err != nil { + return err + } + + return cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentExport(outputPath, bytesWritten)) +} + +// validateExportFormat holds --format to the formats the command can +// actually produce, so an unsupported value fails before any work starts. +func validateExportFormat(format string) error { + if format == exportFormatPDF { + return nil + } + return fmt.Errorf("invalid export format: %q (valid formats: %s)", format, exportFormatPDF) +} + +// exportOutputPath resolves where the document is written. An explicit path +// is used as given; otherwise the page title names the file, which costs a +// read of the page. +func exportOutputPath(ctx context.Context, client *api.Client, pageID string, opts *exportOptions) (string, error) { + if opts.outputFile != "" { + return opts.outputFile, nil + } + + page, err := client.GetPage(ctx, pageID, nil) + if err != nil { + return "", err + } + + return exportFilename(page.Title, pageID), nil +} + +// exportFilename turns a page title into a filename. Titles are free text +// and carry separators, so the result is reduced to a single path element +// and falls back to the page ID when a title leaves nothing usable. +func exportFilename(title, pageID string) string { + cleaned := strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '-' + } + if r < 32 { + return ' ' + } + return r + }, title) + + cleaned = strings.TrimSpace(filepath.Base(strings.TrimSpace(cleaned))) + if cleaned == "" || cleaned == "." || cleaned == ".." { + cleaned = pageID + } + + return cleaned + ".pdf" +} + +// awaitExport polls until Confluence finishes rendering, reporting progress +// on stderr so a wait is distinguishable from a stall. +func awaitExport(ctx context.Context, client *api.Client, export *api.PDFExport, opts *exportOptions) (*api.PDFExportProgress, error) { + ctx, cancel := context.WithTimeout(ctx, opts.timeout) + defer cancel() + + lastReported := -1 + for { + progress, err := client.GetPDFExportProgress(ctx, export) + if err != nil { + // The deadline can lapse during a read as well as between reads, + // and either way the remedy is the same. + if ctx.Err() != nil { + return nil, exportWaitExpired(ctx) + } + return nil, fmt.Errorf("waiting for export: %w", err) + } + if progress.Failed() { + return nil, api.ErrExportFailed + } + if progress.Done() { + return progress, nil + } + + if progress.Progress != lastReported { + lastReported = progress.Progress + _ = cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentExportProgress(progress.Progress)) + } + + select { + case <-ctx.Done(): + return nil, exportWaitExpired(ctx) + case <-time.After(pollInterval): + } + } +} + +// exportWaitExpired reports a wait that ended before the render finished, +// pointing at --timeout only when that deadline is what ended it. +func exportWaitExpired(ctx context.Context) error { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("waiting for export: %w (use --timeout to wait longer)", ctx.Err()) + } + return fmt.Errorf("waiting for export: %w", ctx.Err()) +} + +// writeExport streams the document to disk and reports its size. The bytes +// go to a temporary file beside the destination that is renamed into place +// only once the download completes, so a failure midway leaves no truncated +// document for a retry without --force to refuse. +func writeExport(outputPath string, reader io.Reader) (written int64, err error) { + tmpFile, err := createExportTemp(outputPath) + if err != nil { + return 0, fmt.Errorf("creating output file: %w", err) + } + tmpPath := tmpFile.Name() + defer func() { + if err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + } + }() + + written, err = io.Copy(tmpFile, reader) + if err != nil { + return 0, fmt.Errorf("writing file: %w", err) + } + if err = tmpFile.Close(); err != nil { + return 0, fmt.Errorf("writing file: %w", err) + } + if err = os.Rename(tmpPath, outputPath); err != nil { + return 0, fmt.Errorf("finalizing output file: %w", err) + } + + return written, nil +} + +// exportTempAttempts bounds the retries when a temporary name is taken. +const exportTempAttempts = 10 + +// createExportTemp creates the temporary file beside outputPath. It is not +// os.CreateTemp because that fixes the mode at 0600, and the document should +// get the same umask-derived permissions os.Create gives any other download. +func createExportTemp(outputPath string) (*os.File, error) { + dir, base := filepath.Dir(outputPath), filepath.Base(outputPath) + for range exportTempAttempts { + suffix := make([]byte, 8) + if _, err := rand.Read(suffix); err != nil { + return nil, err + } + name := filepath.Join(dir, "."+base+"."+hex.EncodeToString(suffix)+".tmp") + file, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666) //nolint:gosec // CLI tool creates user-specified output file + if errors.Is(err, fs.ErrExist) { + continue + } + return file, err + } + return nil, fmt.Errorf("no free temporary name beside %s after %d attempts", outputPath, exportTempAttempts) +} diff --git a/tools/cfl/internal/cmd/page/export_test.go b/tools/cfl/internal/cmd/page/export_test.go new file mode 100644 index 00000000..04bde35a --- /dev/null +++ b/tools/cfl/internal/cmd/page/export_test.go @@ -0,0 +1,417 @@ +package page + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" + + "github.com/open-cli-collective/atlassian-go/testutil" + + "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/cmd/root" +) + +func init() { + // Polling is exercised by every export test; none should wait in real + // time between reads. + pollInterval = time.Millisecond +} + +const exportProgressPath = "/api/v2/pdfexporttask/progress/module-11111111-2222-3333-4444-555555555555" + +func loadExportFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) //nolint:gosec // reading test fixture data + testutil.RequireNoError(t, err) + return data +} + +// mockExportServer serves the three legs of an export: the action that +// starts the task, the progress reads, and the document itself. runsBefore +// controls how many reads report the task still running, and download, when +// set, replaces the handler for the document. +func mockExportServer(t *testing.T, runsBefore int, download http.HandlerFunc) *httptest.Server { + t.Helper() + page := loadExportFixture(t, "export_page.json") + start := loadExportFixture(t, "export_start.html") + running := loadExportFixture(t, "export_progress_running.json") + succeeded := loadExportFixture(t, "export_progress_succeeded.json") + document := loadExportFixture(t, "export_document.pdf") + + var mu sync.Mutex + var polls int + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/pages/123456": + w.WriteHeader(http.StatusOK) + _, _ = w.Write(page) + case "/spaces/flyingpdf/pdfpageexport.action": + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(start) + case exportProgressPath: + mu.Lock() + stillRunning := polls < runsBefore + if stillRunning { + polls++ + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + if stillRunning { + _, _ = w.Write(running) + return + } + _, _ = w.Write(succeeded) + case "/download/export.pdf": + if download != nil { + download(w, r) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(document) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) +} + +// truncatedDownload announces a full document, sends only its opening bytes +// and drops the connection, as a download cut off midway does. +func truncatedDownload(t *testing.T) http.HandlerFunc { + t.Helper() + document := loadExportFixture(t, "export_document.pdf") + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(len(document)*100)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(document[:10]) + w.(http.Flusher).Flush() + conn, _, err := http.NewResponseController(w).Hijack() + if err != nil { + t.Errorf("hijacking connection: %v", err) + return + } + _ = conn.Close() + } +} + +func newExportTestRootOptions() *root.Options { + return &root.Options{ + Output: "table", + NoColor: true, + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } +} + +func newExportTestOptions(t *testing.T, server *httptest.Server) *exportOptions { + t.Helper() + rootOpts := newExportTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "user@example.com", "token")) + return &exportOptions{ + Options: rootOpts, + format: exportFormatPDF, + timeout: 30 * time.Second, + } +} + +func TestRunExport_Success(t *testing.T) { + server := mockExportServer(t, 0, nil) + defer server.Close() + want := loadExportFixture(t, "export_document.pdf") + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer func() { _ = os.Chdir(origDir) }() + + opts := newExportTestOptions(t, server) + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Exported: Quarterly Handoff.pdf\nSize: 26 B\n", opts.Stdout.(*bytes.Buffer).String()) + + content, err := os.ReadFile(filepath.Join(tmpDir, "Quarterly Handoff.pdf")) //nolint:gosec // reading test output file + testutil.RequireNoError(t, err) + testutil.Equal(t, string(want), string(content)) +} + +func TestRunExport_CustomOutputFile(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0, nil) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "handoff.pdf") + opts := newExportTestOptions(t, server) + opts.outputFile = outputPath + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Exported: "+outputPath+"\nSize: 26 B\n", opts.Stdout.(*bytes.Buffer).String()) + + content, err := os.ReadFile(outputPath) //nolint:gosec // reading test output file + testutil.RequireNoError(t, err) + testutil.Equal(t, string(loadExportFixture(t, "export_document.pdf")), string(content)) +} + +// TestRunExport_ReportsProgress pins that a wait is visible. Confluence +// renders server-side, so silence for that stretch is indistinguishable +// from a hang. +func TestRunExport_ReportsProgress(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 1, nil) + defer server.Close() + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(t.TempDir(), "handoff.pdf") + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + testutil.Contains(t, opts.Stderr.(*bytes.Buffer).String(), "Exporting: 0% complete") + // Progress belongs on stderr so stdout carries only the artifact. + testutil.NotContains(t, opts.Stdout.(*bytes.Buffer).String(), "Exporting") +} + +func TestRunExport_FileExists_NoForce(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0, nil) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "handoff.pdf") + testutil.RequireNoError(t, os.WriteFile(outputPath, []byte("existing content"), 0600)) + + opts := newExportTestOptions(t, server) + opts.outputFile = outputPath + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "file already exists") + testutil.ErrorContains(t, err, "--force") + + content, _ := os.ReadFile(outputPath) //nolint:gosec // reading test fixture file + testutil.Equal(t, "existing content", string(content)) +} + +func TestRunExport_FileExists_WithForce(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0, nil) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "handoff.pdf") + testutil.RequireNoError(t, os.WriteFile(outputPath, []byte("existing content"), 0600)) + + opts := newExportTestOptions(t, server) + opts.outputFile = outputPath + opts.force = true + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + + content, _ := os.ReadFile(outputPath) //nolint:gosec // reading test output file + testutil.Equal(t, string(loadExportFixture(t, "export_document.pdf")), string(content)) +} + +func TestRunExport_InvalidFormat(t *testing.T) { + t.Parallel() + opts := &exportOptions{Options: newExportTestRootOptions(), format: "docx", timeout: time.Minute} + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, `invalid export format: "docx"`) + testutil.ErrorContains(t, err, "valid formats: pdf") +} + +func TestRunExport_InvalidTimeout(t *testing.T) { + t.Parallel() + opts := &exportOptions{Options: newExportTestRootOptions(), format: exportFormatPDF} + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "invalid --timeout") +} + +func TestRunExport_TimeoutWaitingForRender(t *testing.T) { + t.Parallel() + start := loadExportFixture(t, "export_start.html") + running := loadExportFixture(t, "export_progress_running.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/spaces/flyingpdf/pdfpageexport.action": + w.WriteHeader(http.StatusOK) + _, _ = w.Write(start) + default: + // The task never finishes. + w.WriteHeader(http.StatusOK) + _, _ = w.Write(running) + } + })) + defer server.Close() + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(t.TempDir(), "handoff.pdf") + opts.timeout = 50 * time.Millisecond + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "--timeout") +} + +func TestRunExport_ExportFailed(t *testing.T) { + t.Parallel() + start := loadExportFixture(t, "export_start.html") + failed := loadExportFixture(t, "export_progress_failed.json") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/spaces/flyingpdf/pdfpageexport.action": + w.WriteHeader(http.StatusOK) + _, _ = w.Write(start) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write(failed) + } + })) + defer server.Close() + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(t.TempDir(), "handoff.pdf") + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "export failed") +} + +// TestRunExport_PollsUntilDone pins that several running reads are waited +// through well inside a timeout the production interval could not meet. +func TestRunExport_PollsUntilDone(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 5, nil) + defer server.Close() + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(t.TempDir(), "handoff.pdf") + opts.timeout = time.Second + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) +} + +// TestRunExport_FileModeFollowsUmask pins that the exported document gets +// the permissions os.Create would give it, as attachment download does, +// rather than the fixed 0600 of a temporary file. +func TestRunExport_FileModeFollowsUmask(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0, nil) + defer server.Close() + + dir := t.TempDir() + reference, err := os.Create(filepath.Join(dir, "reference")) //nolint:gosec // test file in a temp dir + testutil.RequireNoError(t, err) + testutil.RequireNoError(t, reference.Close()) + want, err := os.Stat(reference.Name()) + testutil.RequireNoError(t, err) + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(dir, "handoff.pdf") + testutil.RequireNoError(t, runExport(context.Background(), "123456", opts)) + + got, err := os.Stat(opts.outputFile) + testutil.RequireNoError(t, err) + testutil.Equal(t, want.Mode().Perm(), got.Mode().Perm()) +} + +// TestRunExport_DownloadCutOff pins that a download failing midway leaves +// nothing at the destination, so a retry without --force is not refused by +// a truncated document. +func TestRunExport_DownloadCutOff(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0, truncatedDownload(t)) + defer server.Close() + + dir := t.TempDir() + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(dir, "handoff.pdf") + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "writing file") + + entries, err := os.ReadDir(dir) + testutil.RequireNoError(t, err) + testutil.Len(t, entries, 0) + + retry := mockExportServer(t, 0, nil) + defer retry.Close() + retryOpts := newExportTestOptions(t, retry) + retryOpts.outputFile = opts.outputFile + + testutil.RequireNoError(t, runExport(context.Background(), "123456", retryOpts)) + content, err := os.ReadFile(opts.outputFile) //nolint:gosec // reading test output file + testutil.RequireNoError(t, err) + testutil.Equal(t, string(loadExportFixture(t, "export_document.pdf")), string(content)) +} + +// TestRunExport_DownloadCutOff_KeepsExisting pins that with --force a failed +// download does not destroy the file it would have replaced. +func TestRunExport_DownloadCutOff_KeepsExisting(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0, truncatedDownload(t)) + defer server.Close() + + dir := t.TempDir() + outputPath := filepath.Join(dir, "handoff.pdf") + testutil.RequireNoError(t, os.WriteFile(outputPath, []byte("existing content"), 0600)) + + opts := newExportTestOptions(t, server) + opts.outputFile = outputPath + opts.force = true + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + + content, err := os.ReadFile(outputPath) //nolint:gosec // reading test fixture file + testutil.RequireNoError(t, err) + testutil.Equal(t, "existing content", string(content)) + entries, err := os.ReadDir(dir) + testutil.RequireNoError(t, err) + testutil.Len(t, entries, 1) +} + +// TestExportFilename covers titles that are free text: they carry path +// separators and characters a filename cannot hold, and must still resolve +// to a single file in the working directory. +func TestExportFilename(t *testing.T) { + t.Parallel() + tests := []struct { + name string + title string + pageID string + want string + }{ + {"plain title", "Quarterly Handoff", "123", "Quarterly Handoff.pdf"}, + {"path separators", "Runbook: DB/Restore", "123", "Runbook- DB-Restore.pdf"}, + {"path traversal", "../../../etc/passwd", "123", "..-..-..-etc-passwd.pdf"}, + {"leading slash", "/etc/passwd", "123", "-etc-passwd.pdf"}, + {"empty title", "", "123", "123.pdf"}, + {"whitespace title", " ", "123", "123.pdf"}, + {"dot title", ".", "123", "123.pdf"}, + {"double dot title", "..", "123", "123.pdf"}, + {"reserved characters", `Report <2026> "final"?`, "123", "Report -2026- -final--.pdf"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := exportFilename(tt.title, tt.pageID) + testutil.Equal(t, tt.want, got) + // Whatever the title held, the result names one file here. + testutil.Equal(t, got, filepath.Base(got)) + }) + } +} diff --git a/tools/cfl/internal/cmd/page/page.go b/tools/cfl/internal/cmd/page/page.go index ef2675d0..7bc55c3c 100644 --- a/tools/cfl/internal/cmd/page/page.go +++ b/tools/cfl/internal/cmd/page/page.go @@ -35,6 +35,7 @@ func Register(rootCmd *cobra.Command, opts *root.Options) { cmd.AddCommand(newEditCmd(opts)) cmd.AddCommand(newDeleteCmd(opts)) cmd.AddCommand(newCopyCmd(opts)) + cmd.AddCommand(newExportCmd(opts)) rootCmd.AddCommand(cmd) } diff --git a/tools/cfl/internal/cmd/page/testdata/export_document.pdf b/tools/cfl/internal/cmd/page/testdata/export_document.pdf new file mode 100644 index 00000000..6d21c17a Binary files /dev/null and b/tools/cfl/internal/cmd/page/testdata/export_document.pdf differ diff --git a/tools/cfl/internal/cmd/page/testdata/export_page.json b/tools/cfl/internal/cmd/page/testdata/export_page.json new file mode 100644 index 00000000..5d007c3b --- /dev/null +++ b/tools/cfl/internal/cmd/page/testdata/export_page.json @@ -0,0 +1,6 @@ +{ + "id": "123456", + "status": "current", + "title": "Quarterly Handoff", + "spaceId": "789" +} diff --git a/tools/cfl/internal/cmd/page/testdata/export_progress_failed.json b/tools/cfl/internal/cmd/page/testdata/export_progress_failed.json new file mode 100644 index 00000000..d78c6b29 --- /dev/null +++ b/tools/cfl/internal/cmd/page/testdata/export_progress_failed.json @@ -0,0 +1,7 @@ +{ + "progress": 40, + "state": "FAILED", + "result": "", + "estimatedTimeRemaining": 30000, + "timeElapsed": 12 +} diff --git a/tools/cfl/internal/cmd/page/testdata/export_progress_running.json b/tools/cfl/internal/cmd/page/testdata/export_progress_running.json new file mode 100644 index 00000000..74a50f7f --- /dev/null +++ b/tools/cfl/internal/cmd/page/testdata/export_progress_running.json @@ -0,0 +1,7 @@ +{ + "progress": 0, + "state": "IN_PROGRESS", + "result": "", + "estimatedTimeRemaining": 30000, + "timeElapsed": 4 +} diff --git a/tools/cfl/internal/cmd/page/testdata/export_progress_succeeded.json b/tools/cfl/internal/cmd/page/testdata/export_progress_succeeded.json new file mode 100644 index 00000000..fbb1eee7 --- /dev/null +++ b/tools/cfl/internal/cmd/page/testdata/export_progress_succeeded.json @@ -0,0 +1,7 @@ +{ + "progress": 100, + "state": "SUCCEEDED", + "result": "/download/export.pdf", + "estimatedTimeRemaining": 30000, + "timeElapsed": 35 +} diff --git a/tools/cfl/internal/cmd/page/testdata/export_start.html b/tools/cfl/internal/cmd/page/testdata/export_start.html new file mode 100644 index 00000000..3a877c89 --- /dev/null +++ b/tools/cfl/internal/cmd/page/testdata/export_start.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<html> +<head> + <title>Export to PDF + + + + + + + + + + + +
+

Exporting to PDF

+
+ 0% complete + + +
+ + + diff --git a/tools/cfl/internal/present/mutation.go b/tools/cfl/internal/present/mutation.go index 6769fc61..37dbdd71 100644 --- a/tools/cfl/internal/present/mutation.go +++ b/tools/cfl/internal/present/mutation.go @@ -68,6 +68,22 @@ func (PagePresenter) PresentCopy(page *api.Page) *sharedpresent.OutputModel { return successWithFields(fmt.Sprintf("Copied page: %s", orDash(page.Title)), fields...) } +func (PagePresenter) PresentExport(outputPath string, sizeBytes int64) *sharedpresent.OutputModel { + return successWithFields( + fmt.Sprintf("Exported: %s", outputPath), + sharedpresent.Field{Label: "Size", Value: formatAttachmentFileSize(sizeBytes)}, + ) +} + +// PresentExportProgress reports that an export is still running. Confluence +// renders the document server-side, so a caller with no output for that +// stretch cannot tell waiting apart from a hang. +func (PagePresenter) PresentExportProgress(percent int) *sharedpresent.OutputModel { + return &sharedpresent.OutputModel{Sections: []sharedpresent.Section{ + stderrInfo(fmt.Sprintf("Exporting: %d%% complete", percent)), + }} +} + func (PagePresenter) PresentDelete(page *api.Page) *sharedpresent.OutputModel { return successMessage(fmt.Sprintf("Deleted page: %s (ID: %s)", orDash(page.Title), orDash(page.ID))) } diff --git a/tools/cfl/internal/present/mutation_test.go b/tools/cfl/internal/present/mutation_test.go index 99439900..57a0666d 100644 --- a/tools/cfl/internal/present/mutation_test.go +++ b/tools/cfl/internal/present/mutation_test.go @@ -110,6 +110,28 @@ func TestAttachmentMutationPresenters(t *testing.T) { testutil.Equal(t, "Deleted attachment: spec.pdf (ID: att-1)", deleteSummary.Message) } +func TestPagePresenterExport(t *testing.T) { + t.Parallel() + + model := PagePresenter{}.PresentExport("handoff.pdf", 261759) + summary := requireMessageSection(t, model, 0) + testutil.Equal(t, sharedpresent.StreamStdout, summary.Stream) + testutil.Equal(t, "Exported: handoff.pdf", summary.Message) + fields := requireDetailSection(t, model, 1) + testutil.Equal(t, []sharedpresent.Field{{Label: "Size", Value: "255.6 KB"}}, fields.Fields) +} + +// TestPagePresenterExportProgress pins progress to stderr so stdout carries +// only the success artifact. +func TestPagePresenterExportProgress(t *testing.T) { + t.Parallel() + + model := PagePresenter{}.PresentExportProgress(40) + msg := requireMessageSection(t, model, 0) + testutil.Equal(t, sharedpresent.StreamStderr, msg.Stream) + testutil.Equal(t, "Exporting: 40% complete", msg.Message) +} + func TestPresentDeletionCancelled(t *testing.T) { t.Parallel()