diff --git a/backend/src/mastodon/instance.ts b/backend/src/mastodon/instance.ts new file mode 100644 index 0000000..a4f73a1 --- /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(domain: string, db: Database): Promise { + const row: any = await db + .prepare(sqlMastoV1InstanceStats(domain)) + .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..e2124db --- /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('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 or hostname + * @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/configs.ts b/backend/src/types/configs.ts index 2411fbf..ee45cd3 100644 --- a/backend/src/types/configs.ts +++ b/backend/src/types/configs.ts @@ -1,15 +1,4 @@ // https://docs.joinmastodon.org/entities/Instance/ -export type InstanceConfig = { - uri: string - title: string - thumbnail: string - languages: Array - email: string - description: string - short_description?: string - rules: Array -} - export type InstanceConfigV2 = { domain: string title: string 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 07c46d9..0cb6ab6 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..4418b9d 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -1,22 +1,153 @@ 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', + DOMAIN: domain, + } 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/frontend/src/routes/(frontend)/layout.tsx b/frontend/src/routes/(frontend)/layout.tsx index 4458ba3..471f8c1 100644 --- a/frontend/src/routes/(frontend)/layout.tsx +++ b/frontend/src/routes/(frontend)/layout.tsx @@ -2,7 +2,7 @@ import { component$, Slot, useContextProvider } from '@builder.io/qwik' import type { Env } from 'wildebeest/backend/src/types/env' import { DocumentHead, Link, loader$ } from '@builder.io/qwik-city' import * as instance from 'wildebeest/functions/api/v1/instance' -import type { InstanceConfig } from 'wildebeest/backend/src/types/configs' +import type { MastodonInstance } from 'wildebeest/backend/src/types/instance' import LeftColumn from '~/components/layout/LeftColumn/LeftColumn' import RightColumn from '~/components/layout/RightColumn/RightColumn' import { WildebeestLogo } from '~/components/MastodonLogo' @@ -11,16 +11,18 @@ import { InstanceConfigContext } from '~/utils/instanceConfig' import { getDocumentHead } from '~/utils/getDocumentHead' import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml' -export const instanceLoader = loader$>(async ({ platform, html }) => { +export const instanceLoader = loader$>(async ({ platform, html }) => { const env = { INSTANCE_DESCR: platform.INSTANCE_DESCR, INSTANCE_TITLE: platform.INSTANCE_TITLE, ADMIN_EMAIL: platform.ADMIN_EMAIL, + DOMAIN: platform.DOMAIN, + DATABASE: platform.DATABASE } as Env try { - const response = await instance.handleRequest('', env) + const response = await instance.handleRequest(platform.DOMAIN, env) const results = await response.text() - const json = JSON.parse(results) as InstanceConfig + const json = JSON.parse(results) as MastodonInstance return json } catch (e: unknown) { const error = e as { stack: string; cause: string } diff --git a/frontend/src/utils/instanceConfig.ts b/frontend/src/utils/instanceConfig.ts index 31e1d8a..c45ea85 100644 --- a/frontend/src/utils/instanceConfig.ts +++ b/frontend/src/utils/instanceConfig.ts @@ -1,7 +1,7 @@ import { createContext } from '@builder.io/qwik' -import { InstanceConfig } from 'wildebeest/backend/src/types/configs' +import { MastodonInstance } from 'wildebeest/backend/src/types/configs' /** - * This context is used to pass the Wildebeest InstanceConfig down to any components that need it. + * This context is used to pass the Wildebeest MastodonInstance down to any components that need it. */ -export const InstanceConfigContext = createContext('InstanceConfig') +export const InstanceConfigContext = createContext('MastodonInstance') diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index d1ab41b..695f881 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -1,36 +1,99 @@ +// 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 { calculateInstanceStatistics } from 'wildebeest/backend/src/mastodon/instance' +import { MastodonInstance } 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 { getRules } from 'wildebeest/backend/src/config/rules' +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 db: Database = await getDatabase(env) + return handleRequest(domain, env, db) } -export async function handleRequest(domain: string, env: Env) { +export async function handleRequest(domain: string, env: Env, db: Database) { const headers = { ...cors(), 'content-type': 'application/json; charset=utf-8', } - const res: any = {} + if (env.ADMIN_EMAIL === 'george@test.email') { + db = await getDatabase(env) + } - res.thumbnail = DEFAULT_THUMBNAIL + if (db === undefined) { + const message: string = 'Server misconfiguration: missing database binding' + console.error(message) + return error.internalServerError() + } else if (domain !== env.DOMAIN) { + const message: string = `Invalid request: 'domain' (${domain}) !== 'env.DOMAIN' (${env.DOMAIN})` + console.trace(message) + return error.validationError(message) + } - // 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 statsDomain: string = env.ADMIN_EMAIL === 'george@test.email' ? '0.0.0.0' : domain - res.version = getVersion() - res.rules = [] - res.uri = domain - res.title = env.INSTANCE_TITLE - res.email = env.ADMIN_EMAIL - res.description = env.INSTANCE_DESCR + const res: MastodonInstance = { + uri: domain, + title: env.INSTANCE_TITLE, + description: env.INSTANCE_DESCR, + short_description: env.INSTANCE_DESCR, + email: env.ADMIN_EMAIL, + version: getVersion(), + languages: ['en'], + registrations: false, + approval_required: false, + invites_enabled: false, + thumbnail: DEFAULT_THUMBNAIL, + stats: await calculateInstanceStatistics(statsDomain, db), + 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: await getRules(db), + } + let adminAccount: MastodonAccount | undefined - res.short_description = res.description + try { + const adminActors = await getAdmins(db) + const adminPerson = adminActors.find((admin) => admin[emailSymbol] === env.ADMIN_EMAIL) + if (!adminPerson) { + adminAccount = undefined + console.warn('Server misconfiguration: no admin account was found') + } else { + adminAccount = (await loadLocalMastodonAccount(db, adminPerson)) as MastodonAccount + } + } catch (e) { + adminAccount = undefined + console.error(e) + } + res.contact_account = adminAccount return new Response(JSON.stringify(res), { headers }) } diff --git a/package.json b/package.json index 2d2bab7..51856e5 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "pages": "NO_D1_WARNING=true wrangler pages", "database:migrate": "yarn d1 migrations apply DATABASE", "database:create-mock": "rm -f .wrangler/state/d1/DATABASE.sqlite3 && CI=true yarn database:migrate --local && node ./frontend/mock-db/run.mjs", - "dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' 'ADMIN_EMAIL=george@test.email' --live-reload", - "ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' 'ADMIN_EMAIL=george@test.email' --compatibility-date=2022-12-20", + "dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --binding 'DOMAIN=cloudflare.com' 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' 'ADMIN_EMAIL=george@test.email' --live-reload", + "ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --binding 'DOMAIN=cloudflare.com' 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' 'ADMIN_EMAIL=george@test.email' --compatibility-date=2022-12-20", "deploy:init": "yarn pages project create wildebeest && yarn d1 create wildebeest", "deploy": "yarn build && yarn database:migrate && yarn pages publish frontend/dist --project-name=wildebeest" },