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:
parent
bef58e1069
commit
c6e407fb7e
366
apps/admin-x-activitypub/src/api/activitypub.test.ts
Normal file
366
apps/admin-x-activitypub/src/api/activitypub.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
109
apps/admin-x-activitypub/src/api/activitypub.ts
Normal file
109
apps/admin-x-activitypub/src/api/activitypub.ts
Normal 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');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user