Skip to content

Commit e8ecdaf

Browse files
Added block and rate limit errors in ActivityPub (#24370)
ref https://linear.app/ghost/issue/PROD-2261 - the infrastructure will return HTTP 429/empty body when a user is rate-limited, and HTTP 403/empty body when a user is blocked - added support for non-json error responses - added toast error for rate-limits, when the user is performing an action (e.g. liking a post) - added general error screen for rate-limits and blocks when reloading screens --------- Co-authored-by: Sodbileg Gansukh <[email protected]>
1 parent 5f1630e commit e8ecdaf

File tree

10 files changed

+195
-55
lines changed

10 files changed

+195
-55
lines changed

apps/admin-x-activitypub/src/api/activitypub.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,22 @@ export interface PaginatedPostsResponse {
192192
next: string | null;
193193
}
194194

195+
export type ApiError = {
196+
message: string;
197+
statusCode: number;
198+
};
199+
200+
export const isApiError = (error: unknown): error is ApiError => {
201+
return (
202+
typeof error === 'object' &&
203+
error !== null &&
204+
'statusCode' in error &&
205+
'message' in error &&
206+
typeof error.statusCode === 'number' &&
207+
typeof error.message === 'string'
208+
);
209+
};
210+
195211
export class ActivityPubAPI {
196212
constructor(
197213
private readonly apiUrl: URL,
@@ -230,17 +246,27 @@ export class ActivityPubAPI {
230246
return null;
231247
}
232248

233-
const json = await response.json();
234-
235249
if (!response.ok) {
236-
const error = {
237-
message: json?.message || json?.error || 'Unexpected Error',
250+
const error: ApiError = {
251+
message: 'Something went wrong, please try again.',
238252
statusCode: response.status
239253
};
254+
255+
try {
256+
const json = await response.json();
257+
const errorMessage = json.message || json.error;
258+
259+
if (errorMessage) {
260+
error.message = errorMessage;
261+
}
262+
} catch {
263+
// Leave the default message
264+
}
265+
240266
throw error;
241267
}
242268

243-
return json;
269+
return await response.json();
244270
}
245271

246272
async blockDomain(domain: URL): Promise<boolean> {

apps/admin-x-activitypub/src/components/global/EmptyViewIndicator.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ export const EmptyViewIcon: React.FC<{children?: ReactNode}> = ({children}) => {
77
);
88
};
99

10-
export const EmptyViewIndicator: React.FC<{children?: ReactNode}> = ({children}) => {
10+
export const EmptyViewIndicator: React.FC<{children?: ReactNode; className?: string}> = ({children, className}) => {
1111
return (
12-
<div className='mx-auto mt-[24vh] flex max-w-[500px] flex-col items-center gap-5 text-center text-gray-700'>
12+
<div className={`mx-auto mt-[24vh] flex max-w-[500px] flex-col items-center gap-5 text-center text-gray-700 ${className || ''}`}>
1313
{children}
1414
</div>
1515
);

apps/admin-x-activitypub/src/components/layout/Error/Error.tsx

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,64 @@
11
import Layout from '@components/layout/Layout';
2+
import {Button, H4, LucideIcon} from '@tryghost/shade';
23
import {EmptyViewIcon, EmptyViewIndicator} from '@src/components/global/EmptyViewIndicator';
3-
import {H3, LucideIcon} from '@tryghost/shade';
44
import {useNavigate} from '@tryghost/admin-x-framework';
55
import {useRouteError} from 'react-router';
66

7-
const Error = () => {
8-
const error = useRouteError();
7+
const Error = ({statusCode}: {statusCode?: number}) => {
8+
const routeError = useRouteError();
99
const navigate = useNavigate();
1010

1111
const toDashboard = (e: React.MouseEvent<HTMLElement>) => {
1212
e.preventDefault();
1313
navigate('/dashboard', {crossApp: true});
1414
};
1515

16-
return (
17-
!error ? (
16+
if (routeError) {
17+
return (
1818
<Layout>
1919
<EmptyViewIndicator>
2020
<EmptyViewIcon><LucideIcon.SearchX /></EmptyViewIcon>
21-
<H3 className='-mb-3'>Oops, page not found!</H3>
21+
<H4 className='-mb-4'>Oops, page not found!</H4>
2222
<div>We couldn&apos;t find the page you were looking for. It may have been moved, deleted, or never existed in the first place.</div>
2323
</EmptyViewIndicator>
2424
</Layout>
25-
) : (
26-
<div className="admin-x-container-error">
27-
<div className="admin-x-error max-w-xl">
28-
<h1>Loading interrupted</h1>
29-
<p>They say life is a series of trials and tribulations. This moment right here? It&apos;s a tribulation. Our app was supposed to load, and yet here we are. Loadless. Click back to the dashboard to try again.</p>
30-
<a className='cursor-pointer text-green' onClick={toDashboard}>&larr; Back to the dashboard</a>
31-
</div>
25+
);
26+
}
27+
28+
if (statusCode === 429) {
29+
return (
30+
<EmptyViewIndicator className='mt-[50vh] -translate-y-1/2'>
31+
<EmptyViewIcon><LucideIcon.TriangleAlert /></EmptyViewIcon>
32+
<H4 className='-mb-4'>Rate limit exceeded</H4>
33+
<div>You&apos;ve made too many requests. Please try again in a moment.</div>
34+
<Button asChild>
35+
<a href="https://ghost.org/help/social-web/" rel="noopener noreferrer" target="_blank">Learn more &rarr;</a>
36+
</Button>
37+
</EmptyViewIndicator>
38+
);
39+
}
40+
41+
if (statusCode === 403) {
42+
return (
43+
<EmptyViewIndicator className='mt-[50vh] -translate-y-1/2'>
44+
<EmptyViewIcon><LucideIcon.Ban /></EmptyViewIcon>
45+
<H4 className='-mb-4'>Account suspended</H4>
46+
<div>Your account has been suspended due to policy violations.</div>
47+
<Button asChild>
48+
<a href="https://ghost.org/help/social-web/" rel="noopener noreferrer" target="_blank">Learn more &rarr;</a>
49+
</Button>
50+
</EmptyViewIndicator>
51+
);
52+
}
53+
54+
return (
55+
<div className="admin-x-container-error">
56+
<div className="admin-x-error max-w-xl">
57+
<h1>Loading interrupted</h1>
58+
<p>They say life is a series of trials and tribulations. This moment right here? It&apos;s a tribulation. Our app was supposed to load, and yet here we are. Loadless. Click back to the dashboard to try again.</p>
59+
<a className='cursor-pointer text-green' onClick={toDashboard}>&larr; Back to the dashboard</a>
3260
</div>
33-
)
61+
</div>
3462
);
3563
};
3664

apps/admin-x-activitypub/src/hooks/use-activity-pub-queries.ts

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ function createActivityPubAPI(handle: string, siteUrl: string) {
4747
);
4848
}
4949

50+
function renderRateLimitError(
51+
title = 'Rate limit exceeded',
52+
description = 'You\'ve made too many requests. Please try again later.'
53+
) {
54+
toast.error(title, {description});
55+
}
56+
57+
function renderBlockedError(
58+
title = 'Action failed',
59+
description = 'This user has restricted who can interact with their account.'
60+
) {
61+
toast.error(title, {description});
62+
}
63+
5064
const QUERY_KEYS = {
5165
outbox: (handle: string) => ['outbox', handle],
5266
liked: (handle: string) => ['liked', handle],
@@ -559,9 +573,11 @@ export function useLikeMutationForUser(handle: string) {
559573
updateNotificationsLikedCache(queryClient, handle, id, false);
560574

561575
if (error.statusCode === 403) {
562-
toast.error('Action failed', {
563-
description: 'This user has restricted who can interact with their account.'
564-
});
576+
renderBlockedError();
577+
}
578+
579+
if (error.statusCode === 429) {
580+
renderRateLimitError();
565581
}
566582
}
567583
});
@@ -581,6 +597,11 @@ export function useUnlikeMutationForUser(handle: string) {
581597
updateLikeCache(queryClient, handle, id, false);
582598
updateLikeCacheOnce(queryClient, id, false);
583599
updateNotificationsLikedCache(queryClient, handle, id, false);
600+
},
601+
onError(error: {message: string, statusCode: number}) {
602+
if (error.statusCode === 429) {
603+
renderRateLimitError();
604+
}
584605
}
585606
});
586607
}
@@ -613,6 +634,11 @@ export function useBlockDomainMutationForUser(handle: string) {
613634
};
614635
}
615636
);
637+
},
638+
onError(error: {message: string, statusCode: number}) {
639+
if (error.statusCode === 429) {
640+
renderRateLimitError();
641+
}
616642
}
617643
});
618644
}
@@ -643,6 +669,11 @@ export function useUnblockDomainMutationForUser(handle: string) {
643669
};
644670
}
645671
);
672+
},
673+
onError(error: {message: string, statusCode: number}) {
674+
if (error.statusCode === 429) {
675+
renderRateLimitError();
676+
}
646677
}
647678
});
648679
}
@@ -674,6 +705,11 @@ export function useBlockMutationForUser(handle: string) {
674705
);
675706
queryClient.invalidateQueries({queryKey: QUERY_KEYS.feed});
676707
queryClient.invalidateQueries({queryKey: QUERY_KEYS.inbox});
708+
},
709+
onError(error: {message: string, statusCode: number}) {
710+
if (error.statusCode === 429) {
711+
renderRateLimitError();
712+
}
677713
}
678714
});
679715
}
@@ -701,6 +737,11 @@ export function useUnblockMutationForUser(handle: string) {
701737
};
702738
}
703739
);
740+
},
741+
onError(error: {message: string, statusCode: number}) {
742+
if (error.statusCode === 429) {
743+
renderRateLimitError();
744+
}
704745
}
705746
});
706747
}
@@ -806,9 +847,10 @@ export function useRepostMutationForUser(handle: string) {
806847
updateRepostCacheOnce(queryClient, id, false);
807848
updateNotificationsRepostCache(queryClient, handle, id, false);
808849
if (error.statusCode === 403) {
809-
toast.error('Action failed', {
810-
description: 'This user has restricted who can interact with their account.'
811-
});
850+
renderBlockedError();
851+
}
852+
if (error.statusCode === 429) {
853+
renderRateLimitError();
812854
}
813855
}
814856
});
@@ -828,6 +870,11 @@ export function useDerepostMutationForUser(handle: string) {
828870
updateRepostCache(queryClient, handle, id, false);
829871
updateRepostCacheOnce(queryClient, id, false);
830872
updateNotificationsRepostCache(queryClient, handle, id, false);
873+
},
874+
onError(error: {message: string, statusCode: number}) {
875+
if (error.statusCode === 429) {
876+
renderRateLimitError();
877+
}
831878
}
832879
});
833880
}
@@ -1010,7 +1057,12 @@ export function useUnfollowMutationForUser(handle: string, onSuccess: () => void
10101057

10111058
onSuccess();
10121059
},
1013-
onError
1060+
onError: (error: {message: string, statusCode: number}) => {
1061+
if (error.statusCode === 429) {
1062+
renderRateLimitError();
1063+
}
1064+
onError();
1065+
}
10141066
});
10151067
}
10161068

