Skip to content

Moved CSV export filename ownership to the API#28395

Merged
9larsons merged 5 commits into
mainfrom
refactor/centralize-csv-export-filename
Jun 6, 2026
Merged

Moved CSV export filename ownership to the API#28395
9larsons merged 5 commits into
mainfrom
refactor/centralize-csv-export-filename

Conversation

@9larsons
Copy link
Copy Markdown
Contributor

@9larsons 9larsons commented Jun 6, 2026

ref #28310

Why

The members and post-analytics CSV exports each defined the <slug>.ghost.<type>.<date>.csv filename in up to four places — two API endpoints (members, posts) and two admin helpers (getMembersExportFileName, getPostAnalyticsExportFileName) — and slugified the site title two different ways (a hand-rolled regex on the client vs @tryghost/string's slugify on the server, which transliterate non-ASCII titles differently).

This duplication exists only because admin downloads fetch the export into a blob and set the filename via a.download, which bypasses the server's Content-Disposition header. So the client re-implemented what the server already knew.

What

Make the API the single source of truth for export filenames, and have the admin client read it back from the response:

Backend (ghost/core)

  • Added endpoints/utils/csv-export-filename.js (getCSVExportFileName(type)) — the one definition of the convention.
  • Added a shared serializers/output/stream-csv-response.ts and routed both the members and posts CSV serializers through it.
  • The members streaming serializer previously hardcoded members.<date>.csv, so the prefixed Content-Disposition never reached direct/API consumers. It now uses the filename plumbed from the endpoint, so the API download is named correctly.

Frontend

  • blobDownload/blobDownloadFromEndpoint now read the filename from the response's Content-Disposition header (new getFilenameFromContentDisposition parser); the explicit filename arg is now an optional fallback.
  • Removed the duplicated frontend filename helpers and the siteTitle prop-drilling, and unified the post-analytics export onto blobDownloadFromEndpoint (removing the now-orphaned usePostsExports hook and its bespoke blob/anchor download).

Net behaviour is unchanged for users (same filenames in the admin UI), but the convention now lives in exactly one place and the direct API download is finally correct.

Test plan

  • ghost/core: new unit tests for getCSVExportFileName (slug + transliteration + fallback) and the members CSV serializer (Content-Disposition, legacy fallback, stream errors). Posts serializer tests still pass. 14 passing.
  • admin-x-framework: blobDownload now covered for header-driven naming, fallback, and the getFilenameFromContentDisposition parser. 25 passing.
  • posts: members-actions export test updated (server names the file). 6 passing.
  • Lint + tsc clean across all touched files.
  • ⚠️ The analytics export switched from a react-query fetch to blobDownloadFromEndpoint (same-origin, cookie-authed — same path the members export already uses). The analytics.test.ts acceptance test mocks by URL and should pass, but it's a Playwright test worth confirming in CI.

ref #28310

- the members and post-analytics CSV exports each defined the
  `<slug>.ghost.<type>.<date>.csv` filename in up to four places (two API
  endpoints and two admin helpers), and slugified the site title inconsistently
  (hand-rolled regex on the client vs `@tryghost/string` on the server)
- made the API the single source of truth: added getCSVExportFileName() and a
  shared streaming-response helper, and taught the admin blobDownload helper to
  read the filename from the response's Content-Disposition header
- this also fixes the members export: the streaming serializer previously
  hardcoded `members.<date>.csv`, so the API's prefixed Content-Disposition
  never reached direct/API consumers
- removed the now-duplicated frontend filename helpers, the siteTitle
  prop-drilling and the orphaned usePostsExports hook
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 6, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 575a718d-92f7-45f1-944d-bae2cd7c0cf4

📥 Commits

Reviewing files that changed from the base of the PR and between ac02625 and 495b843.

📒 Files selected for processing (6)
  • apps/admin-x-framework/src/utils/helpers.ts
  • apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-export.tsx
  • apps/posts/src/views/members/components/members-actions.tsx
  • apps/posts/test/unit/views/members/members-actions.test.tsx
  • ghost/core/core/server/api/endpoints/utils/csv-export-filename.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/stream-csv-response.ts
💤 Files with no reviewable changes (3)
  • apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-export.tsx
  • apps/posts/src/views/members/components/members-actions.tsx
  • apps/posts/test/unit/views/members/members-actions.test.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • ghost/core/core/server/api/endpoints/utils/csv-export-filename.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/stream-csv-response.ts
  • apps/admin-x-framework/src/utils/helpers.ts

Walkthrough

This PR centralizes CSV export filename generation on the backend (getCSVExportFileName), introduces createCSVStreamResponse for streaming CSV responses, and updates frontend helpers (getFilenameFromContentDisposition, blobDownload, blobDownloadFromEndpoint) to prefer filenames from Content-Disposition with fallbacks. Frontend export code and tests were simplified to use endpoint downloads; serializers and endpoint code were updated to delegate streaming and provide consistent filenames. Tests and e2e expectations were added/updated for parsing, filename formats, streaming behavior, and error propagation.

Possibly related PRs

  • TryGhost/Ghost#28362: Both PRs change ghost/core/core/server/api/endpoints/members.js to adjust the exportCSV/Content-Disposition members CSV download filename to include the (slugified) site name with a ghost... fallback.
  • TryGhost/Ghost#28308: Modifies analytics export filename logic and frontend handling for post analytics exports; directly relates to the migration-tools frontend change here.
  • TryGhost/Ghost#27752: Overlaps with posts analytics export and CSV serializer changes at the same endpoint/serialization layer.

Suggested labels

ok to merge for me

Suggested reviewers

  • sagzy
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: moving CSV export filename responsibility from the client to the API, which is the central theme of this refactoring.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, clearly explaining the motivation, implementation details, and testing approach.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/centralize-csv-export-filename

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/admin-x-framework/src/utils/helpers.ts`:
- Around line 48-52: The current RFC5987 parsing only strips the exact "UTF-8''"
token and misses non-empty language tags (e.g. UTF-8'en'), so update the
header.match call that produces extendedMatch to use a pattern that accepts an
optional charset followed by an optional non-empty language tag and the two
single-quote delimiter (i.e., allow charset'lang'') before capturing the encoded
filename; then continue to trim surrounding quotes and pass the captured group
to decodeURIComponent as you already do (adjust the match on the line that
defines extendedMatch and keep the existing decode/trim/try-catch flow).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 02c7efdc-f60b-46e5-a02e-fa92789e0a1e

📥 Commits

Reviewing files that changed from the base of the PR and between 4ba6670 and 8d21f35.

📒 Files selected for processing (16)
  • apps/admin-x-framework/src/api/posts.ts
  • apps/admin-x-framework/src/utils/helpers.ts
  • apps/admin-x-framework/test/unit/utils/helpers.test.ts
  • apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-export.tsx
  • apps/admin-x-settings/test/unit/utils/post-analytics-export-filename.test.ts
  • apps/posts/src/views/members/components/members-actions.tsx
  • apps/posts/src/views/members/members.tsx
  • apps/posts/test/unit/views/members/members-actions.test.tsx
  • ghost/core/core/server/api/endpoints/members.js
  • ghost/core/core/server/api/endpoints/posts.js
  • ghost/core/core/server/api/endpoints/utils/csv-export-filename.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/members.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js
  • ghost/core/core/server/api/endpoints/utils/serializers/output/stream-csv-response.ts
  • ghost/core/test/unit/api/canary/utils/serializers/output/members-export-csv.test.ts
  • ghost/core/test/unit/api/endpoints/utils/csv-export-filename.test.js
💤 Files with no reviewable changes (2)
  • apps/admin-x-settings/test/unit/utils/post-analytics-export-filename.test.ts
  • apps/admin-x-framework/src/api/posts.ts

Comment thread apps/admin-x-framework/src/utils/helpers.ts Outdated
ref #28310

- routing the members CSV export through the shared streaming-response helper
  changed three response headers that the e2e-api snapshots assert on: the
  Content-Disposition is now the site-prefixed `<slug>.ghost.members.<date>.csv`
  (capitalised to match the posts export), Cache-Control gains `no-transform`,
  and the now-redundant `Vary: Accept-Encoding` is dropped
- updated the backup, members-exporter and members snapshots accordingly
@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 6, 2026

Codecov Report

❌ Patch coverage is 84.00000% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.78%. Comparing base (ad86270) to head (495b843).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...ts/utils/serializers/output/stream-csv-response.ts 78.72% 9 Missing and 1 partial ⚠️
.../server/api/endpoints/utils/csv-export-filename.js 92.00% 2 Missing ⚠️
.../api/endpoints/utils/serializers/output/members.js 86.66% 2 Missing ⚠️
ghost/core/core/server/api/endpoints/members.js 75.00% 1 Missing ⚠️
ghost/core/core/server/api/endpoints/posts.js 66.66% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #28395   +/-   ##
=======================================
  Coverage   73.77%   73.78%           
=======================================
  Files        1536     1538    +2     
  Lines      130934   130965   +31     
  Branches    15690    15694    +4     
=======================================
+ Hits        96597    96630   +33     
- Misses      33347    33369   +22     
+ Partials      990      966   -24     
Flag Coverage Δ
admin-tests 54.67% <ø> (ø)
e2e-tests 73.78% <84.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

ref #28310

- getFilenameFromContentDisposition only stripped the literal `UTF-8''` token,
  so an extended `filename*` value with a non-empty language tag (e.g.
  `UTF-8'en'`) or a non-UTF-8 charset (e.g. `ISO-8859-1''`) leaked the prefix
  into the parsed filename
- match the full RFC 5987 `charset'language'` prefix before the encoded value
Comment thread apps/admin-x-framework/src/utils/helpers.ts Fixed
9larsons added 2 commits June 6, 2026 14:10
ref #28310

- CodeQL flagged the RFC 5987 extended-form regex as a polynomial ReDoS: the
  nested `[^';]*'[^';]*'` runs overlap with the following capture when the
  header has no closing quote
- replaced it with a single linear capture plus plain string-op prefix
  stripping; behaviour is unchanged and covered by the existing unit tests
ref #28310

- dropped redundant inline comments from the members and analytics export
  callsites (the behaviour is documented once on the shared blobDownload helper)
- tightened the remaining JSDoc on the filename helper, the stream-response
  helper and the Content-Disposition parser
@9larsons 9larsons enabled auto-merge (squash) June 6, 2026 20:02
@9larsons 9larsons merged commit a95a9b9 into main Jun 6, 2026
53 checks passed
@9larsons 9larsons deleted the refactor/centralize-csv-export-filename branch June 6, 2026 20:04
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.

2 participants