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.
This commit is contained in:
Fabien O'Carroll 2024-07-22 14:44:47 +07:00 committed by Fabien 'egg' O'Carroll
parent bef58e1069
commit c6e407fb7e
2 changed files with 475 additions and 0 deletions

View File

@ -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<void>
};
function Fetch(specs: Record<string, Spec>) {
return async function (resource: URL, init?: RequestInit): Promise<Response> {
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');
});
});
});

View File

@ -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<Response> = window.fetch.bind(window)
) {}
private async getToken(): Promise<string | null> {
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<object | null> {
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<Activity[]> {
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<Activity[]> {
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<number> {
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<Activity[]> {
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<number> {
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<void> {
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
await this.fetchJSON(url, 'POST');
}
}