@@ -1176,10 +1228,12 @@ export function useFollowMutationForUser(handle: string, onSuccess: () => void,
11761228
onError(error: {message: string, statusCode: number}) {
11771229
onError();
11781230

1231+
if (error.statusCode === 429) {
1232+
renderRateLimitError();
1233+
}
1234+
11791235
if (error.statusCode === 403) {
1180-
toast.error('Action failed', {
1181-
description: 'This user has restricted who can interact with their account.'
1182-
});
1236+
renderBlockedError();
11831237
}
11841238
}
11851239
});
@@ -1368,10 +1422,13 @@ export function useReplyMutationForUser(handle: string, actorProps?: ActorProper
13681422
// in the thread as this is handled locally in the ArticleModal component
13691423

13701424
if (error.statusCode === 403) {
1371-
return toast.error('Action failed', {
1372-
description: 'This user has restricted who can interact with their account.'
1373-
});
1425+
renderBlockedError();
1426+
}
1427+
1428+
if (error.statusCode === 429) {
1429+
renderRateLimitError();
13741430
}
1431+
13751432
toast.error('An error occurred while sending your reply.');
13761433
}
13771434
});
@@ -1419,14 +1476,18 @@ export function useNoteMutationForUser(handle: string, actorProps?: ActorPropert
14191476
updateActivityInPaginatedCollection(queryClient, queryKeyOutbox, 'data', context?.id ?? '', () => activity);
14201477
updateActivityInPaginatedCollection(queryClient, queryKeyPostsByAccount, 'posts', context?.id ?? '', () => activity);
14211478
},
1422-
onError(error, _variables, context) {
1479+
onError(error: {message: string, statusCode: number}, _variables, context) {
14231480
// eslint-disable-next-line no-console
14241481
console.error(error);
14251482

14261483
removeActivityFromPaginatedCollection(queryClient, queryKeyFeed, 'posts', context?.id ?? '');
14271484
removeActivityFromPaginatedCollection(queryClient, queryKeyOutbox, 'data', context?.id ?? '');
14281485
removeActivityFromPaginatedCollection(queryClient, queryKeyPostsByAccount, 'posts', context?.id ?? '');
14291486

1487+
if (error.statusCode === 429) {
1488+
renderRateLimitError();
1489+
}
1490+
14301491
toast.error('An error occurred while posting your note.');
14311492
}
14321493
});
@@ -2280,7 +2341,7 @@ function useFilteredAccountsFromJSON(options: {
22802341
}, [followingIds, blockedAccountIds, blockedDomains, excludeFollowing, excludeCurrentUser, currentUser]);
22812342

