Jorge Caballero 2023-08-02 11:03:30 +01:00 zatwierdzone przez GitHub
commit 6d10771382
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
11 zmienionych plików z 344 dodań i 126 usunięć

Wyświetl plik

@ -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<InstanceStatistics> {
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
}

Wyświetl plik

@ -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
;
`
}

Wyświetl plik

@ -1,15 +1,4 @@
// https://docs.joinmastodon.org/entities/Instance/
export type InstanceConfig = {
uri: string
title: string
thumbnail: string
languages: Array<string>
email: string
description: string
short_description?: string
rules: Array<Rule>
}
export type InstanceConfigV2 = {
domain: string
title: string

Wyświetl plik

@ -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<string>
registrations?: boolean
approval_required?: boolean
invites_enabled?: boolean
urls?: InstanceURL
statistics?: InstanceStatistics
stats?: InstanceStatistics
thumbnail?: string
contact_account?: MastodonAccount
rules?: Array<InstanceRule>
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<string>
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
}

Wyświetl plik

@ -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<Data>()
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<any>()
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<Data>()
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()

Wyświetl plik

@ -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<Array<string>>()
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<MastodonInstance>()
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<MastodonInstance>()
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<MastodonInstance>()
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<any>()
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<Array<string>>()
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<Data>()
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'))
}
})
})
})
})

Wyświetl plik

@ -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})`
}

Wyświetl plik

@ -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$<Promise<InstanceConfig>>(async ({ platform, html }) => {
export const instanceLoader = loader$<Promise<MastodonInstance>>(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 }

Wyświetl plik

@ -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>('InstanceConfig')
export const InstanceConfigContext = createContext<MastodonInstance>('MastodonInstance')

Wyświetl plik

@ -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<Env, any> = 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 })
}

Wyświetl plik

@ -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"
},