Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/hooks/commit-msg.bash
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ fi
# Check for emoji in user-facing changes
if [[ "$subject" =~ ^[^[:space:]]*[[:space:]] ]]; then
first_word="${subject%% *}"
if [[ ! "$first_word" =~ ^[[:punct:]] ]]; then
# Emoji are multi-byte (non-ASCII), so detect them by stripping every ASCII
# byte and seeing if anything is left. The previous check tested the first word
# against [[:punct:]], which never matches an emoji — so the warning fired on
# *every* correctly-prefixed commit (🐛, ✨, …) and passed on plain ASCII.
if [[ -z "$(printf '%s' "$first_word" | LC_ALL=C tr -d '\000-\177')" ]]; then
echo -e "${yellow}Warning: User-facing changes should start with an emoji${no_color}"
echo -e "Common emojis: ✨ (Feature), 🎨 (Improvement), 🐛 (Bug Fix), 🌐 (i18n), 💡 (User-facing)"
fi
Expand Down
6 changes: 0 additions & 6 deletions apps/admin-x-framework/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,6 @@ export const getPost = createQueryWithId<PostsResponseType>({
path: id => `/posts/${id}/`
});

// This endpoints returns a csv file
export const usePostsExports = createQuery<string>({
dataType,
path: '/posts/export/'
});

