TryGhost/Ghost

View on GitHub
apps/admin-x-activitypub/src/api/activitypub.ts

Summary

Maintainability
F
4 days
Test Coverage
// 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', body?: object): Promise<object | null> {
        const token = await this.getToken();
        const options: RequestInit = {
            method,
            headers: {
                Authorization: `Bearer ${token}`,
                Accept: 'application/activity+json'
            }
        };
        if (body) {
            options.body = JSON.stringify(body);
            (options.headers! as Record<string, string>)['Content-Type'] = 'application/json';
        }
        const response = await this.fetch(url, options);
        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 ('orderedItems' in json) {
            return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
        }
        if ('items' in json) {
            return Array.isArray(json.items) ? 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 ('orderedItems' in json) {
            return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
        }
        if ('items' in json) {
            return Array.isArray(json.items) ? 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 ('orderedItems' in json) {
            return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
        }
        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');
    }

    async getActor(url: string): Promise<Actor> {
        const json = await this.fetchJSON(new URL(url));
        return json as Actor;
    }

    get likedApiUrl() {
        return new URL(`.ghost/activitypub/liked/${this.handle}`, this.apiUrl);
    }

    async getLiked() {
        const json = await this.fetchJSON(this.likedApiUrl);
        if (json === null) {
            return [];
        }
        if ('orderedItems' in json) {
            return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
        }
        return [];
    }

    async like(id: string): Promise<void> {
        const url = new URL(`.ghost/activitypub/actions/like/${encodeURIComponent(id)}`, this.apiUrl);
        await this.fetchJSON(url, 'POST');
    }

    async unlike(id: string): Promise<void> {
        const url = new URL(`.ghost/activitypub/actions/unlike/${encodeURIComponent(id)}`, this.apiUrl);
        await this.fetchJSON(url, 'POST');
    }

    get activitiesApiUrl() {
        return new URL(`.ghost/activitypub/activities/${this.handle}`, this.apiUrl);
    }

    async getActivities(
        includeOwn: boolean = false,
        includeReplies: boolean = false,
        filter: {type?: string[]} | null = null,
        cursor?: string
    ): Promise<{data: Activity[], nextCursor: string | null}> {
        const LIMIT = 50;

        const url = new URL(this.activitiesApiUrl);
        url.searchParams.set('limit', LIMIT.toString());
        if (includeOwn) {
            url.searchParams.set('includeOwn', includeOwn.toString());
        }
        if (includeReplies) {
            url.searchParams.set('includeReplies', includeReplies.toString());
        }
        if (filter) {
            url.searchParams.set('filter', JSON.stringify(filter));
        }
        if (cursor) {
            url.searchParams.set('cursor', cursor);
        }

        const json = await this.fetchJSON(url);

        if (json === null) {
            return {
                data: [],
                nextCursor: null
            };
        }

        if (!('items' in json)) {
            return {
                data: [],
                nextCursor: null
            };
        }

        const data = Array.isArray(json.items) ? json.items : [];
        const nextCursor = 'nextCursor' in json && typeof json.nextCursor === 'string' ? json.nextCursor : null;

        return {
            data,
            nextCursor
        };
    }

    async getAllActivities(
        includeOwn: boolean = false,
        includeReplies: boolean = false,
        filter: {type?: string[]} | null = null
    ): Promise<Activity[]> {
        const LIMIT = 50;

        const fetchActivities = async (url: URL): Promise<Activity[]> => {
            const json = await this.fetchJSON(url);

            // If the response is null, return early
            if (json === null) {
                return [];
            }

            // If the response doesn't have an items array, return early
            if (!('items' in json)) {
                return [];
            }

            // If the response has an items property, but it's not an array
            // use an empty array
            const items = Array.isArray(json.items) ? json.items : [];

            // If the response has a nextCursor property, fetch the next page
            // recursively and concatenate the results
            if ('nextCursor' in json && typeof json.nextCursor === 'string') {
                const nextUrl = new URL(url);

                nextUrl.searchParams.set('cursor', json.nextCursor);
                nextUrl.searchParams.set('limit', LIMIT.toString());
                if (includeOwn) {
                    nextUrl.searchParams.set('includeOwn', includeOwn.toString());
                }
                if (includeReplies) {
                    nextUrl.searchParams.set('includeReplies', includeReplies.toString());
                }
                if (filter) {
                    nextUrl.searchParams.set('filter', JSON.stringify(filter));
                }

                const nextItems = await fetchActivities(nextUrl);

                return items.concat(nextItems);
            }

            return items;
        };

        // Make a copy of the activities API URL and set the limit
        const url = new URL(this.activitiesApiUrl);
        url.searchParams.set('limit', LIMIT.toString());
        if (includeOwn) {
            url.searchParams.set('includeOwn', includeOwn.toString());
        }
        if (includeReplies) {
            url.searchParams.set('includeReplies', includeReplies.toString());
        }
        if (filter) {
            url.searchParams.set('filter', JSON.stringify(filter));
        }

        // Fetch the activities
        return fetchActivities(url);
    }

    async reply(id: string, content: string) {
        const url = new URL(`.ghost/activitypub/actions/reply/${encodeURIComponent(id)}`, this.apiUrl);
        const response = await this.fetchJSON(url, 'POST', {content});
        return response;
    }

    get userApiUrl() {
        return new URL(`.ghost/activitypub/users/${this.handle}`, this.apiUrl);
    }

    async getUser() {
        const json = await this.fetchJSON(this.userApiUrl);
        return json;
    }
}