diff --git a/backend/src/mastodon/instance.ts b/backend/src/mastodon/instance.ts new file mode 100644 index 0000000..78c1ded --- /dev/null +++ b/backend/src/mastodon/instance.ts @@ -0,0 +1,15 @@ +import type { InstanceStatistics } from 'wildebeest/backend/src/types/instance' +import { sqlMastoV1InstanceStats } from 'wildebeest/backend/src/mastodon/sql/instance' +import { Database } from 'wildebeest/backend/src/database' + +export async function calculateInstanceStatistics(origin: string, db: Database): Promise { + const row: any = await db + .prepare(sqlMastoV1InstanceStats(origin)) + .first<{ user_count: number; status_count: number; domain_count: number }>() + + return { + user_count: row?.user_count ?? 0, + status_count: row?.status_count ?? 0, + domain_count: row?.domain_count ?? 1, + } as InstanceStatistics +} diff --git a/backend/src/mastodon/sql/instance.ts b/backend/src/mastodon/sql/instance.ts new file mode 100644 index 0000000..64dd24e --- /dev/null +++ b/backend/src/mastodon/sql/instance.ts @@ -0,0 +1,28 @@ +// Prepared statements for Mastodon Instance API endpoints +/** Returns a SQL statement that can be used to calculate the instance-level + * statistics required by the Mastodon `GET /api/v1/instance` endpoint. The + * string returned by this method should be passed as a prepared statement + * to a `Database` object that references a Wildebeest database instance in order + * to retrieve actual results. For example: + * + * + * ``` + * const sqlQuery: string = sqlMastoV1InstanceStats('https://example.com') + * const row: any = await db.prepare(sqlQuery).first<{ user_count: number, status_count: number, domain_count: number }>() + * + * + * ``` + * + * @param domain expects an HTTP **origin** (i.e. must include the https://) + * @return a string value representing a SQL statement that can be used to + * calculate instance-level aggregate statistics + */ +export const sqlMastoV1InstanceStats = (domain: string): string => { + return ` + SELECT + (SELECT count(1) FROM actors WHERE type IN ('Person', 'Service') AND id LIKE '%${domain}/ap/users/%') AS user_count, + (SELECT count(1) FROM objects WHERE local = 1 AND type = 'Note') AS status_count, + (SELECT count(1) FROM peers) + 1 AS domain_count + ; +` +} diff --git a/backend/src/types/instance.ts b/backend/src/types/instance.ts new file mode 100644 index 0000000..d2bac34 --- /dev/null +++ b/backend/src/types/instance.ts @@ -0,0 +1,67 @@ +import type { MastodonAccount } from './account' + +// https://docs.joinmastodon.org/entities/Instance/ +// https://github.com/mastodon/mastodon-ios/blob/develop/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon%2BEntity%2BInstance.swift +// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java +export interface MastodonInstance { + uri: string + title: string + description: string + short_description: string + email: string + version?: string + languages?: Array + registrations?: boolean + approval_required?: boolean + invites_enabled?: boolean + urls?: InstanceURL + statistics?: InstanceStatistics + stats?: InstanceStatistics + thumbnail?: string + contact_account?: MastodonAccount + rules?: Array + configuration?: InstanceConfiguration +} + +export interface InstanceURL { + streaming_api: string +} + +export type InstanceStatistics = { + user_count: number + status_count: number + domain_count: number +} + +export type InstanceRule = { + id: string + text: string +} + +export type InstanceConfiguration = { + statuses?: StatusesConfiguration + media_attachments?: MediaAttachmentsConfiguration + polls?: PollsConfiguration +} + +export type StatusesConfiguration = { + max_characters: number + max_media_attachments: number + characters_reserved_per_url: number +} + +export type MediaAttachmentsConfiguration = { + supported_mime_types: Array + image_size_limit: number + image_matrix_limit: number + video_size_limit: number + video_frame_rate_limit: number + video_matrix_limit: number +} + +export type PollsConfiguration = { + max_options: number + max_characters_per_option: number + min_expiration: number + max_expiration: number +} diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index c5ca0ea..4b28670 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -1,7 +1,4 @@ import { strict as assert } from 'node:assert/strict' -import type { Env } from 'wildebeest/backend/src/types/env' -import * as v1_instance from 'wildebeest/functions/api/v1/instance' -import * as v2_instance from 'wildebeest/functions/api/v2/instance' import * as custom_emojis from 'wildebeest/functions/api/v1/custom_emojis' import * as mutes from 'wildebeest/functions/api/v1/mutes' import * as blocks from 'wildebeest/functions/api/v1/blocks' @@ -14,80 +11,6 @@ const userKEK = 'test_kek23' const domain = 'cloudflare.com' describe('Mastodon APIs', () => { - describe('instance', () => { - type Data = { - rules: unknown[] - uri: string - title: string - email: string - description: string - version: string - domain: string - contact: { email: string } - } - - test('return the instance infos v1', async () => { - const env = { - INSTANCE_TITLE: 'a', - ADMIN_EMAIL: 'b', - INSTANCE_DESCR: 'c', - } as Env - - const res = await v1_instance.handleRequest(domain, env) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) - - { - const data = await res.json() - assert.equal(data.rules.length, 0) - assert.equal(data.uri, domain) - assert.equal(data.title, 'a') - assert.equal(data.email, 'b') - assert.equal(data.description, 'c') - assert(data.version.includes('Wildebeest')) - } - }) - - test('adds a short_description if missing v1', async () => { - const env = { - INSTANCE_DESCR: 'c', - } as Env - - const res = await v1_instance.handleRequest(domain, env) - assert.equal(res.status, 200) - - { - const data = await res.json() - assert.equal(data.short_description, 'c') - } - }) - - test('return the instance infos v2', async () => { - const db = await makeDB() - - const env = { - INSTANCE_TITLE: 'a', - ADMIN_EMAIL: 'b', - INSTANCE_DESCR: 'c', - } as Env - const res = await v2_instance.handleRequest(domain, db, env) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) - - { - const data = await res.json() - assert.equal(data.rules.length, 0) - assert.equal(data.domain, domain) - assert.equal(data.title, 'a') - assert.equal(data.contact.email, 'b') - assert.equal(data.description, 'c') - assert(data.version.includes('Wildebeest')) - } - }) - }) - describe('custom emojis', () => { test('returns an empty array', async () => { const res = await custom_emojis.onRequest() diff --git a/backend/test/mastodon/instance.spec.ts b/backend/test/mastodon/instance.spec.ts index be13158..c97b165 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -1,22 +1,152 @@ import { addPeer } from 'wildebeest/backend/src/activitypub/peers' import { strict as assert } from 'node:assert/strict' +import type { Env } from 'wildebeest/backend/src/types/env' +import * as v1_instance from 'wildebeest/functions/api/v1/instance' +import * as v2_instance from 'wildebeest/functions/api/v2/instance' import * as peers from 'wildebeest/functions/api/v1/instance/peers' -import { makeDB } from '../utils' +import { makeDB, assertCORS, assertJSON } from 'wildebeest/backend/test/utils' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { MastodonInstance } from 'wildebeest/backend/src/types/instance' + +const adminKEK = 'admin' +const userKEK = 'test_kek2' +const admin_email = 'admin@cloudflare.com' +const domain = 'cloudflare.com' describe('Mastodon APIs', () => { - describe('instance', () => { - test('returns peers', async () => { - const db = await makeDB() - await addPeer(db, 'a') - await addPeer(db, 'b') + describe('/v1', () => { + describe('/instance', () => { + const env = { + INSTANCE_TITLE: 'a', + ADMIN_EMAIL: admin_email, + INSTANCE_DESCR: 'c', + } as Env - const res = await peers.handleRequest(db) - assert.equal(res.status, 200) + test('return the correct instance admin', async () => { + const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) - const data = await res.json>() - assert.equal(data.length, 2) - assert.equal(data[0], 'a') - assert.equal(data[1], 'b') + const res = await v1_instance.handleRequest(domain, env, db) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + { + const data = await res.json() + assert.equal(data.email, admin_email) + assert.equal(data?.contact_account?.acct, adminKEK) + } + }) + + test('return the correct instance statistics', async () => { + const db = await makeDB() + const person = await createPerson(domain, db, adminKEK, admin_email, {}, true) + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + await addPeer(db, 'a') + await addPeer(db, 'b') + await createPublicNote(domain, db, 'my first status', person) + + const res = await v1_instance.handleRequest(domain, env, db) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + { + const data = await res.json() + assert.equal(data.stats?.user_count, 2) + assert.equal(data.stats?.status_count, 1) + assert.equal(data.stats?.domain_count, 3) + } + }) + + test('return the instance info', async () => { + const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) + + const res = await v1_instance.handleRequest(domain, env, db) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + { + const data = await res.json() + assert.equal(data.rules?.length, 0) + assert.equal(data.uri, domain) + assert.equal(data.title, 'a') + assert.equal(data.email, admin_email) + assert.equal(data.description, 'c') + assert(data.version?.includes('Wildebeest')) + } + }) + + test('adds a short_description if missing v1', async () => { + const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) + + const res = await v1_instance.handleRequest(domain, env, db) + assert.equal(res.status, 200) + + { + const data = await res.json() + assert.equal(data.short_description, 'c') + } + }) + + describe('/peers', () => { + test('returns peers', async () => { + const db = await makeDB() + await addPeer(db, 'a') + await addPeer(db, 'b') + + const res = await peers.handleRequest(db) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 2) + assert.equal(data[0], 'a') + assert.equal(data[1], 'b') + }) + }) + }) + }) + describe('/v2', () => { + describe('/instance', () => { + type Data = { + rules: unknown[] + uri: string + title: string + email: string + description: string + version: string + domain: string + contact: { email: string } + } + + test('return the instance infos v2', async () => { + const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) + + const env = { + INSTANCE_TITLE: 'a', + ADMIN_EMAIL: 'b', + INSTANCE_DESCR: 'c', + } as Env + const res = await v2_instance.handleRequest(domain, db, env) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + { + const data = await res.json() + assert.equal(data.rules.length, 0) + assert.equal(data.domain, domain) + assert.equal(data.title, 'a') + assert.equal(data.contact.email, 'b') + assert.equal(data.description, 'c') + assert(data.version.includes('Wildebeest')) + } + }) }) }) }) diff --git a/config/ua.ts b/config/ua.ts index b1201b1..a70b92f 100644 --- a/config/ua.ts +++ b/config/ua.ts @@ -1,5 +1,5 @@ import { WILDEBEEST_VERSION, MASTODON_API_VERSION } from 'wildebeest/config/versions' export function getFederationUA(domain: string): string { - return `Wildebeest/${WILDEBEEST_VERSION} (Mastodon/${MASTODON_API_VERSION}; +${domain})` + return `Wildebeest/${WILDEBEEST_VERSION} (Mastodon/${MASTODON_API_VERSION} compatible; +https://${domain})` } diff --git a/config/versions.ts b/config/versions.ts index 737dd2e..66cb01f 100644 --- a/config/versions.ts +++ b/config/versions.ts @@ -4,7 +4,3 @@ import * as packagejson from '../package.json' export const MASTODON_API_VERSION = '4.0.2' export const WILDEBEEST_VERSION = packagejson.version - -export function getVersion(): string { - return `${MASTODON_API_VERSION} (compatible; Wildebeest ${WILDEBEEST_VERSION})` -} diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index b04dd6c..b4346c1 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -1,37 +1,93 @@ +// https://docs.joinmastodon.org/entities/Instance/ +// https://docs.joinmastodon.org/methods/instance/ import type { Env } from 'wildebeest/backend/src/types/env' import { cors } from 'wildebeest/backend/src/utils/cors' +import * as error from 'wildebeest/backend/src/errors' import { DEFAULT_THUMBNAIL } from 'wildebeest/backend/src/config' -import { getVersion } from 'wildebeest/config/versions' +import { getFederationUA } from 'wildebeest/config/ua' +import { calculateInstanceStatistics } from 'wildebeest/backend/src/mastodon/instance' +import { MastodonInstance, InstanceStatistics } from 'wildebeest/backend/src/types/instance' +import { MastodonAccount } from 'wildebeest/backend/src/types/account' +import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' +import { Database, getDatabase } from 'wildebeest/backend/src/database' +import { getAdmins } from 'wildebeest/backend/src/utils/auth/getAdmins' +import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors' export const onRequest: PagesFunction = async ({ env, request }) => { - const domain = new URL(request.url).hostname - return handleRequest(domain, env) + const domain: string = new URL(request.url).hostname + const dbOverride: Database = await getDatabase(env) + return handleRequest(domain, env, dbOverride) } -export async function handleRequest(domain: string, env: Env) { - // prettier-ignore +export async function handleRequest(domain: string, env: Env, dbOverride?: Database) { const headers = { + ...cors(), 'content-type': 'application/json; charset=utf-8', - ...cors() } - const res: any = {} + const res: MastodonInstance = { + uri: domain, + title: env?.INSTANCE_TITLE, + description: env?.INSTANCE_DESCR, + short_description: env?.INSTANCE_DESCR, + email: env?.ADMIN_EMAIL, + version: getFederationUA(domain), + languages: ['en'], + registrations: false, + approval_required: false, + invites_enabled: false, + thumbnail: DEFAULT_THUMBNAIL, + configuration: { + statuses: { + max_characters: 500, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, + media_attachments: { + supported_mime_types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_rate_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 0, + max_characters_per_option: 1, + min_expiration: 1, + max_expiration: 1, + }, + }, + rules: [], + } + try { + const db = dbOverride ?? (await getDatabase(env)) + if (db === undefined) { + console.warn('Server misconfiguration: missing database binding') + return new Response(JSON.stringify(res), { headers }) + } - res.thumbnail = DEFAULT_THUMBNAIL + const instanceStatistics: InstanceStatistics = await calculateInstanceStatistics(domain, db) + res.stats = instanceStatistics - // Registration is disabled because unsupported by Wildebeest. Users - // should go through the login flow and authenticate with Access. - // The documentation is incorrect and registrations is a boolean. - res.registrations = false + const adminActors = await getAdmins(db) + if (adminActors?.length > 0 === false) { + console.warn('Server misconfiguration: missing admin account') + return error.internalServerError() + // return new Response(JSON.stringify(res), { headers }) + } else { + const adminAccounts: Map = new Map() + for (const adminActor of adminActors) { + const adminAccount = await loadLocalMastodonAccount(db, adminActor) + adminAccounts.set(adminActor[emailSymbol], adminAccount) + } - res.version = getVersion() - res.rules = [] - res.uri = domain - res.title = env.INSTANCE_TITLE - res.email = env.ADMIN_EMAIL - res.description = env.INSTANCE_DESCR - - res.short_description = res.description - - return new Response(JSON.stringify(res), { headers }) + // prettier-ignore + res.contact_account = adminAccounts.has(env?.ADMIN_EMAIL) ? adminAccounts.get(env?.ADMIN_EMAIL) : Array.from(adminAccounts.values())[0] + } + return new Response(JSON.stringify(res), { headers }) + } catch (e: any) { + console.error(`Server misconfiguration.`) + return new Response(JSON.stringify(res), { headers }) + } } diff --git a/functions/api/v2/instance.ts b/functions/api/v2/instance.ts index 80439d8..9e313a7 100644 --- a/functions/api/v2/instance.ts +++ b/functions/api/v2/instance.ts @@ -2,7 +2,7 @@ import type { Env } from 'wildebeest/backend/src/types/env' import { cors } from 'wildebeest/backend/src/utils/cors' import { DEFAULT_THUMBNAIL } from 'wildebeest/backend/src/config' import type { InstanceConfigV2 } from 'wildebeest/backend/src/types/configs' -import { getVersion } from 'wildebeest/config/versions' +import { getFederationUA } from 'wildebeest/config/ua' import { type Database, getDatabase } from 'wildebeest/backend/src/database' export const onRequest: PagesFunction = async ({ env, request }) => { @@ -20,7 +20,7 @@ export async function handleRequest(domain: string, db: Database, env: Env) { const res: InstanceConfigV2 = { domain, title: env.INSTANCE_TITLE, - version: getVersion(), + version: getFederationUA(domain), source_url: 'https://github.com/cloudflare/wildebeest', description: env.INSTANCE_DESCR, thumbnail: {