export const useDeletePost = createMutation<unknown, string>({
method: 'DELETE',
path: id => `/posts/${id}/`
Expand Down
52 changes: 49 additions & 3 deletions apps/admin-x-framework/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,64 @@ export function downloadFromEndpoint(path: string) {
downloadFile(`${getGhostPaths().apiRoot}${path}`);
}

/**
* Extracts the filename from a `Content-Disposition` header, preferring the
* RFC 5987 extended form (`filename*=`) over the basic `filename=` form.
* Returns `undefined` when neither is present.
*/
export function getFilenameFromContentDisposition(header: string | null): string | undefined {
if (!header) {
return undefined;
}

// RFC 5987 ext-value is charset'language'value; capture it with one linear
// pattern (no nested quantifiers — avoids ReDoS), then drop the prefix.
const extendedMatch = header.match(/filename\*=([^;]+)/i);
if (extendedMatch?.[1]) {
const singleQuote = '\'';
const extValue = extendedMatch[1].trim();
const firstQuote = extValue.indexOf(singleQuote);
const secondQuote = firstQuote === -1 ? -1 : extValue.indexOf(singleQuote, firstQuote + 1);
const encoded = secondQuote === -1 ? extValue : extValue.slice(secondQuote + 1);
try {
return decodeURIComponent(encoded.replace(/^["']|["']$/g, ''));
} catch {
// Malformed encoding - fall through to the basic form
}
}

const quotedMatch = header.match(/filename="([^"]*)"/i);
if (quotedMatch?.[1]) {
return quotedMatch[1].trim();
}

const unquotedMatch = header.match(/filename=([^;]+)/i);
if (unquotedMatch?.[1]) {
return unquotedMatch[1].trim();
}

return undefined;
}

/**
* Downloads a file by fetching it as a blob and triggering a browser download.
* Use this instead of downloadFile/downloadFromEndpoint for streaming responses
* (e.g. large CSV exports) where the iframe approach may not work reliably.
*
* The filename comes from the response's `Content-Disposition` header;
* `fallbackFilename` is only used when the server omits it.
*/
export async function blobDownload(url: string, filename: string): Promise<void> {
export async function blobDownload(url: string, fallbackFilename?: string): Promise<void> {
const response = await fetch(url, {method: 'GET'});

if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
}

const filename = getFilenameFromContentDisposition(response.headers.get('content-disposition'))
?? fallbackFilename
?? 'download';

const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
Expand All @@ -57,7 +103,7 @@ export async function blobDownload(url: string, filename: string): Promise<void>
window.URL.revokeObjectURL(blobUrl);
}

export async function blobDownloadFromEndpoint(path: string, filename: string): Promise<void> {
export async function blobDownloadFromEndpoint(path: string, fallbackFilename?: string): Promise<void> {
const url = `${getGhostPaths().apiRoot}${path}`;
return blobDownload(url, filename);
return blobDownload(url, fallbackFilename);
}
72 changes: 62 additions & 10 deletions apps/admin-x-framework/test/unit/utils/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {vi, type MockInstance} from 'vitest';
import {getGhostPaths, downloadFile, downloadFromEndpoint, blobDownload, blobDownloadFromEndpoint} from '../../../src/utils/helpers';
import {getGhostPaths, downloadFile, downloadFromEndpoint, blobDownload, blobDownloadFromEndpoint, getFilenameFromContentDisposition} from '../../../src/utils/helpers';

describe('helpers utils', () => {
// Store original values
Expand Down Expand Up @@ -179,6 +179,7 @@ describe('helpers utils', () => {
const mockBlob = new Blob(['test,data'], {type: 'text/csv'});
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
blob: () => Promise.resolve(mockBlob)
});

Expand All @@ -191,27 +192,43 @@ describe('helpers utils', () => {
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/fake-blob-url');
});

it('sets the correct filename on the download link', async () => {
it('names the file from the Content-Disposition header', async () => {
const mockBlob = new Blob(['test'], {type: 'text/csv'});
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers({'content-disposition': 'Attachment; filename="my-site.ghost.members.2026-02-17.csv"'}),
blob: () => Promise.resolve(mockBlob)
});

let capturedElement: any;
vi.spyOn(document, 'createElement').mockImplementation(() => {
capturedElement = {
href: '',
download: '',
click: vi.fn(),
remove: vi.fn()
};
capturedElement = {href: '', download: '', click: vi.fn(), remove: vi.fn()};
return capturedElement as unknown as HTMLElement;
});

// The header wins even when a fallback is supplied
await blobDownload('https://example.com/export.csv', 'fallback.csv');

expect(capturedElement.download).toBe('my-site.ghost.members.2026-02-17.csv');
});

it('falls back to the provided filename when no Content-Disposition header is present', async () => {
const mockBlob = new Blob(['test'], {type: 'text/csv'});
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
blob: () => Promise.resolve(mockBlob)
});

let capturedElement: any;
vi.spyOn(document, 'createElement').mockImplementation(() => {
capturedElement = {href: '', download: '', click: vi.fn(), remove: vi.fn()};
return capturedElement as unknown as HTMLElement;
});

await blobDownload('https://example.com/export.csv', 'members.2026-02-17.csv');
await blobDownload('https://example.com/export.csv', 'members.csv');

expect(capturedElement.download).toBe('members.2026-02-17.csv');
expect(capturedElement.download).toBe('members.csv');
});

it('throws on non-ok response', async () => {
Expand Down Expand Up @@ -268,6 +285,7 @@ describe('helpers utils', () => {
const mockBlob = new Blob(['data'], {type: 'text/csv'});
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
blob: () => Promise.resolve(mockBlob)
});

Expand All @@ -284,6 +302,7 @@ describe('helpers utils', () => {
const mockBlob = new Blob(['data'], {type: 'text/csv'});
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
blob: () => Promise.resolve(mockBlob)
});

Expand All @@ -295,4 +314,37 @@ describe('helpers utils', () => {
);
});
});

describe('getFilenameFromContentDisposition', () => {
it('returns undefined for a missing header', () => {
expect(getFilenameFromContentDisposition(null)).toBeUndefined();
expect(getFilenameFromContentDisposition('')).toBeUndefined();
});

it('parses a quoted filename', () => {
expect(getFilenameFromContentDisposition('Attachment; filename="my-site.ghost.members.2026-02-17.csv"'))
.toBe('my-site.ghost.members.2026-02-17.csv');
});

it('parses an unquoted filename', () => {
expect(getFilenameFromContentDisposition('attachment; filename=members.csv'))
.toBe('members.csv');
});

it('prefers the RFC 5987 extended (filename*) form and decodes it', () => {
expect(getFilenameFromContentDisposition('attachment; filename="fallback.csv"; filename*=UTF-8\'\'caf%C3%A9.members.csv'))
.toBe('café.members.csv');
});

it('handles a non-empty language tag and non-UTF-8 charset in the extended form', () => {
expect(getFilenameFromContentDisposition('attachment; filename*=UTF-8\'en\'caf%C3%A9.csv'))
.toBe('café.csv');
expect(getFilenameFromContentDisposition('attachment; filename*=ISO-8859-1\'\'file.csv'))
.toBe('file.csv');
});

it('returns undefined when no filename parameter is present', () => {
expect(getFilenameFromContentDisposition('attachment')).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,29 +1,11 @@
import React from 'react';
import {Button} from '@tryghost/admin-x-design-system';
import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers';
import {downloadAllContent} from '@tryghost/admin-x-framework/api/db';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {slugify} from '@tryghost/string';
import {useGlobalData} from '../../../providers/global-data-provider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {usePostsExports} from '@tryghost/admin-x-framework/api/posts';

export const getPostAnalyticsExportFileName = (siteTitle?: string | null) => {
const titlePrefix = siteTitle ? `${slugify(siteTitle)}.` : '';
const today = new Date().toISOString().split('T')[0];

return `${titlePrefix}ghost.analytics.${today}.csv`;
};

const MigrationToolsExport: React.FC = () => {
const [isExportingPosts, setIsExportingPosts] = React.useState(false);
const {settings} = useGlobalData();
const [siteTitle] = getSettingValues<string>(settings, ['title']);
const {refetch: postsData} = usePostsExports({
searchParams: {
limit: '1000'
},
enabled: false
});
const handleError = useHandleError();

const exportPosts = async () => {
Expand All @@ -34,19 +16,7 @@ const MigrationToolsExport: React.FC = () => {
setIsExportingPosts(true);

try {
const {data} = await postsData();
if (data) {
const blob = new Blob([data], {type: 'text/csv'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.setAttribute('hidden', '');
a.setAttribute('href', url);
a.setAttribute('download', getPostAnalyticsExportFileName(siteTitle));
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
await blobDownloadFromEndpoint('/posts/export/?limit=1000', 'posts.analytics.csv');
} catch (e) {
handleError(e);
} finally {
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-settings/src/utils/social-urls/linkedin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ const PUB_USERNAME_REGEX = /^[a-zA-Z0-9-]{3,100}(?:\/[a-zA-Z0-9-]*)*$/;
*/
const LINKEDIN_URL_REGEX = /^(?:https?:\/\/)?(?:www\.)?(?:([a-z]{2})\.)?linkedin\.com\/(in|pub|company|school)\/([^?#]+)/i;

// trims whitespace and removes leading @ if it exists
const formatUsername = (value: string) => value.trim().replace(/^@/, '');
// trims whitespace, removes a leading @, and removes a trailing slash if present
const formatUsername = (value: string) => value.trim().replace(/^@/, '').replace(/\/$/, '');

const extractInputParts = (input: string) => {
// Detect full URL patterns first
Expand Down
16 changes: 16 additions & 0 deletions apps/admin-x-settings/test/unit/utils/linkedin-urls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ describe('LinkedIn URLs', () => {
assert.equal(validateLinkedInUrl('johnsmith'), 'https://www.linkedin.com/in/johnsmith');
});

it('should accept LinkedIn URLs with a trailing slash and normalise them', () => {
assert.equal(validateLinkedInUrl('https://www.linkedin.com/in/johnsmith/'), 'https://www.linkedin.com/in/johnsmith');
assert.equal(validateLinkedInUrl('linkedin.com/in/johnsmith/'), 'https://www.linkedin.com/in/johnsmith');
assert.equal(validateLinkedInUrl('https://www.linkedin.com/company/ghost-foundation/'), 'https://www.linkedin.com/company/ghost-foundation');
});

it('should reject URLs from other domains', () => {
assert.throws(() => validateLinkedInUrl('https://twitter.com/johnsmith'), /The URL must be in a format like https:\/\/www\.linkedin\.com\/in\/yourUsername/);
assert.throws(() => validateLinkedInUrl('http://example.com'), /The URL must be in a format like https:\/\/www\.linkedin\.com\/in\/yourUsername/);
Expand All @@ -46,6 +52,11 @@ describe('LinkedIn URLs', () => {
assert.equal(linkedinHandleToUrl('john-smith'), 'https://www.linkedin.com/in/john-smith');
});

it('should strip a trailing slash from handles', () => {
assert.equal(linkedinHandleToUrl('johnsmith/'), 'https://www.linkedin.com/in/johnsmith');
assert.equal(linkedinHandleToUrl('company/ghost-foundation/'), 'https://www.linkedin.com/company/ghost-foundation');
});

it('should reject invalid LinkedIn handles', () => {
assert.throws(() => linkedinHandleToUrl('john@smith'), /Your Username is not a valid LinkedIn Username/);
assert.throws(() => linkedinHandleToUrl('john#smith'), /Your Username is not a valid LinkedIn Username/);
Expand All @@ -67,6 +78,11 @@ describe('LinkedIn URLs', () => {
assert.equal(linkedinUrlToHandle('linkedin.com/pub/johnsmith/12/34/567'), 'pub/johnsmith/12/34/567');
});

it('should strip trailing slash when extracting handle', () => {
assert.equal(linkedinUrlToHandle('https://www.linkedin.com/in/johnsmith/'), 'johnsmith');
assert.equal(linkedinUrlToHandle('https://www.linkedin.com/company/ghost-foundation/'), 'company/ghost-foundation');
});

it('should return null for invalid LinkedIn URLs', () => {
assert.equal(linkedinUrlToHandle('https://example.com/johnsmith'), null);
assert.equal(linkedinUrlToHandle('invalid-url'), null);
Expand Down

This file was deleted.

Loading
Loading