Skip to content

feat(cfl): add page export to PDF - #481

Draft
piekstra wants to merge 2 commits into
mainfrom
piekstra/cfl-page-export-pdf
Draft

feat(cfl): add page export to PDF#481
piekstra wants to merge 2 commits into
mainfrom
piekstra/cfl-page-export-pdf

Conversation

@piekstra

@piekstra piekstra commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds cfl page export <page-id>, which writes a Confluence page out as a PDF.

Everything else about a page is reachable from the CLI, but exporting one was
not, so any workflow that ends in "send this page to someone as a document" had
to stop and go click Export to PDF in the web UI. This closes that gap.

cfl page export 123456                          # filename from the page title
cfl page export 123456 -O handoff.pdf
cfl page export 123456 -O handoff.pdf --force
cfl page export 123456 --timeout 10m
Exporting: 0% complete
Exported: Quarterly Handoff.pdf
Size: 255.6 KB

Why it works the way it does

Confluence Cloud publishes no REST endpoint for PDF export. The request has
been open since 2018 (CONFCLOUD-61557,
still Gathering Interest), and the Atlassian KB article that describes a
302 straight to a PDF is scoped to Data Center and does not apply here. The
v1 and v2 API groups have no export surface at all. I confirmed both against
a live Cloud site rather than taking the docs' word for it:

Probe Result
GET /wiki/api/v2/pages/{id}/export 404
GET /wiki/rest/api/content/{id}/export 404
GET /wiki/rest/api/content/{id}/exportpdf 404

What does exist is the flow the Confluence UI itself uses, and it works with the
API-token auth cfl already has. It has three legs:

  1. GET /wiki/spaces/flyingpdf/pdfpageexport.action?pageId=<id> starts a
    server-side render and returns an HTML progress page. The task identifier is
    published only as <meta name="ajs-taskId"> inside that page, which is why
    this leg parses HTML. A sibling ajs-isV3 declares which progress endpoint
    served the request.
  2. GET /wiki/api/v2/pdfexporttask/progress/<taskId> reports
    {progress, state, result} until state reaches SUCCEEDED.
  3. result names the rendered document, which is then downloaded.

Four behaviors here are not obvious and each one is load-bearing:

  • The XSRF header decides the request. Without X-Atlassian-Token: no-check
    the start request is refused with 403; with it, 200. I hit exactly this
    while building it.
  • A recognized route and a missing one both return the SPA shell, so status
    is the only thing that separates them. A bogus action path returns 404 while
    pdfpageexport.action returns 200, which is the control that establishes the
    route is real rather than being swallowed by the SPA router.
  • The result URL may be absolute or site-relative, and the auth handling
    inverts between them.
    The document is normally handed to a media host with a
    signed URL carrying its own access, where sending the Atlassian credential is
    both wrong and a credential leak to another host. A site-relative result needs
    the credential. Sending it is therefore decided by where the URL points.
  • A rejected credential is answered with a sign-in page under HTTP 200. The
    status code cannot catch that, so the download is verified to start with
    %PDF- and fails with ErrExportNotPDF otherwise, rather than writing an HTML
    page to disk under a .pdf name.

Both progress-endpoint generations are supported because the page declares which
one applies; the pre-v3 shape adds one indirection (the result addresses a
resource whose body is the document URL). Neither shape is guessed: both come
from the export script Confluence itself ships.

Verification

Run against a live Confluence Cloud site with the ordinary API-token config:

$ cfl page export <page-id>
Exporting: 0% complete
Exported: <page title>.pdf
Size: 255.6 KB

$ file '<page title>.pdf'
PDF document, version 1.4, 3 pages

Guard paths, also run live:

$ cfl page export <page-id> --format docx
Error: invalid export format: "docx" (valid formats: pdf)

$ cfl page export <page-id>
Error: file already exists: <page title>.pdf (use --force to overwrite)

$ cfl page export 999999999
Error: starting export: page 999999999 not found, or not visible to this user

$ cfl page export <page-id> --timeout 0
Error: invalid --timeout: 0s (must be greater than zero)

make check is green (tidy, lint, test, build).

Notes for review

  • The flyingpdf path is undocumented and Atlassian has moved it before (the
    progress endpoint changed in April 2026, breaking the recipes in the wild).
    That is an argument for the failure modes above being explicit rather than for
    avoiding the path, since it is the only one that produces a PDF at all. The
    alternative considered was rendering the page body locally, which would produce
    a document that does not match what Confluence's own export produces.
  • Output follows the attachment download precedent (-O/--output-file,
    -f/--force) and the mutation contract in OUTPUT_SPEC.md: a success summary
    plus follow-up fields, progress on stderr so stdout stays clean for scripting.
  • --format currently accepts only pdf. It is there so the closed set is
    stated and rejected loudly rather than a second format being bolted on later
    by positional convention.
  • Wire fixtures are synthetic and live in api/testdata/.

Docs

tools/cfl/README.md, root README.md, internal/cmd/OUTPUT_SPEC.md,
skills/Confluence/CliReference.md, skills/Confluence/Workflows/ManagePage.md,
and the cfl changelog.

tools/cfl/integration-tests.md gains a page export section. That catalog is
where a break in the undocumented flow would actually be caught, and it also
records the one thing I could not establish: bearer auth is unverified for
this command.
The export goes through a web action rather than a REST
endpoint, and whether the api.atlassian.com gateway proxies that action has
not been tested. I would rather state that than let it be assumed.

Exporting a page to PDF was the one thing that sent a workflow back to the
Confluence web UI, which matters when the page is a document that has to be
sent to someone outside the site.

Confluence Cloud publishes no REST endpoint for this. What it does have is
the action its own UI navigates to, which both starts a server-side render
and returns a page carrying the task identifier as metadata, plus a progress
endpoint that reports the task and finally names the rendered document.
`page export` drives those three legs: start, poll, download.

Details worth knowing at the call site:

- The XSRF header is what separates an accepted start request from a 403.
- Confluence declares which generation of the progress endpoint served the
  request, and that also decides whether the task result is the document URL
  or a URL yielding one. Both are handled; neither is guessed.
- The document is handed off to a media host with a signed URL that carries
  its own access, so the credential is sent only to the configured site and
  never to whatever host the task names.
- A rejected credential is answered with a sign-in page under HTTP 200, so
  the download is checked for the PDF header rather than trusting the status.

Progress goes to stderr and the success block to stdout, so redirecting
stdout captures the result and nothing else. Without --output-file the page
title names the file, reduced to a single path element.
The export drives an undocumented Confluence flow that Atlassian has moved
before without notice, so the catalog is where a regression in it would be
caught. Also records that bearer auth is unverified for this command rather
than leaving the gap implied: the export goes through a web action rather
than a REST endpoint, and whether the gateway proxies that action has not
been established.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant