diff --git a/apps/activitypub/package.json b/apps/activitypub/package.json index fa29036eae1..a8d79f8f961 100644 --- a/apps/activitypub/package.json +++ b/apps/activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/activitypub", - "version": "3.1.23", + "version": "3.1.25", "license": "MIT", "repository": { "type": "git", diff --git a/apps/activitypub/src/api/activitypub.test.ts b/apps/activitypub/src/api/activitypub.test.ts index 2e141c81338..eab482dcb63 100644 --- a/apps/activitypub/src/api/activitypub.test.ts +++ b/apps/activitypub/src/api/activitypub.test.ts @@ -1597,6 +1597,190 @@ describe('ActivityPubAPI', function () { }); }); + describe('accountAliases', function () { + test('It fetches account aliases', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { + response: JSONResponse({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.getAccountAliases(); + + expect(result).toEqual({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + }); + }); + + test('It adds an account alias', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { + async assert(_resource, init) { + expect(init?.method).toEqual('POST'); + expect(init?.body).toEqual('{"sourceHandle":"@old@mastodon.social"}'); + }, + response: JSONResponse({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.addAccountAlias('@old@mastodon.social'); + + expect(result.aliases).toEqual([{ + apId: 'https://mastodon.social/users/old' + }]); + }); + + test('It returns an empty alias response when adding an account alias has no response body', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { + response: new Response(null, {status: 204}) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.addAccountAlias('@old@mastodon.social'); + + expect(result).toEqual({ + destination: { + handle: '', + apId: '' + }, + aliases: [] + }); + }); + + test('It removes an account alias', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { + async assert(_resource, init) { + expect(init?.method).toEqual('DELETE'); + expect(init?.body).toEqual('{"actorUri":"https://mastodon.social/users/old"}'); + }, + response: JSONResponse({ + destination: { + handle: '@index@example.com', + apId: 'https://example.com/.ghost/activitypub/users/index' + }, + aliases: [] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.removeAccountAlias('https://mastodon.social/users/old'); + + expect(result.aliases).toEqual([]); + }); + + test('It returns an empty alias response when removing an account alias has no response body', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/v1/aliases`]: { + response: new Response(null, {status: 204}) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const result = await api.removeAccountAlias('https://mastodon.social/users/old'); + + expect(result).toEqual({ + destination: { + handle: '', + apId: '' + }, + aliases: [] + }); + }); + }); + describe('enableBluesky', function () { test('It enables bluesky', async function () { const fakeFetch = Fetch({ diff --git a/apps/activitypub/src/api/activitypub.ts b/apps/activitypub/src/api/activitypub.ts index 14021e29128..504eb502a3d 100644 --- a/apps/activitypub/src/api/activitypub.ts +++ b/apps/activitypub/src/api/activitypub.ts @@ -102,6 +102,39 @@ export type AccountFollowsType = 'following' | 'followers'; type GetAccountResponse = Account +export interface AccountAlias { + apId: string; +} + +export interface AccountAliasesResponse { + destination: { + handle: string; + apId: string; + }; + aliases: AccountAlias[]; +} + +function emptyAccountAliasesResponse(): AccountAliasesResponse { + return { + destination: { + handle: '', + apId: '' + }, + aliases: [] + }; +} + +function parseAccountAliasesResponse(json: object | null): AccountAliasesResponse { + if (json === null || !('destination' in json)) { + return emptyAccountAliasesResponse(); + } + + return { + destination: json.destination as AccountAliasesResponse['destination'], + aliases: 'aliases' in json && Array.isArray(json.aliases) ? json.aliases as AccountAlias[] : [] + }; +} + export type FollowAccount = Pick & {isFollowing: true}; export interface GetAccountFollowsResponse { @@ -484,6 +517,27 @@ export class ActivityPubAPI { }; } + async getAccountAliases(): Promise { + const url = new URL('.ghost/activitypub/v1/aliases', this.apiUrl); + const json = await this.fetchJSON(url); + + return parseAccountAliasesResponse(json); + } + + async addAccountAlias(sourceHandle: string): Promise { + const url = new URL('.ghost/activitypub/v1/aliases', this.apiUrl); + const json = await this.fetchJSON(url, 'POST', {sourceHandle}); + + return parseAccountAliasesResponse(json); + } + + async removeAccountAlias(actorUri: string): Promise { + const url = new URL('.ghost/activitypub/v1/aliases', this.apiUrl); + const json = await this.fetchJSON(url, 'DELETE', {actorUri}); + + return parseAccountAliasesResponse(json); + } + async getFeed(next?: string): Promise { return this.getPaginatedPosts('.ghost/activitypub/v1/feed/notes', next); } diff --git a/apps/activitypub/src/hooks/use-activity-pub-queries.ts b/apps/activitypub/src/hooks/use-activity-pub-queries.ts index d5c1d6c6b96..29139dbf7e4 100644 --- a/apps/activitypub/src/hooks/use-activity-pub-queries.ts +++ b/apps/activitypub/src/hooks/use-activity-pub-queries.ts @@ -1,5 +1,6 @@ import { type Account, + type AccountAliasesResponse, type AccountFollowsType, type AccountSearchResult, ActivityPubAPI, @@ -74,6 +75,7 @@ const QUERY_KEYS = { return ['profile_posts', profileHandle]; }, account: (handle: string) => ['account', handle], + accountAliases: (handle: string) => ['account_aliases', handle], accountFollows: (handle: string, type: AccountFollowsType) => ['account_follows', handle, type], searchResults: (query: string) => ['search_results', query], suggestedProfiles: (handle: string, limit: number) => ['suggested_profiles', handle, limit], @@ -1654,6 +1656,50 @@ export function useAccountFollowsForUser(profileHandle: string, type: AccountFol }); } +export function useAccountAliasesForUser(handle: string) { + return useQuery({ + queryKey: QUERY_KEYS.accountAliases(handle), + async queryFn() { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + + return api.getAccountAliases(); + } + }); +} + +export function useAddAccountAliasMutationForUser(handle: string) { + const queryClient = useQueryClient(); + + return useMutation({ + async mutationFn(sourceHandle: string) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + + return api.addAccountAlias(sourceHandle); + }, + onSuccess(response: AccountAliasesResponse) { + queryClient.setQueryData(QUERY_KEYS.accountAliases(handle), response); + } + }); +} + +export function useRemoveAccountAliasMutationForUser(handle: string) { + const queryClient = useQueryClient(); + + return useMutation({ + async mutationFn(actorUri: string) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + + return api.removeAccountAlias(actorUri); + }, + onSuccess(response: AccountAliasesResponse) { + queryClient.setQueryData(QUERY_KEYS.accountAliases(handle), response); + } + }); +} + export function useFeedForUser(options: {enabled: boolean}) { const queryKey = QUERY_KEYS.feed; const queryClient = useQueryClient(); diff --git a/apps/activitypub/src/routes.tsx b/apps/activitypub/src/routes.tsx index 3ee76ceff17..cef3dca4348 100644 --- a/apps/activitypub/src/routes.tsx +++ b/apps/activitypub/src/routes.tsx @@ -3,6 +3,7 @@ import AppError from '@components/layout/error'; import {Navigate, Outlet, RouteObject, lazyComponent} from '@tryghost/admin-x-framework'; const basePath = import.meta.env.VITE_TEST ? '' : 'activitypub'; +const accountMigrationPath = 'preferences/move'; export type CustomRouteObject = RouteObject & { pageTitle?: string; @@ -106,6 +107,12 @@ export const routes: CustomRouteObject[] = [ lazy: lazyComponent(() => import('./views/preferences/components/bluesky-sharing')), showBackButton: true }, + { + path: accountMigrationPath, + lazy: lazyComponent(() => import('./views/preferences/components/account-migration')), + pageTitle: 'Account migration', + showBackButton: true + }, { path: 'welcome', lazy: lazyComponent(() => import('./components/layout/onboarding')), diff --git a/apps/activitypub/src/views/preferences/components/account-migration.tsx b/apps/activitypub/src/views/preferences/components/account-migration.tsx new file mode 100644 index 00000000000..0b05931776a --- /dev/null +++ b/apps/activitypub/src/views/preferences/components/account-migration.tsx @@ -0,0 +1,201 @@ +import Layout from '@src/components/layout'; +import React, {useState} from 'react'; +import {Button, Field, FieldDescription, FieldError, FieldLabel, Input, LoadingIndicator, Skeleton} from '@tryghost/shade/components'; +import {H2} from '@tryghost/shade/primitives'; +import {LucideIcon} from '@tryghost/shade/utils'; +import {useAccountAliasesForUser, useAddAccountAliasMutationForUser, useRemoveAccountAliasMutationForUser} from '@hooks/use-activity-pub-queries'; + +const HANDLE_REGEX = /^@?[^@\s]+@[^@\s]+$/; + +function normalizeHandle(handle: string) { + const trimmedHandle = handle.trim(); + + return trimmedHandle.startsWith('@') ? trimmedHandle : `@${trimmedHandle}`; +} + +function getAliasDisplayHandle(actorUri: string) { + try { + const url = new URL(actorUri); + const mastodonUserMatch = url.pathname.match(/^\/users\/([^/]+)\/?$/); + + if (mastodonUserMatch) { + return `${decodeURIComponent(mastodonUserMatch[1])}@${url.hostname}`; + } + + return `${url.hostname}${url.pathname}`; + } catch { + return actorUri; + } +} + +function getAliasErrorMessage(error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'statusCode' in error && + typeof error.statusCode === 'number' + ) { + if (error.statusCode === 400) { + return 'Enter a valid handle, like old@mastodon.social.'; + } + + if (error.statusCode === 404) { + return 'Could not find that profile. Check the handle and try again.'; + } + } + + return 'Something went wrong, please try again.'; +} + +const AccountMigration: React.FC = () => { + const { + data: aliasData, + isError: hasAliasLoadError, + isLoading: isLoadingAliases, + refetch: refetchAliases + } = useAccountAliasesForUser('index'); + const addAliasMutation = useAddAccountAliasMutationForUser('index'); + const removeAliasMutation = useRemoveAccountAliasMutationForUser('index'); + const [sourceHandle, setSourceHandle] = useState(''); + const [handleError, setHandleError] = useState(null); + const [aliasActionError, setAliasActionError] = useState(null); + const [removingAlias, setRemovingAlias] = useState(null); + + const aliases = [...(aliasData?.aliases ?? [])].reverse(); + const showAliasesSection = isLoadingAliases || hasAliasLoadError || aliases.length > 0; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedHandle = sourceHandle.trim(); + + if (!HANDLE_REGEX.test(trimmedHandle)) { + setHandleError('Enter a valid handle, like old@mastodon.social.'); + return; + } + + setHandleError(null); + setAliasActionError(null); + + try { + const normalizedHandle = normalizeHandle(trimmedHandle); + await addAliasMutation.mutateAsync(normalizedHandle); + setSourceHandle(''); + } catch (error) { + setHandleError(getAliasErrorMessage(error)); + } + }; + + const handleRemoveAlias = async (actorUri: string) => { + setAliasActionError(null); + setRemovingAlias(actorUri); + + try { + await removeAliasMutation.mutateAsync(actorUri); + } catch { + setAliasActionError('Could not remove migration profile.'); + } finally { + setRemovingAlias(null); + } + }; + + return ( + +
+
+

