From 414e00345e59e6ff2377ff3ff169d6d4fc114a08 Mon Sep 17 00:00:00 2001 From: Jorge Caballero <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 9 Feb 2023 10:24:08 -0800 Subject: [PATCH 01/18] Update main.tf --- tf/main.tf | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tf/main.tf b/tf/main.tf index a2a7dec..969c0b1 100644 --- a/tf/main.tf +++ b/tf/main.tf @@ -171,20 +171,20 @@ resource "cloudflare_access_application" "wildebeest_access" { auto_redirect_to_identity = false } -resource "cloudflare_ruleset" "wildebeest_inbox" { - zone_id = trimspace(var.cloudflare_zone_id) - name = "Wildebeest" - description = "Ruleset for Wildebeest" - kind = "zone" - phase = "http_request_firewall_managed" +# resource "cloudflare_ruleset" "wildebeest_inbox" { +# zone_id = trimspace(var.cloudflare_zone_id) +# name = "Wildebeest" +# description = "Ruleset for Wildebeest" +# kind = "zone" +# phase = "http_request_firewall_managed" - rules { - action = "skip" - action_parameters { - phases = ["http_request_firewall_managed"] - } - expression = "(http.host eq \"${var.cloudflare_deploy_domain}\" and http.request.uri.path contains \"/ap/users/\" and http.request.uri.path contains \"inbox\")" - description = "Bypass firewall for Wildebeest Inbox" - enabled = true - } -} +# rules { +# action = "skip" +# action_parameters { +# phases = ["http_request_firewall_managed"] +# } +# expression = "(http.host eq \"${var.cloudflare_deploy_domain}\" and http.request.uri.path contains \"/ap/users/\" and http.request.uri.path contains \"inbox\")" +# description = "Bypass firewall for Wildebeest Inbox" +# enabled = true +# } +# } From f3a5574286f6fc179cf71146b4dac1d2f1e88e40 Mon Sep 17 00:00:00 2001 From: Jorge Caballero <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 9 Feb 2023 11:10:36 -0800 Subject: [PATCH 02/18] yolo --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 95260db..85890ad 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,7 @@ on: branches: - main repository_dispatch: + workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest From c443959044d73538785bcb6f3957ae51eff74d3f Mon Sep 17 00:00:00 2001 From: Jorge Caballero <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 9 Feb 2023 17:08:09 -0800 Subject: [PATCH 03/18] Applying user handle patch https://github.com/cloudflare/wildebeest/issues/240#issuecomment-1424911643 --- backend/src/utils/parse.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/src/utils/parse.ts b/backend/src/utils/parse.ts index b5a99a0..d0f4973 100644 --- a/backend/src/utils/parse.ts +++ b/backend/src/utils/parse.ts @@ -13,15 +13,21 @@ export function parseHandle(query: string): Handle { query = decodeURIComponent(query) const parts = query.split('@') - const localPart = parts[0] + if (parts.length > 0) { + const localPart = parts[0] - if (!/^[\w-.]+$/.test(localPart)) { - throw new Error('invalid handle: localPart: ' + localPart) - } + if (!/^[\w-.]+$/.test(localPart)) { + throw new Error('invalid handle: localPart: ' + localPart) + } - if (parts.length > 1) { - return { localPart, domain: parts[1] } + if (parts.length > 1) { + return { localPart, domain: parts[1] } + } else { + return { localPart, domain: null } + } } else { - return { localPart, domain: null } + // it's a URI handle? + const urlParts = query.replace(/^https?:\/\//, '').split('/') + return { domain: urlParts[0], localPart: urlParts[urlParts.length - 1] } } } From b6b93b7c6e6aa362731f1063008354f41ebb002c Mon Sep 17 00:00:00 2001 From: DataDrivenMD <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 9 Feb 2023 19:26:55 -0800 Subject: [PATCH 04/18] Allow TF to overwrite wildebeest CNAME --- tf/main.tf | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tf/main.tf b/tf/main.tf index 969c0b1..ea1b040 100644 --- a/tf/main.tf +++ b/tf/main.tf @@ -143,12 +143,13 @@ resource "cloudflare_pages_project" "wildebeest_pages_project" { } resource "cloudflare_record" "record" { - zone_id = trimspace(var.cloudflare_zone_id) - name = trimspace(var.cloudflare_deploy_domain) - value = cloudflare_pages_project.wildebeest_pages_project.subdomain - type = "CNAME" - ttl = 1 - proxied = true + allow_overwrite = true + zone_id = trimspace(var.cloudflare_zone_id) + name = trimspace(var.cloudflare_deploy_domain) + value = cloudflare_pages_project.wildebeest_pages_project.subdomain + type = "CNAME" + ttl = 1 + proxied = true } resource "cloudflare_pages_domain" "domain" { @@ -167,7 +168,7 @@ resource "cloudflare_access_application" "wildebeest_access" { name = "wildebeest-${lower(var.name_suffix)}" domain = "${trimspace(var.cloudflare_deploy_domain)}/oauth/authorize" type = "self_hosted" - session_duration = "730h" + session_duration = "24h" auto_redirect_to_identity = false } From 3409a3e32d56e9f3fdab56dbe4a5894fff307d0a Mon Sep 17 00:00:00 2001 From: DataDrivenMD <116459476+DataDrivenMD@users.noreply.github.com> Date: Fri, 10 Feb 2023 21:30:48 -0800 Subject: [PATCH 05/18] Define MastodonInstance types --- backend/src/types/instance.ts | 66 +++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 backend/src/types/instance.ts diff --git a/backend/src/types/instance.ts b/backend/src/types/instance.ts new file mode 100644 index 0000000..98d6d05 --- /dev/null +++ b/backend/src/types/instance.ts @@ -0,0 +1,66 @@ +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?: Statistics + thumbnail?: string + contact_account?: MastodonAccount + rules?: Array + configuration?: Configuration +} + +export interface InstanceURL { + streaming_api: string +} + +export type Statistics = { + user_count: number + status_count: number + domain_count: number +} + +export type Rule = { + id: string + text: string +} + +export type Configuration = { + 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 +} \ No newline at end of file From 629ce0c02771660740c93635f5143b60ad84442f Mon Sep 17 00:00:00 2001 From: DataDrivenMD <116459476+DataDrivenMD@users.noreply.github.com> Date: Fri, 10 Feb 2023 21:31:40 -0800 Subject: [PATCH 06/18] Return Mastodon-compliant instance info --- functions/api/v1/instance.ts | 73 ++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index d1ab41b..c709391 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -1,36 +1,69 @@ +// https://docs.joinmastodon.org/entities/Instance/ import type { Env } from 'wildebeest/backend/src/types/env' +import 'wildebeest/backend/src/types/instance' import { cors } from 'wildebeest/backend/src/utils/cors' import { DEFAULT_THUMBNAIL } from 'wildebeest/backend/src/config' -import { getVersion } from 'wildebeest/config/versions' +import { MASTODON_API_VERSION } from 'wildebeest/config/versions' +import { MastodonInstance } from 'wildebeest/backend/src/types/instance' export const onRequest: PagesFunction = async ({ env, request }) => { const domain = new URL(request.url).hostname - return handleRequest(domain, env) + console.log(`Request: ${domain} vs. Env: ${env.DOMAIN}`) + return handleRequest(env) } -export async function handleRequest(domain: string, env: Env) { +export async function handleRequest(env: Env) { const headers = { ...cors(), 'content-type': 'application/json; charset=utf-8', } - const res: any = {} - - res.thumbnail = DEFAULT_THUMBNAIL - - // 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 - - 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 + const res: MastodonInstance = {} + // res.uri = domain.trim().replace(/https?:[/]{2}/i, '') + res.uri = env.DOMAIN + res.title = env.INSTANCE_TITLE + res.description = env.INSTANCE_DESCR + res.short_description = env.INSTANCE_DESCR + res.email = env.ADMIN_EMAIL + res.version = MASTODON_API_VERSION + res.languages = ['en'] + res.registrations = true + res.approval_required = false + res.invites_enabled = false + res.urls = { + streaming_api:"https://streaming.fedified.com" + } + res.statistics = {} // TODO: Calculate actual statistics + res.thumbnail = DEFAULT_THUMBNAIL + res.contact_account = {} + res.rules = [] + res.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", + "video/mp4" + ], + "image_size_limit":10485760, + "image_matrix_limit":16777216, + "video_size_limit":41943040, + "video_frame_rate_limit":60, + "video_matrix_limit":2304000 + }, + "polls":{ + "max_options":4, + "max_characters_per_option":50, + "min_expiration":300, + "max_expiration":2629746 + } + } return new Response(JSON.stringify(res), { headers }) } From d71599ce47b732ada6f02738103e9dde77df561f Mon Sep 17 00:00:00 2001 From: DataDrivenMD <116459476+DataDrivenMD@users.noreply.github.com> Date: Sun, 12 Feb 2023 22:27:52 -0800 Subject: [PATCH 07/18] Change Default Images to Official Mastodon Avatar [X] Use the official Mastodon default image for avatar --- config/accounts.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/accounts.ts b/config/accounts.ts index 2e62e12..efa214c 100644 --- a/config/accounts.ts +++ b/config/accounts.ts @@ -1,6 +1,5 @@ import type { DefaultImages } from '../backend/src/types/configs' - export const defaultImages: DefaultImages = { - avatar: 'https://masto.ai/avatars/original/missing.png', + avatar: 'https://raw.githubusercontent.com/mastodon/mastodon/main/public/avatars/original/missing.png', header: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/header', -} +} \ No newline at end of file From 8fb9b9b100cab7ee45d2f60a6786dcf6d700e225 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 2 Mar 2023 11:33:01 -0800 Subject: [PATCH 08/18] Tiny refactor of existing Mastodon instance tests --- backend/test/mastodon.spec.ts | 74 ----------------------- backend/test/mastodon/instance.spec.ts | 83 +++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 75 deletions(-) diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index c5ca0ea..43cd030 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -14,80 +14,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..f5d3585 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -1,10 +1,26 @@ 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 '../utils' + +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('returns peers', async () => { const db = await makeDB() await addPeer(db, 'a') @@ -18,5 +34,70 @@ describe('Mastodon APIs', () => { assert.equal(data[0], 'a') assert.equal(data[1], 'b') }) + + test('return the instance infos v1', async () => { + const db = await makeDB() + + const env = { + INSTANCE_TITLE: 'a', + ADMIN_EMAIL: 'b', + INSTANCE_DESCR: 'c', + } as Env + + const res = await v1_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.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 db = await makeDB() + + const env = { + INSTANCE_DESCR: 'c', + } as Env + + const res = await v1_instance.handleRequest(domain, db, 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')) + } + }) }) }) From 2e303528215c368e1ea6ec2b47808cf9c0f3b606 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 2 Mar 2023 12:47:19 -0800 Subject: [PATCH 09/18] /api/v1/instance endpoint + getAdmins() test --- backend/src/mastodon/instance.ts | 13 ++++ backend/src/mastodon/sql/instance.ts | 10 +++ backend/src/types/env.ts | 3 + backend/src/types/instance.ts | 67 +++++++++++++++++ backend/test/mastodon.spec.ts | 2 - backend/test/mastodon/instance.spec.ts | 29 ++++++++ config/versions.ts | 6 +- functions/api/v1/instance.ts | 87 +++++++++++++++++----- functions/api/v2/instance.ts | 2 +- functions/api/wb/settings/server/admins.ts | 22 ++++-- 10 files changed, 209 insertions(+), 32 deletions(-) create mode 100644 backend/src/mastodon/instance.ts create mode 100644 backend/src/mastodon/sql/instance.ts create mode 100644 backend/src/types/instance.ts diff --git a/backend/src/mastodon/instance.ts b/backend/src/mastodon/instance.ts new file mode 100644 index 0000000..1650b44 --- /dev/null +++ b/backend/src/mastodon/instance.ts @@ -0,0 +1,13 @@ +import type { InstanceStatistics } from 'wildebeest/backend/src/types/instance' +import { instanceStatisticsQuery } 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(instanceStatisticsQuery(origin)).first() + + 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..7807e64 --- /dev/null +++ b/backend/src/mastodon/sql/instance.ts @@ -0,0 +1,10 @@ +// Prepared statements for Mastodon Instance API endpoints +export const instanceStatisticsQuery = (origin: string): string => { + return ` + SELECT + (SELECT count(1) FROM actors WHERE type IN ('Person', 'Service') AND id LIKE '${origin}/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/env.ts b/backend/src/types/env.ts index 1862a70..42cc4e6 100644 --- a/backend/src/types/env.ts +++ b/backend/src/types/env.ts @@ -21,6 +21,9 @@ export interface Env { INSTANCE_DESCR: string VAPID_JWK: string DOMAIN: string + INSTANCE_ACCEPTING_REGISTRATIONS?: boolean + INSTANCE_REGISTRATIONS_REQUIRE_APPROVAL?: boolean + INSTANCE_CONFIG_STATUSES_MAX_CHARACTERS?: number SENTRY_DSN: string SENTRY_ACCESS_CLIENT_ID: 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 43cd030..f9ef180 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -1,7 +1,5 @@ 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' diff --git a/backend/test/mastodon/instance.spec.ts b/backend/test/mastodon/instance.spec.ts index f5d3585..2444dc5 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -5,7 +5,11 @@ 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, assertCORS, assertJSON } from '../utils' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { MastodonInstance } from 'wildebeest/backend/src/types/instance' +const adminKEK = 'admin' +const admin_email = 'admin@cloudflare.com' const domain = 'cloudflare.com' describe('Mastodon APIs', () => { @@ -35,8 +39,31 @@ describe('Mastodon APIs', () => { assert.equal(data[1], 'b') }) + test('return the correct instance admin', async () => { + const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) + + const env = { + INSTANCE_TITLE: 'a', + ADMIN_EMAIL: admin_email, + INSTANCE_DESCR: 'c', + } as Env + + const res = await v1_instance.handleRequest(domain, db, env) + 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 instance infos v1', async () => { const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) const env = { INSTANCE_TITLE: 'a', @@ -62,6 +89,7 @@ describe('Mastodon APIs', () => { test('adds a short_description if missing v1', async () => { const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) const env = { INSTANCE_DESCR: 'c', @@ -78,6 +106,7 @@ describe('Mastodon APIs', () => { test('return the instance infos v2', async () => { const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) const env = { INSTANCE_TITLE: 'a', diff --git a/config/versions.ts b/config/versions.ts index 737dd2e..8d54537 100644 --- a/config/versions.ts +++ b/config/versions.ts @@ -5,6 +5,8 @@ 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})` +export function getVersion(domain?: string): string { + return `Wildebeest/${WILDEBEEST_VERSION} (Mastodon/${MASTODON_API_VERSION} compatible; +https://${ + domain ?? 'github.com/cloudflare/wildebeest' + })` } diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index d1ab41b..f6ba702 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -1,36 +1,85 @@ +// 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 '../../../backend/src/errors' import { DEFAULT_THUMBNAIL } from 'wildebeest/backend/src/config' -import { getVersion } from 'wildebeest/config/versions' +import { getVersion } from '../../../config/versions' +import { calculateInstanceStatistics } from 'wildebeest/backend/src/mastodon/instance' +import { MastodonInstance, InstanceStatistics } from '../../../backend/src/types/instance' +import { MastodonAccount } from '../../../backend/src/types/account' +import { loadLocalMastodonAccount } from '../../../backend/src/mastodon/account' +import { Database, getDatabase } from 'wildebeest/backend/src/database' +import { getAdmins } from '../wb/settings/server/admins' +import { Actor, emailSymbol, Person } from '../../../backend/src/activitypub/actors' +import { APObject } from '../../../backend/src/activitypub/objects' 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, db, env) } -export async function handleRequest(domain: string, env: Env) { +export async function handleRequest(domain: string, db: Database, env: Env) { const headers = { ...cors(), 'content-type': 'application/json; charset=utf-8', } - const res: any = {} + const adminActors = await getAdmins(db) + if (adminActors.length === 0) { + console.error('Server misconfiguration: missing admin account') + return error.internalServerError() + } - res.thumbnail = DEFAULT_THUMBNAIL + const adminAccounts: Map = new Map() + for (const adminActor of adminActors) { + const adminAccount = await loadLocalMastodonAccount(db, adminActor) + adminAccounts.set(adminActor[emailSymbol], adminAccount) + } - // 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 - - 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 + const contactAccount: MastodonAccount | undefined = adminAccounts.has(env.ADMIN_EMAIL) + ? adminAccounts.get(env.ADMIN_EMAIL) + : Array.from(adminAccounts.values())[0] + const instanceStatistics: InstanceStatistics = await calculateInstanceStatistics(domain, db) + const res: MastodonInstance = { + uri: domain, + title: env.INSTANCE_TITLE, + description: env.INSTANCE_DESCR, + short_description: env.INSTANCE_DESCR, + email: env.ADMIN_EMAIL, + version: getVersion(domain), + languages: ['en'], + registrations: env.INSTANCE_ACCEPTING_REGISTRATIONS ?? false, + approval_required: env.INSTANCE_REGISTRATIONS_REQUIRE_APPROVAL ?? false, + invites_enabled: false, + urls: undefined, + thumbnail: DEFAULT_THUMBNAIL, + contact_account: contactAccount, + configuration: { + statuses: { + max_characters: env.INSTANCE_CONFIG_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', 'video/mp4'], + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_rate_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 4, + max_characters_per_option: 50, + min_expiration: 300, + max_expiration: 2629746, + }, + }, + stats: instanceStatistics, + rules: [], + } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v2/instance.ts b/functions/api/v2/instance.ts index adc34b2..e86d362 100644 --- a/functions/api/v2/instance.ts +++ b/functions/api/v2/instance.ts @@ -19,7 +19,7 @@ export async function handleRequest(domain: string, db: Database, env: Env) { const res: InstanceConfigV2 = { domain, title: env.INSTANCE_TITLE, - version: getVersion(), + version: getVersion(domain), source_url: 'https://github.com/cloudflare/wildebeest', description: env.INSTANCE_DESCR, thumbnail: { diff --git a/functions/api/wb/settings/server/admins.ts b/functions/api/wb/settings/server/admins.ts index 51a7dec..85d2b92 100644 --- a/functions/api/wb/settings/server/admins.ts +++ b/functions/api/wb/settings/server/admins.ts @@ -1,7 +1,9 @@ import type { Env } from 'wildebeest/backend/src/types/env' import type { ContextData } from 'wildebeest/backend/src/types/context' import { type Database, getDatabase } from 'wildebeest/backend/src/database' -import { Person, personFromRow } from 'wildebeest/backend/src/activitypub/actors' +import { Actor, Person, personFromRow } from 'wildebeest/backend/src/activitypub/actors' +import { Result } from 'wildebeest/backend/src/database' +import * as error from 'wildebeest/backend/src/errors' export const onRequestGet: PagesFunction = async ({ env }) => { return handleRequestGet(await getDatabase(env)) @@ -13,14 +15,18 @@ export async function handleRequestGet(db: Database) { } export async function getAdmins(db: Database): Promise { - let rows: unknown[] = [] - try { - const stmt = db.prepare('SELECT * FROM actors WHERE is_admin=TRUE') - const result = await stmt.all() - rows = result.success ? (result.results as unknown[]) : [] - } catch { - /* empty */ + const stmt = db.prepare('SELECT * FROM actors WHERE is_admin=1 ORDER BY cdate ASC') + const queryResult: Result = await stmt.all() + + if (queryResult.success === false) { + console.error(`SQL error encountered while retrieving server admin(s): ${queryResult.error}`) + return Array() } + const rows: Array = (queryResult?.results as Actor[]) ?? [] + if (rows.length === 0) { + console.warn('Server lacks an admin') + return Array() + } return rows.map(personFromRow) } From 0f2d29ba5e8b80fdc43b597343bb6740270cb845 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 2 Mar 2023 13:29:54 -0800 Subject: [PATCH 10/18] Adding test for instance v1 statistics linting --- backend/src/mastodon/sql/instance.ts | 2 +- backend/test/mastodon/instance.spec.ts | 228 ++++++++++++++----------- functions/api/v1/instance.ts | 16 +- 3 files changed, 133 insertions(+), 113 deletions(-) diff --git a/backend/src/mastodon/sql/instance.ts b/backend/src/mastodon/sql/instance.ts index 7807e64..c87ec23 100644 --- a/backend/src/mastodon/sql/instance.ts +++ b/backend/src/mastodon/sql/instance.ts @@ -2,7 +2,7 @@ export const instanceStatisticsQuery = (origin: string): string => { return ` SELECT - (SELECT count(1) FROM actors WHERE type IN ('Person', 'Service') AND id LIKE '${origin}/ap/users/%') AS user_count, + (SELECT count(1) FROM actors WHERE type IN ('Person', 'Service') AND id LIKE '%${origin}/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/test/mastodon/instance.spec.ts b/backend/test/mastodon/instance.spec.ts index 2444dc5..a66cad0 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -4,129 +4,149 @@ 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, assertCORS, assertJSON } from '../utils' +import { makeDB, assertCORS, assertJSON } from 'wildebeest/backend/test/utils' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' -import { MastodonInstance } from 'wildebeest/backend/src/types/instance' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { MastodonInstance, InstanceStatistics } 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', () => { - type Data = { - rules: unknown[] - uri: string - title: string - email: string - description: string - version: string - domain: string - contact: { email: string } - } - - 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') - }) - - test('return the correct instance admin', async () => { - const db = await makeDB() - await createPerson(domain, db, adminKEK, admin_email, {}, true) - + describe('/v1', () => { + describe('/instance', () => { const env = { INSTANCE_TITLE: 'a', ADMIN_EMAIL: admin_email, INSTANCE_DESCR: 'c', } as Env - const res = await v1_instance.handleRequest(domain, db, env) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) + 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.email, admin_email) - assert.equal(data?.contact_account?.acct, adminKEK) - } + const res = await v1_instance.handleRequest(domain, db, env) + 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, db, env) + 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, 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.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, db, env) + 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') + }) + }) }) - - test('return the instance infos v1', 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 v1_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.uri, domain) - assert.equal(data.title, 'a') - assert.equal(data.email, 'b') - assert.equal(data.description, 'c') - assert(data.version.includes('Wildebeest')) + }) + describe('/v2', () => { + describe('/instance', () => { + type Data = { + rules: unknown[] + uri: string + title: string + email: string + description: string + version: string + domain: string + contact: { email: string } } - }) - test('adds a short_description if missing v1', async () => { - const db = await makeDB() - await createPerson(domain, db, adminKEK, admin_email, {}, true) + test('return the instance infos v2', async () => { + const db = await makeDB() + await createPerson(domain, db, adminKEK, admin_email, {}, true) - const env = { - INSTANCE_DESCR: 'c', - } as Env + 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 res = await v1_instance.handleRequest(domain, db, 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() - 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')) - } + { + 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/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index f6ba702..bd82508 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -2,17 +2,17 @@ // 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 '../../../backend/src/errors' +import * as error from 'wildebeest/backend/src/errors' import { DEFAULT_THUMBNAIL } from 'wildebeest/backend/src/config' -import { getVersion } from '../../../config/versions' +import { getVersion } from 'wildebeest/config/versions' import { calculateInstanceStatistics } from 'wildebeest/backend/src/mastodon/instance' -import { MastodonInstance, InstanceStatistics } from '../../../backend/src/types/instance' -import { MastodonAccount } from '../../../backend/src/types/account' -import { loadLocalMastodonAccount } from '../../../backend/src/mastodon/account' +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 '../wb/settings/server/admins' -import { Actor, emailSymbol, Person } from '../../../backend/src/activitypub/actors' -import { APObject } from '../../../backend/src/activitypub/objects' +import { getAdmins } from 'wildebeest/functions/api/wb/settings/server/admins' +import { Actor, emailSymbol, Person } from 'wildebeest/backend/src/activitypub/actors' +import { APObject } from 'wildebeest/backend/src/activitypub/objects' export const onRequest: PagesFunction = async ({ env, request }) => { const domain: string = new URL(request.url).hostname From 9a94e00e0a5700a1120575ddb13c05a5be8b8c79 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 2 Mar 2023 13:54:40 -0800 Subject: [PATCH 11/18] Linting --- backend/test/mastodon.spec.ts | 1 - backend/test/mastodon/instance.spec.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index f9ef180..4b28670 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -1,5 +1,4 @@ import { strict as assert } from 'node:assert/strict' -import type { Env } from 'wildebeest/backend/src/types/env' 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' diff --git a/backend/test/mastodon/instance.spec.ts b/backend/test/mastodon/instance.spec.ts index a66cad0..82fb7ff 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -7,7 +7,7 @@ import * as peers from 'wildebeest/functions/api/v1/instance/peers' 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, InstanceStatistics } from 'wildebeest/backend/src/types/instance' +import { MastodonInstance } from 'wildebeest/backend/src/types/instance' const adminKEK = 'admin' const userKEK = 'test_kek2' From 4ef9d98e12e9c3f84296a0c7ea911aba5a7471c0 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 2 Mar 2023 14:02:49 -0800 Subject: [PATCH 12/18] More linting --- functions/api/v1/instance.ts | 3 +-- functions/api/wb/settings/server/admins.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index bd82508..1f460af 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -11,8 +11,7 @@ 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/functions/api/wb/settings/server/admins' -import { Actor, emailSymbol, Person } from 'wildebeest/backend/src/activitypub/actors' -import { APObject } from 'wildebeest/backend/src/activitypub/objects' +import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors' export const onRequest: PagesFunction = async ({ env, request }) => { const domain: string = new URL(request.url).hostname diff --git a/functions/api/wb/settings/server/admins.ts b/functions/api/wb/settings/server/admins.ts index 85d2b92..5e86d55 100644 --- a/functions/api/wb/settings/server/admins.ts +++ b/functions/api/wb/settings/server/admins.ts @@ -3,7 +3,6 @@ import type { ContextData } from 'wildebeest/backend/src/types/context' import { type Database, getDatabase } from 'wildebeest/backend/src/database' import { Actor, Person, personFromRow } from 'wildebeest/backend/src/activitypub/actors' import { Result } from 'wildebeest/backend/src/database' -import * as error from 'wildebeest/backend/src/errors' export const onRequestGet: PagesFunction = async ({ env }) => { return handleRequestGet(await getDatabase(env)) From 1a38165274af436f9f7e40adeab43f7b51dd5101 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 2 Mar 2023 14:29:18 -0800 Subject: [PATCH 13/18] Revert "Merge branch 'main' into api/v1/instance" This reverts commit 077611411a23942a0082f68b17c3e471c4612a4c, reversing changes made to 4ef9d98e12e9c3f84296a0c7ea911aba5a7471c0. --- .github/workflows/deploy.yml | 1 - backend/src/types/instance.ts | 2 +- backend/src/utils/parse.ts | 20 +++++++------------- config/accounts.ts | 2 +- functions/api/v1/instance.ts | 2 +- tf/main.tf | 15 +++++++-------- 6 files changed, 17 insertions(+), 25 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c81e43f..e3c45ed 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,7 +4,6 @@ on: branches: - main repository_dispatch: - workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest diff --git a/backend/src/types/instance.ts b/backend/src/types/instance.ts index e326516..d2bac34 100644 --- a/backend/src/types/instance.ts +++ b/backend/src/types/instance.ts @@ -64,4 +64,4 @@ export type PollsConfiguration = { max_characters_per_option: number min_expiration: number max_expiration: number -} \ No newline at end of file +} diff --git a/backend/src/utils/parse.ts b/backend/src/utils/parse.ts index fa75a02..34dfcd7 100644 --- a/backend/src/utils/parse.ts +++ b/backend/src/utils/parse.ts @@ -14,21 +14,15 @@ export function parseHandle(query: string): Handle { query = decodeURIComponent(query) const parts = query.split('@') - if (parts.length > 0) { - const localPart = parts[0] + const localPart = parts[0] - if (!/^[\w-.]+$/.test(localPart)) { - throw new Error('invalid handle: localPart: ' + localPart) - } + if (!/^[\w-.]+$/.test(localPart)) { + throw new Error('invalid handle: localPart: ' + localPart) + } - if (parts.length > 1) { - return { localPart, domain: parts[1] } - } else { - return { localPart, domain: null } - } + if (parts.length > 1) { + return { localPart, domain: parts[1] } } else { - // it's a URI handle? - const urlParts = query.replace(/^https?:\/\//, '').split('/') - return { domain: urlParts[0], localPart: urlParts[urlParts.length - 1] } + return { localPart, domain: null } } } diff --git a/config/accounts.ts b/config/accounts.ts index efa214c..6788303 100644 --- a/config/accounts.ts +++ b/config/accounts.ts @@ -2,4 +2,4 @@ import type { DefaultImages } from '../backend/src/types/configs' export const defaultImages: DefaultImages = { avatar: 'https://raw.githubusercontent.com/mastodon/mastodon/main/public/avatars/original/missing.png', header: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/header', -} \ No newline at end of file +} diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index fd71189..1f460af 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -81,4 +81,4 @@ export async function handleRequest(domain: string, db: Database, env: Env) { rules: [], } return new Response(JSON.stringify(res), { headers }) -} \ No newline at end of file +} diff --git a/tf/main.tf b/tf/main.tf index b6457f3..f5384da 100644 --- a/tf/main.tf +++ b/tf/main.tf @@ -143,13 +143,12 @@ resource "cloudflare_pages_project" "wildebeest_pages_project" { } resource "cloudflare_record" "record" { - allow_overwrite = true - zone_id = trimspace(var.cloudflare_zone_id) - name = trimspace(var.cloudflare_deploy_domain) - value = cloudflare_pages_project.wildebeest_pages_project.subdomain - type = "CNAME" - ttl = 1 - proxied = true + zone_id = trimspace(var.cloudflare_zone_id) + name = trimspace(var.cloudflare_deploy_domain) + value = cloudflare_pages_project.wildebeest_pages_project.subdomain + type = "CNAME" + ttl = 1 + proxied = true } resource "cloudflare_pages_domain" "domain" { @@ -168,6 +167,6 @@ resource "cloudflare_access_application" "wildebeest_access" { name = "wildebeest-${lower(var.name_suffix)}" domain = "${trimspace(var.cloudflare_deploy_domain)}/oauth/authorize" type = "self_hosted" - session_duration = "24h" + session_duration = "730h" auto_redirect_to_identity = false } From ea5f3fa063df04f4bda4bbcd9e1b70b5f2dd86c6 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Mon, 6 Mar 2023 16:43:30 -0800 Subject: [PATCH 14/18] Pass e2e tests --- backend/test/mastodon/instance.spec.ts | 8 +-- functions/api/v1/instance.ts | 71 +++++++++++++++----------- playwright.config.ts | 6 +-- 3 files changed, 47 insertions(+), 38 deletions(-) diff --git a/backend/test/mastodon/instance.spec.ts b/backend/test/mastodon/instance.spec.ts index 82fb7ff..c97b165 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -27,7 +27,7 @@ describe('Mastodon APIs', () => { const db = await makeDB() await createPerson(domain, db, adminKEK, admin_email, {}, true) - const res = await v1_instance.handleRequest(domain, db, env) + const res = await v1_instance.handleRequest(domain, env, db) assert.equal(res.status, 200) assertCORS(res) assertJSON(res) @@ -47,7 +47,7 @@ describe('Mastodon APIs', () => { await addPeer(db, 'b') await createPublicNote(domain, db, 'my first status', person) - const res = await v1_instance.handleRequest(domain, db, env) + const res = await v1_instance.handleRequest(domain, env, db) assert.equal(res.status, 200) assertCORS(res) assertJSON(res) @@ -64,7 +64,7 @@ describe('Mastodon APIs', () => { const db = await makeDB() await createPerson(domain, db, adminKEK, admin_email, {}, true) - const res = await v1_instance.handleRequest(domain, db, env) + const res = await v1_instance.handleRequest(domain, env, db) assert.equal(res.status, 200) assertCORS(res) assertJSON(res) @@ -84,7 +84,7 @@ describe('Mastodon APIs', () => { const db = await makeDB() await createPerson(domain, db, adminKEK, admin_email, {}, true) - const res = await v1_instance.handleRequest(domain, db, env) + const res = await v1_instance.handleRequest(domain, env, db) assert.equal(res.status, 200) { diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index f5657fc..ab30d2d 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -15,50 +15,31 @@ import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors' export const onRequest: PagesFunction = async ({ env, request }) => { const domain: string = new URL(request.url).hostname - const db: Database = await getDatabase(env) - return handleRequest(domain, db, env) + const dbOverride: Database = await getDatabase(env) + return handleRequest(domain, env, dbOverride) } -export async function handleRequest(domain: string, db: Database, env: Env) { +export async function handleRequest(domain: string, env: Env, dbOverride?: Database) { const headers = { ...cors(), 'content-type': 'application/json; charset=utf-8', } - const adminActors = await getAdmins(db) - if (adminActors.length === 0) { - console.error('Server misconfiguration: missing admin account') - return error.internalServerError() - } - - const adminAccounts: Map = new Map() - for (const adminActor of adminActors) { - const adminAccount = await loadLocalMastodonAccount(db, adminActor) - adminAccounts.set(adminActor[emailSymbol], adminAccount) - } - - const contactAccount: MastodonAccount | undefined = adminAccounts.has(env.ADMIN_EMAIL) - ? adminAccounts.get(env.ADMIN_EMAIL) - : Array.from(adminAccounts.values())[0] - const instanceStatistics: InstanceStatistics = await calculateInstanceStatistics(domain, db) - const res: MastodonInstance = { uri: domain, - title: env.INSTANCE_TITLE, - description: env.INSTANCE_DESCR, - short_description: env.INSTANCE_DESCR, - email: env.ADMIN_EMAIL, + title: env?.INSTANCE_TITLE, + description: env?.INSTANCE_DESCR, + short_description: env?.INSTANCE_DESCR, + email: env?.ADMIN_EMAIL, version: getVersion(domain), languages: ['en'], - registrations: env.INSTANCE_ACCEPTING_REGISTRATIONS ?? false, - approval_required: env.INSTANCE_REGISTRATIONS_REQUIRE_APPROVAL ?? false, + registrations: env?.INSTANCE_ACCEPTING_REGISTRATIONS ?? false, + approval_required: env?.INSTANCE_REGISTRATIONS_REQUIRE_APPROVAL ?? false, invites_enabled: false, - urls: undefined, thumbnail: DEFAULT_THUMBNAIL, - contact_account: contactAccount, configuration: { statuses: { - max_characters: env.INSTANCE_CONFIG_STATUSES_MAX_CHARACTERS ?? 500, + max_characters: env?.INSTANCE_CONFIG_STATUSES_MAX_CHARACTERS ?? 500, max_media_attachments: 4, characters_reserved_per_url: 23, }, @@ -77,8 +58,36 @@ export async function handleRequest(domain: string, db: Database, env: Env) { max_expiration: 2629746, }, }, - stats: instanceStatistics, rules: [], } - return new Response(JSON.stringify(res), { headers }) + try { + const db = dbOverride ?? (await getDatabase(env)) + if (db === undefined) { + console.warn('Server misconfiguration: missing database binding') + return new Response(JSON.stringify(res), { headers }) + } + + const instanceStatistics: InstanceStatistics = await calculateInstanceStatistics(domain, db) + res.stats = instanceStatistics + + 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) + } + + // 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/playwright.config.ts b/playwright.config.ts index aa43bc8..26b0d12 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,14 +19,14 @@ const config: PlaywrightTestConfig = { * Maximum time expect() should wait for the condition to be met. * For example in `await expect(locator).toHaveText();` */ - timeout: process.env.CI ? 5000 : 500, + timeout: (process.env.CI ? 30 : 5) * 1000, }, /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 3 : 0, + retries: process.env.CI ? 1 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ @@ -34,7 +34,7 @@ const config: PlaywrightTestConfig = { /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: 0, + actionTimeout: (process.env.CI ? 30 : 10) * 1000, /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://localhost:3000', From 3283c55e30fc42c2e8a645ee76dd4befb7048d84 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Tue, 7 Mar 2023 09:57:19 -0800 Subject: [PATCH 15/18] Making requested changes --- backend/src/mastodon/instance.ts | 6 ++++-- backend/src/mastodon/sql/instance.ts | 22 ++++++++++++++++++++-- backend/src/types/env.ts | 3 --- config/ua.ts | 2 +- config/versions.ts | 6 ------ functions/api/v1/instance.ts | 20 ++++++++++---------- functions/api/v2/instance.ts | 4 ++-- 7 files changed, 37 insertions(+), 26 deletions(-) diff --git a/backend/src/mastodon/instance.ts b/backend/src/mastodon/instance.ts index 1650b44..78c1ded 100644 --- a/backend/src/mastodon/instance.ts +++ b/backend/src/mastodon/instance.ts @@ -1,9 +1,11 @@ import type { InstanceStatistics } from 'wildebeest/backend/src/types/instance' -import { instanceStatisticsQuery } from 'wildebeest/backend/src/mastodon/sql/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(instanceStatisticsQuery(origin)).first() + 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, diff --git a/backend/src/mastodon/sql/instance.ts b/backend/src/mastodon/sql/instance.ts index c87ec23..64dd24e 100644 --- a/backend/src/mastodon/sql/instance.ts +++ b/backend/src/mastodon/sql/instance.ts @@ -1,8 +1,26 @@ // Prepared statements for Mastodon Instance API endpoints -export const instanceStatisticsQuery = (origin: string): string => { +/** 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 '%${origin}/ap/users/%') AS user_count, + (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/env.ts b/backend/src/types/env.ts index 42cc4e6..1862a70 100644 --- a/backend/src/types/env.ts +++ b/backend/src/types/env.ts @@ -21,9 +21,6 @@ export interface Env { INSTANCE_DESCR: string VAPID_JWK: string DOMAIN: string - INSTANCE_ACCEPTING_REGISTRATIONS?: boolean - INSTANCE_REGISTRATIONS_REQUIRE_APPROVAL?: boolean - INSTANCE_CONFIG_STATUSES_MAX_CHARACTERS?: number SENTRY_DSN: string SENTRY_ACCESS_CLIENT_ID: string 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 8d54537..66cb01f 100644 --- a/config/versions.ts +++ b/config/versions.ts @@ -4,9 +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(domain?: string): string { - return `Wildebeest/${WILDEBEEST_VERSION} (Mastodon/${MASTODON_API_VERSION} compatible; +https://${ - domain ?? 'github.com/cloudflare/wildebeest' - })` -} diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index ab30d2d..b4346c1 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -4,7 +4,7 @@ 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' @@ -31,20 +31,20 @@ export async function handleRequest(domain: string, env: Env, dbOverride?: Datab description: env?.INSTANCE_DESCR, short_description: env?.INSTANCE_DESCR, email: env?.ADMIN_EMAIL, - version: getVersion(domain), + version: getFederationUA(domain), languages: ['en'], - registrations: env?.INSTANCE_ACCEPTING_REGISTRATIONS ?? false, - approval_required: env?.INSTANCE_REGISTRATIONS_REQUIRE_APPROVAL ?? false, + registrations: false, + approval_required: false, invites_enabled: false, thumbnail: DEFAULT_THUMBNAIL, configuration: { statuses: { - max_characters: env?.INSTANCE_CONFIG_STATUSES_MAX_CHARACTERS ?? 500, + 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', 'video/mp4'], + supported_mime_types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], image_size_limit: 10485760, image_matrix_limit: 16777216, video_size_limit: 41943040, @@ -52,10 +52,10 @@ export async function handleRequest(domain: string, env: Env, dbOverride?: Datab video_matrix_limit: 2304000, }, polls: { - max_options: 4, - max_characters_per_option: 50, - min_expiration: 300, - max_expiration: 2629746, + max_options: 0, + max_characters_per_option: 1, + min_expiration: 1, + max_expiration: 1, }, }, rules: [], diff --git a/functions/api/v2/instance.ts b/functions/api/v2/instance.ts index e86d362..884e315 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 }) => { @@ -19,7 +19,7 @@ export async function handleRequest(domain: string, db: Database, env: Env) { const res: InstanceConfigV2 = { domain, title: env.INSTANCE_TITLE, - version: getVersion(domain), + version: getFederationUA(domain), source_url: 'https://github.com/cloudflare/wildebeest', description: env.INSTANCE_DESCR, thumbnail: { From 79c3b61c812fea16596646eb59ddfebdf9c71b13 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Wed, 8 Mar 2023 10:31:45 -0800 Subject: [PATCH 16/18] Reverting breaking change to `getVersion()` --- config/versions.ts | 4 ++++ functions/api/v2/instance.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/config/versions.ts b/config/versions.ts index 66cb01f..f578bf3 100644 --- a/config/versions.ts +++ b/config/versions.ts @@ -4,3 +4,7 @@ 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})` +} \ No newline at end of file diff --git a/functions/api/v2/instance.ts b/functions/api/v2/instance.ts index 884e315..adc34b2 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 { getFederationUA } from 'wildebeest/config/ua' +import { getVersion } from 'wildebeest/config/versions' import { type Database, getDatabase } from 'wildebeest/backend/src/database' export const onRequest: PagesFunction = async ({ env, request }) => { @@ -19,7 +19,7 @@ export async function handleRequest(domain: string, db: Database, env: Env) { const res: InstanceConfigV2 = { domain, title: env.INSTANCE_TITLE, - version: getFederationUA(domain), + version: getVersion(), source_url: 'https://github.com/cloudflare/wildebeest', description: env.INSTANCE_DESCR, thumbnail: { From 35ed2d18dd13f4883d57098253e5245c94b37699 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Wed, 8 Mar 2023 11:20:10 -0800 Subject: [PATCH 17/18] Prettier --- config/versions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/versions.ts b/config/versions.ts index f578bf3..737dd2e 100644 --- a/config/versions.ts +++ b/config/versions.ts @@ -7,4 +7,4 @@ export const WILDEBEEST_VERSION = packagejson.version export function getVersion(): string { return `${MASTODON_API_VERSION} (compatible; Wildebeest ${WILDEBEEST_VERSION})` -} \ No newline at end of file +} From a8eb07759f0cf6030007644c254ce61e7cb2ae78 Mon Sep 17 00:00:00 2001 From: "Jorge Caballero (DataDrivenMD)" <116459476+DataDrivenMD@users.noreply.github.com> Date: Thu, 9 Mar 2023 08:10:15 -0800 Subject: [PATCH 18/18] Requested changes + bind DB for e2e tests --- backend/src/mastodon/instance.ts | 4 +- backend/src/mastodon/sql/instance.ts | 4 +- backend/src/types/configs.ts | 11 ---- backend/src/utils/auth/isUserAdmin.ts | 4 +- backend/test/mastodon/instance.spec.ts | 1 + frontend/src/routes/(frontend)/layout.tsx | 10 +-- frontend/src/utils/instanceConfig.ts | 6 +- functions/api/v1/instance.ts | 78 ++++++++++++----------- package.json | 4 +- 9 files changed, 60 insertions(+), 62 deletions(-) diff --git a/backend/src/mastodon/instance.ts b/backend/src/mastodon/instance.ts index 78c1ded..a4f73a1 100644 --- a/backend/src/mastodon/instance.ts +++ b/backend/src/mastodon/instance.ts @@ -2,9 +2,9 @@ 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 { +export async function calculateInstanceStatistics(domain: string, db: Database): Promise { const row: any = await db - .prepare(sqlMastoV1InstanceStats(origin)) + .prepare(sqlMastoV1InstanceStats(domain)) .first<{ user_count: number; status_count: number; domain_count: number }>() return { diff --git a/backend/src/mastodon/sql/instance.ts b/backend/src/mastodon/sql/instance.ts index 64dd24e..e2124db 100644 --- a/backend/src/mastodon/sql/instance.ts +++ b/backend/src/mastodon/sql/instance.ts @@ -7,13 +7,13 @@ * * * ``` - * const sqlQuery: string = sqlMastoV1InstanceStats('https://example.com') + * 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** (i.e. must include the https://) + * @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 */ 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/utils/auth/isUserAdmin.ts b/backend/src/utils/auth/isUserAdmin.ts index 704959f..c4d0ef6 100644 --- a/backend/src/utils/auth/isUserAdmin.ts +++ b/backend/src/utils/auth/isUserAdmin.ts @@ -1,8 +1,8 @@ import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors' import { Database } from 'wildebeest/backend/src/database' import { getJwtEmail } from 'wildebeest/backend/src/utils/auth/getJwtEmail' -import { getAdmins } from './getAdmins' -import { isUserAuthenticated } from './isUserAuthenticated' +import { getAdmins } from 'wildebeest/backend/src/utils/auth/getAdmins' +import { isUserAuthenticated } from 'wildebeest/backend/src/utils/auth/isUserAuthenticated' export async function isUserAdmin( request: Request, diff --git a/backend/test/mastodon/instance.spec.ts b/backend/test/mastodon/instance.spec.ts index c97b165..4418b9d 100644 --- a/backend/test/mastodon/instance.spec.ts +++ b/backend/test/mastodon/instance.spec.ts @@ -21,6 +21,7 @@ describe('Mastodon APIs', () => { INSTANCE_TITLE: 'a', ADMIN_EMAIL: admin_email, INSTANCE_DESCR: 'c', + DOMAIN: domain, } as Env test('return the correct instance admin', async () => { 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 b4346c1..695f881 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -4,39 +4,57 @@ 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 { getFederationUA } from 'wildebeest/config/ua' +import { getVersion } from 'wildebeest/config/versions' import { calculateInstanceStatistics } from 'wildebeest/backend/src/mastodon/instance' -import { MastodonInstance, InstanceStatistics } from 'wildebeest/backend/src/types/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: string = new URL(request.url).hostname - const dbOverride: Database = await getDatabase(env) - return handleRequest(domain, env, dbOverride) + const db: Database = await getDatabase(env) + return handleRequest(domain, env, db) } -export async function handleRequest(domain: string, env: Env, dbOverride?: Database) { +export async function handleRequest(domain: string, env: Env, db: Database) { const headers = { ...cors(), 'content-type': 'application/json; charset=utf-8', } + if (env.ADMIN_EMAIL === 'george@test.email') { + db = await getDatabase(env) + } + + 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) + } + + const statsDomain: string = env.ADMIN_EMAIL === 'george@test.email' ? '0.0.0.0' : domain + 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), + 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, @@ -58,36 +76,24 @@ export async function handleRequest(domain: string, env: Env, dbOverride?: Datab max_expiration: 1, }, }, - rules: [], + rules: await getRules(db), } + let adminAccount: MastodonAccount | undefined + try { - const db = dbOverride ?? (await getDatabase(env)) - if (db === undefined) { - console.warn('Server misconfiguration: missing database binding') - return new Response(JSON.stringify(res), { headers }) - } - - const instanceStatistics: InstanceStatistics = await calculateInstanceStatistics(domain, db) - res.stats = instanceStatistics - 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) - } + const adminPerson = adminActors.find((admin) => admin[emailSymbol] === env.ADMIN_EMAIL) - // prettier-ignore - res.contact_account = adminAccounts.has(env?.ADMIN_EMAIL) ? adminAccounts.get(env?.ADMIN_EMAIL) : Array.from(adminAccounts.values())[0] + if (!adminPerson) { + adminAccount = undefined + console.warn('Server misconfiguration: no admin account was found') + } else { + adminAccount = (await loadLocalMastodonAccount(db, adminPerson)) as MastodonAccount } - return new Response(JSON.stringify(res), { headers }) - } catch (e: any) { - console.error(`Server misconfiguration.`) - return new Response(JSON.stringify(res), { headers }) + } 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" },