From c6e407fb7e2f1206db28753ab7047f13d995b4a0 Mon Sep 17 00:00:00 2001 From: Fabien O'Carroll Date: Mon, 22 Jul 2024 14:44:47 +0700 Subject: [PATCH] Created ActivityPubAPI module ref https://linear.app/tryghost/issue/MOM-288 Instead of having all of our network code inside of admin-x-framework, we're moving it into activitypub to make it easier to work with. We've also added support for using identity tokens for authentication with the ActivityPub API. --- .../src/api/activitypub.test.ts | 366 ++++++++++++++++++ .../src/api/activitypub.ts | 109 ++++++ 2 files changed, 475 insertions(+) create mode 100644 apps/admin-x-activitypub/src/api/activitypub.test.ts create mode 100644 apps/admin-x-activitypub/src/api/activitypub.ts diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts new file mode 100644 index 0000000000..3e742b65f4 --- /dev/null +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -0,0 +1,366 @@ +import {Activity, ActivityPubAPI} from './activitypub'; + +function NotFound() { + return new Response(null, { + status: 404 + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function JSONResponse(data: any, contentType = 'application/json', status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': contentType + } + }); +} + +type Spec = { + response: Response, + assert?: (resource: URL, init?: RequestInit) => Promise +}; + +function Fetch(specs: Record) { + return async function (resource: URL, init?: RequestInit): Promise { + const spec = specs[resource.href]; + if (!spec) { + return NotFound(); + } + if (spec.assert) { + await spec.assert(resource, init); + } + return spec.response; + }; +} + +describe('ActivityPubAPI', function () { + describe('getInbox', function () { + test('It passes the token to the inbox endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.getInbox(); + }); + + test('Returns an empty array when the inbox is empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getInbox(); + const expected: never[] = []; + + expect(actual).toEqual(expected); + }); + + test('Returns an the items array when the inbox is not empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + response: + JSONResponse({ + type: 'Collection', + items: [{ + type: 'Create', + object: { + type: 'Note' + } + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getInbox(); + const expected: Activity[] = [ + { + type: 'Create', + object: { + type: 'Note' + } + } + ]; + + expect(actual).toEqual(expected); + }); + }); + + describe('getFollowing', function () { + test('It passes the token to the following endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.getFollowing(); + }); + + test('Returns an empty array when the following is empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowing(); + const expected: never[] = []; + + expect(actual).toEqual(expected); + }); + + test('Returns an the items array when the following is not empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + response: + JSONResponse({ + type: 'Collection', + items: [{ + type: 'Person' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowing(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); + }); + + describe('getFollowers', function () { + test('It passes the token to the followers endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.getFollowers(); + }); + + test('Returns an empty array when the followers is empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowers(); + const expected: never[] = []; + + expect(actual).toEqual(expected); + }); + + test('Returns an the items array when the followers is not empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + response: + JSONResponse({ + type: 'Collection', + items: [{ + type: 'Person' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowers(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); + }); + + describe('follow', function () { + test('It passes the token to the follow endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/actions/follow/@user@domain.com': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({}) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.follow('@user@domain.com'); + }); + }); +}); diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts new file mode 100644 index 0000000000..fb7a185131 --- /dev/null +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -0,0 +1,109 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Actor = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Activity = any; + +export class ActivityPubAPI { + constructor( + private readonly apiUrl: URL, + private readonly authApiUrl: URL, + private readonly handle: string, + private readonly fetch: (resource: URL, init?: RequestInit) => Promise = window.fetch.bind(window) + ) {} + + private async getToken(): Promise { + try { + const response = await this.fetch(this.authApiUrl); + const json = await response.json(); + return json?.identities?.[0]?.token || null; + } catch (err) { + // TODO: Ping sentry? + return null; + } + } + + private async fetchJSON(url: URL, method: 'GET' | 'POST' = 'GET'): Promise { + const token = await this.getToken(); + const response = await this.fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/activity+json' + } + }); + const json = await response.json(); + return json; + } + + get inboxApiUrl() { + return new URL(`.ghost/activitypub/inbox/${this.handle}`, this.apiUrl); + } + + async getInbox(): Promise { + const json = await this.fetchJSON(this.inboxApiUrl); + if (json === null) { + return []; + } + if ('items' in json) { + return Array.isArray(json?.items) ? json.items : []; + } + return []; + } + + get followingApiUrl() { + return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl); + } + + async getFollowing(): Promise { + const json = await this.fetchJSON(this.followingApiUrl); + if (json === null) { + return []; + } + if ('items' in json) { + return Array.isArray(json?.items) ? json.items : []; + } + return []; + } + + async getFollowingCount(): Promise { + const json = await this.fetchJSON(this.followingApiUrl); + if (json === null) { + return 0; + } + if ('totalItems' in json && typeof json.totalItems === 'number') { + return json.totalItems; + } + return 0; + } + + get followersApiUrl() { + return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl); + } + + async getFollowers(): Promise { + const json = await this.fetchJSON(this.followersApiUrl); + if (json === null) { + return []; + } + if ('items' in json) { + return Array.isArray(json?.items) ? json.items : []; + } + return []; + } + + async getFollowersCount(): Promise { + const json = await this.fetchJSON(this.followersApiUrl); + if (json === null) { + return 0; + } + if ('totalItems' in json && typeof json.totalItems === 'number') { + return json.totalItems; + } + return 0; + } + + async follow(username: string): Promise { + const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl); + await this.fetchJSON(url, 'POST'); + } +}