Account migration

+
+ +
+

+ You can move your followers from another social web account (eg. Mastodon) to this one by creating an account alias. This action is harmless and reversible. The account migration is initiated from the old account. +

+
+ +
+ + + Old account handle + + + Specify the username@domain of the account you want to move from + +
+ setSourceHandle(event.target.value)} + /> + +
+ {handleError && ( + + {handleError} + + )} + {aliasActionError && ( + + {aliasActionError} + + )} +
+
+ + {showAliasesSection && ( +
+
+ +
Account aliases
+
+
+ + {isLoadingAliases ? ( +
+ +
+ ) : hasAliasLoadError ? ( +
+ Could not load account aliases. + +
+ ) : ( +
+ {aliases.map(alias => ( +
+
{getAliasDisplayHandle(alias.apId)}
+ +
+ ))} +
+ )} +
+ )} +
+
+ ); +}; + +export default AccountMigration; diff --git a/apps/activitypub/src/views/preferences/components/settings.tsx b/apps/activitypub/src/views/preferences/components/settings.tsx index ad1687a4eef..258d9a6883c 100644 --- a/apps/activitypub/src/views/preferences/components/settings.tsx +++ b/apps/activitypub/src/views/preferences/components/settings.tsx @@ -5,6 +5,7 @@ import {Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger} import {H4} from '@tryghost/shade/primitives'; import {Link} from '@tryghost/admin-x-framework'; import {LucideIcon, cn} from '@tryghost/shade/utils'; +import {useAppBasePath} from '@src/hooks/use-app-base-path'; import {useNavigateWithBasePath} from '@src/hooks/use-navigate-with-base-path'; interface SettingsProps { @@ -21,14 +22,14 @@ const Settings: React.FC = ({account, className = ''}) => { - Account + Profile Edit your profile information and account details - + e.preventDefault()}> @@ -57,6 +58,15 @@ const Settings: React.FC = ({account, className = ''}) => { + + + Account migration + Move another social web account to this one + + + + + @@ -120,15 +130,17 @@ interface SettingItemProps { } const SettingItem: React.FC = ({children, className = '', withHover = false, to, href, onClick}) => { + const basePath = useAppBasePath(); const baseClasses = 'flex items-center justify-between py-3 gap-4'; const hoverClasses = withHover ? 'relative cursor-pointer before:absolute before:inset-x-[-16px] before:inset-y-[-1px] before:rounded-md before:bg-gray-50 before:opacity-0 before:transition-opacity before:will-change-[opacity] hover:z-10 hover:cursor-pointer hover:border-b-transparent hover:before:opacity-100 dark:before:bg-gray-950' : ''; const itemClasses = cn(baseClasses, hoverClasses, className); + const fullPath = to && to.startsWith('/') ? `${basePath}${to}` : to; - if (to) { + if (fullPath) { return ( {children} diff --git a/apps/activitypub/test/acceptance/preferences.test.ts b/apps/activitypub/test/acceptance/preferences.test.ts new file mode 100644 index 00000000000..24e5c112c40 --- /dev/null +++ b/apps/activitypub/test/acceptance/preferences.test.ts @@ -0,0 +1,163 @@ +import {expect, test} from '@playwright/test'; +import {mockApi} from '@tryghost/admin-x-framework/test/acceptance'; +import {mockInitialApiRequests} from '../utils/initial-api-requests'; + +const account = { + id: 'alice', + handle: '@alice@fake.host', + name: 'Alice', + url: 'https://fake.host/@alice', + avatarUrl: 'https://fake.host/avatars/alice.jpg', + followingCount: 5, + followerCount: 10, + likedCount: 3 +}; + +test.describe('Preferences', async () => { + test.beforeEach(async ({page}) => { + await mockInitialApiRequests(page); + }); + + test('I can add an old Mastodon handle for follower migration', async ({page}) => { + const {lastApiRequests} = await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [] + } + }, + addAlias: { + method: 'POST', + path: '/v1/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [{ + apId: 'https://mastodon.social/users/old' + }] + } + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences'); + + await expect(page.getByRole('link', {name: /Account migration/})).toBeVisible(); + await page.getByRole('link', {name: /Account migration/}).click(); + + await expect(page.getByRole('heading', {name: 'Account migration'})).toBeVisible(); + await expect.poll(() => lastApiRequests.getAliases).toBeTruthy(); + await expect(page.getByTestId('account-migration-aliases')).toHaveCount(0); + await page.getByLabel('Old account handle').fill('old@mastodon.social'); + await page.getByRole('button', {name: 'Create alias'}).click(); + + await expect.poll(() => lastApiRequests.addAlias).toBeTruthy(); + expect(lastApiRequests.addAlias?.body).toMatchObject({ + sourceHandle: '@old@mastodon.social' + }); + await expect(page.getByTestId('account-migration-aliases')).toContainText('old@mastodon.social'); + }); + + test('I can see existing aliases newest first', async ({page}) => { + await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [ + {apId: 'https://mastodon.social/users/old'}, + {apId: 'https://mastodon.social/users/new'} + ] + } + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences/move'); + + const aliases = page.getByTestId('account-migration-aliases'); + await expect(aliases).toContainText('new@mastodon.social'); + await expect(aliases).toContainText('old@mastodon.social'); + + const aliasText = await aliases.textContent(); + const newAliasIndex = aliasText?.indexOf('new@mastodon.social') ?? -1; + const oldAliasIndex = aliasText?.indexOf('old@mastodon.social') ?? -1; + + expect(newAliasIndex).toBeGreaterThanOrEqual(0); + expect(oldAliasIndex).toBeGreaterThanOrEqual(0); + expect(newAliasIndex).toBeLessThan(oldAliasIndex); + }); + + test('I see an alias action error when unlinking fails', async ({page}) => { + await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/aliases', + response: { + destination: { + handle: '@alice@fake.host', + apId: 'https://fake.host/.ghost/activitypub/users/index' + }, + aliases: [{apId: 'https://mastodon.social/users/old'}] + } + }, + removeAlias: { + method: 'DELETE', + path: '/v1/aliases', + response: {}, + responseStatus: 500 + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences/move'); + await page.getByRole('button', {name: 'Unlink'}).click(); + + await expect(page.getByText('Could not remove migration profile.')).toBeVisible(); + await expect(page.getByLabel('Old account handle')).not.toHaveAttribute('aria-invalid', 'true'); + }); + + test('I see an error when account aliases cannot be loaded', async ({page}) => { + await mockApi({page, requests: { + getMyAccount: { + method: 'GET', + path: '/v1/account/me', + response: account + }, + getAliases: { + method: 'GET', + path: '/v1/aliases', + response: {}, + responseStatus: 500 + } + }, options: {useActivityPub: true}}); + + await page.goto('#/preferences/move'); + + await expect(page.getByTestId('account-migration-aliases')).toContainText('Could not load account aliases.'); + await expect(page.getByRole('button', {name: 'Retry'})).toBeVisible(); + }); +});