22822343
const isLoading = isLoadingFollowing || isLoadingBlockedAccounts || isLoadingBlockedDomains || isLoadingCurrentUser;
2283-
2344+
22842345
// Track if we have finished loading all following data
22852346
const isFollowingDataComplete = !isLoadingFollowing && !hasNextPage;
22862347

apps/admin-x-activitypub/src/views/Feed/Feed.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
1+
import Error from '@components/layout/Error';
12
import FeedList from './components/FeedList';
23
import React from 'react';
4+
import {isApiError} from '@src/api/activitypub';
35
import {
46
useFeedForUser,
57
useUserDataForUser
68
} from '@hooks/use-activity-pub-queries';
79

810
const Feed: React.FC = () => {
911
const {feedQuery} = useFeedForUser({enabled: true});
10-
11-
const feedQueryData = feedQuery;
12-
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = feedQueryData;
12+
const {data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = feedQuery;
1313

1414
const activities = (data?.pages.flatMap(page => page.posts) ?? Array.from({length: 5}, (_, index) => ({id: `placeholder-${index}`, object: {}})));
1515

1616
const {data: user} = useUserDataForUser('index');
1717

18+
if (error && isApiError(error)) {
19+
return <Error statusCode={error.statusCode}/>;
20+
}
21+
1822
return <FeedList
1923
activities={activities}
2024
fetchNextPage={fetchNextPage}

apps/admin-x-activitypub/src/views/Inbox/Inbox.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
import Error from '@components/layout/Error';
12
import InboxList from './components/InboxList';
23
import React from 'react';
4+
import {isApiError} from '@src/api/activitypub';
35
import {useInboxForUser} from '@hooks/use-activity-pub-queries';
46

57
const Inbox: React.FC = () => {
68
const {inboxQuery} = useInboxForUser({enabled: true});
79
const feedQueryData = inboxQuery;
8-
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = feedQueryData;
10+
const {data, error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = feedQueryData;
911

1012
const activities = (data?.pages.flatMap(page => page.posts) ?? Array.from({length: 5}, (_, index) => ({id: `placeholder-${index}`, object: {}})));
1113

14+
if (error && isApiError(error)) {
15+
return <Error statusCode={error.statusCode}/>;
16+
}
17+
1218
return <InboxList
1319
activities={activities}
1420
fetchNextPage={fetchNextPage}

0 commit comments

Comments
 (0)