Skip to content

Commit 2ab7fe9

Browse files
committed
Fixed analytics export review feedback
Guarded the CSV serializer against missing filenames, relaxed export header matchers to cover the no-title fallback, and exposed the existing `@tryghost/string` module type to the admin build that imports admin-x-settings source.
1 parent bcacabe commit 2ab7fe9

7 files changed

Lines changed: 51 additions & 17 deletions

File tree

apps/admin/src/vite-env.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@
22

33
declare module '@tryghost/limit-service'
44
declare module '@tryghost/nql'
5+
declare module '@tryghost/string' {
6+
export function slugify(string: string, options?: {requiredChangesOnly?: boolean}): string;
7+
}
58
declare module '@tryghost/koenig-lexical'

ghost/core/core/server/api/endpoints/utils/serializers/output/posts.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const mappers = require('./mappers');
33
const tiersService = require('../../../../../services/tiers');
44
const {pipeline} = require('stream');
55
const {createCSVTransform} = require('./posts-csv-transform');
6+
const {InternalServerError} = require('@tryghost/errors');
67

78
module.exports = {
89
async all(models, apiConfig, frame) {
@@ -56,6 +57,12 @@ module.exports = {
5657

5758
exportCSV(models, apiConfig, frame) {
5859
frame.response = function streamResponse(req, res, next) {
60+
if (!models.filename) {
61+
return next(new InternalServerError({
62+
message: 'Missing CSV export filename'
63+
}));
64+
}
65+
5966
const csvTransform = createCSVTransform();
6067

6168
res.setHeader('Content-Type', 'text/csv; charset=utf-8');

ghost/core/test/e2e-api/admin/__snapshots__/post-analytics-export.test.js.snap

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ exports[`Post Analytics Export Default settings (all features enabled) Exports C
44
Object {
55
"access-control-allow-origin": "http://127.0.0.1:2369",
66
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
7-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
7+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
88
"content-type": "text/csv; charset=utf-8",
99
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
1010
"vary": "Accept-Version, Origin",
@@ -37,7 +37,7 @@ exports[`Post Analytics Export Settings variations Hides clicks column when emai
3737
Object {
3838
"access-control-allow-origin": "http://127.0.0.1:2369",
3939
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
40-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
40+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
4141
"content-type": "text/csv; charset=utf-8",
4242
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
4343
"vary": "Accept-Version, Origin",
@@ -70,7 +70,7 @@ exports[`Post Analytics Export Settings variations Hides email columns when memb
7070
Object {
7171
"access-control-allow-origin": "http://127.0.0.1:2369",
7272
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
73-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
73+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
7474
"content-type": "text/csv; charset=utf-8",
7575
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
7676
"vary": "Accept-Version, Origin",
@@ -103,7 +103,7 @@ exports[`Post Analytics Export Settings variations Hides feedback columns when n
103103
Object {
104104
"access-control-allow-origin": "http://127.0.0.1:2369",
105105
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
106-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
106+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
107107
"content-type": "text/csv; charset=utf-8",
108108
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
109109
"vary": "Accept-Version, Origin",
@@ -136,7 +136,7 @@ exports[`Post Analytics Export Settings variations Hides opens column when email
136136
Object {
137137
"access-control-allow-origin": "http://127.0.0.1:2369",
138138
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
139-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
139+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
140140
"content-type": "text/csv; charset=utf-8",
141141
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
142142
"vary": "Accept-Version, Origin",
@@ -169,7 +169,7 @@ exports[`Post Analytics Export Settings variations Hides paid_conversions when p
169169
Object {
170170
"access-control-allow-origin": "http://127.0.0.1:2369",
171171
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
172-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
172+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
173173
"content-type": "text/csv; charset=utf-8",
174174
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
175175
"vary": "Accept-Version, Origin",
@@ -202,7 +202,7 @@ exports[`Post Analytics Export Settings variations Hides signups and paid_conver
202202
Object {
203203
"access-control-allow-origin": "http://127.0.0.1:2369",
204204
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
205-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
205+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
206206
"content-type": "text/csv; charset=utf-8",
207207
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
208208
"vary": "Accept-Version, Origin",

ghost/core/test/e2e-api/admin/__snapshots__/posts.test.js.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1834,7 +1834,7 @@ exports[`Posts API Export Can export 1: [headers] 1`] = `
18341834
Object {
18351835
"access-control-allow-origin": "http://127.0.0.1:2369",
18361836
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
1837-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
1837+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
18381838
"content-type": "text/csv; charset=utf-8",
18391839
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
18401840
"vary": "Accept-Version, Origin",
@@ -1865,7 +1865,7 @@ exports[`Posts API Export Can export with filter 1: [headers] 1`] = `
18651865
Object {
18661866
"access-control-allow-origin": "http://127.0.0.1:2369",
18671867
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
1868-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
1868+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
18691869
"content-type": "text/csv; charset=utf-8",
18701870
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
18711871
"vary": "Accept-Version, Origin",
@@ -1885,7 +1885,7 @@ exports[`Posts API Export Can export with limit 1: [headers] 1`] = `
18851885
Object {
18861886
"access-control-allow-origin": "http://127.0.0.1:2369",
18871887
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
1888-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
1888+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
18891889
"content-type": "text/csv; charset=utf-8",
18901890
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
18911891
"vary": "Accept-Version, Origin",
@@ -1904,7 +1904,7 @@ exports[`Posts API Export Can export with order 1: [headers] 1`] = `
19041904
Object {
19051905
"access-control-allow-origin": "http://127.0.0.1:2369",
19061906
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0, no-transform",
1907-
"content-disposition": StringMatching /\\^Attachment; filename="\\[a-z0-9-\\]\\+\\\\\\.ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
1907+
"content-disposition": StringMatching /\\^Attachment; filename="\\(\\?:\\[a-z0-9-\\]\\+\\\\\\.\\)\\?ghost\\\\\\.analytics\\\\\\.\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}\\\\\\.csv"\\$/,
19081908
"content-type": "text/csv; charset=utf-8",
19091909
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
19101910
"vary": "Accept-Version, Origin",

ghost/core/test/e2e-api/admin/post-analytics-export.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const csvReplacements = [
2929

3030
const matchExportHeaders = {
3131
'content-version': anyContentVersion,
32-
'content-disposition': stringMatching(/^Attachment; filename="[a-z0-9-]+\.ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
32+
'content-disposition': stringMatching(/^Attachment; filename="(?:[a-z0-9-]+\.)?ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
3333
};
3434

3535
describe('Post Analytics Export', function () {

ghost/core/test/e2e-api/admin/posts.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ describe('Posts API', function () {
141141
.expectStatus(200)
142142
.matchHeaderSnapshot({
143143
'content-version': anyContentVersion,
144-
'content-disposition': stringMatching(/^Attachment; filename="[a-z0-9-]+\.ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
144+
'content-disposition': stringMatching(/^Attachment; filename="(?:[a-z0-9-]+\.)?ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
145145
});
146146

147147
// body snapshot doesn't work with text/csv
@@ -158,7 +158,7 @@ describe('Posts API', function () {
158158
.expectStatus(200)
159159
.matchHeaderSnapshot({
160160
'content-version': anyContentVersion,
161-
'content-disposition': stringMatching(/^Attachment; filename="[a-z0-9-]+\.ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
161+
'content-disposition': stringMatching(/^Attachment; filename="(?:[a-z0-9-]+\.)?ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
162162
});
163163

164164
// body snapshot doesn't work with text/csv
@@ -175,7 +175,7 @@ describe('Posts API', function () {
175175
.expectStatus(200)
176176
.matchHeaderSnapshot({
177177
'content-version': anyContentVersion,
178-
'content-disposition': stringMatching(/^Attachment; filename="[a-z0-9-]+\.ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
178+
'content-disposition': stringMatching(/^Attachment; filename="(?:[a-z0-9-]+\.)?ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
179179
});
180180

181181
// body snapshot doesn't work with text/csv
@@ -192,7 +192,7 @@ describe('Posts API', function () {
192192
.expectStatus(200)
193193
.matchHeaderSnapshot({
194194
'content-version': anyContentVersion,
195-
'content-disposition': stringMatching(/^Attachment; filename="[a-z0-9-]+\.ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
195+
'content-disposition': stringMatching(/^Attachment; filename="(?:[a-z0-9-]+\.)?ghost\.analytics\.\d{4}-\d{2}-\d{2}\.csv"$/)
196196
});
197197

198198
// body snapshot doesn't work with text/csv

ghost/core/test/unit/api/canary/utils/serializers/output/posts-export-csv.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('Unit: posts CSV export serializer', function () {
101101
const source = Readable.from([{id: '1', title: 'Post'}], {objectMode: true});
102102
const frame: {response?: Function} = {};
103103

104-
postsSerializer.exportCSV({data: source}, null, frame);
104+
postsSerializer.exportCSV({data: source, filename: 'test-blog.ghost.analytics.2026-06-02.csv'}, null, frame);
105105

106106
const response: any = new Writable({
107107
write(_chunk: unknown, _encoding: string, callback: Function) {
@@ -118,4 +118,28 @@ describe('Unit: posts CSV export serializer', function () {
118118

119119
assert.equal(err, sourceError);
120120
});
121+
122+
it('Passes missing filenames to next', async function () {
123+
const source = Readable.from([{id: '1', title: 'Post'}], {objectMode: true});
124+
const frame: {response?: Function} = {};
125+
const response: any = new Writable({
126+
write(_chunk: unknown, _encoding: string, callback: Function) {
127+
callback();
128+
}
129+
});
130+
const headers: Record<string, string> = {};
131+
response.setHeader = (key: string, value: string) => {
132+
headers[key] = value;
133+
};
134+
response.getHeader = () => undefined;
135+
136+
postsSerializer.exportCSV({data: source}, null, frame);
137+
138+
const err = await new Promise((resolve) => {
139+
frame.response!(null, response, resolve);
140+
});
141+
142+
assert.equal((err as Error).message, 'Missing CSV export filename');
143+
assert.deepEqual(headers, {});
144+
});
121145
});

0 commit comments

Comments
 (0)