diff --git a/backend/src/accounts/getAccount.ts b/backend/src/accounts/getAccount.ts index 959e3fc4e..ff6faed8d 100644 --- a/backend/src/accounts/getAccount.ts +++ b/backend/src/accounts/getAccount.ts @@ -1,30 +1,54 @@ // https://docs.joinmastodon.org/methods/accounts/#get import { type Database } from 'wildebeest/backend/src/database' -import { actorURL, getActorById } from 'wildebeest/backend/src/activitypub/actors' -import { parseHandle } from 'wildebeest/backend/src/utils/parse' -import type { Handle } from 'wildebeest/backend/src/utils/parse' +import { parseHandle, type Handle } from 'wildebeest/backend/src/utils/parse' +import { isHandle, urlToHandle } from 'wildebeest/backend/src/utils/handle' +import { isNumeric } from 'wildebeest/backend/src/utils/id' import { queryAcct } from 'wildebeest/backend/src/webfinger/index' import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' -import { MastodonAccount } from '../types' -import { adjustLocalHostDomain } from '../utils/adjustLocalHostDomain' - -export async function getAccount(domain: string, accountId: string, db: Database): Promise { - const handle = parseHandle(accountId) - - if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) { - // Retrieve the statuses from a local user - return getLocalAccount(domain, db, handle) - } else if (handle.domain !== null) { - // Retrieve the statuses of a remote actor - const acct = `${handle.localPart}@${handle.domain}` - return getRemoteAccount(handle, acct, db) +import { MastodonAccount } from 'wildebeest/backend/src/types' +// import { adjustLocalHostDomain } from '../utils/adjustLocalHostDomain' +import { findMastodonAccountIDByEmailQuery } from 'wildebeest/backend/src/mastodon/sql/account' + +export async function getAccount( + localDomain: string, + mastodonAcctOrAPObjectId: string, + db: Database +): Promise { + if (isHandle(mastodonAcctOrAPObjectId)) { + const handle: Handle = parseHandle(mastodonAcctOrAPObjectId) + return await _getAccount(handle, localDomain, db) + } else if (isNumeric(mastodonAcctOrAPObjectId)) { + console.error(`NOT IMPLEMENTED - MASTODON ID: ${mastodonAcctOrAPObjectId}`) + return null + } else { + try { + const apObjectId: URL = new URL(mastodonAcctOrAPObjectId) + const handle: Handle = parseHandle(urlToHandle(apObjectId)) + return await _getAccount(handle, localDomain, db) + } catch { + console.error(`Unrecognized account identified: ${mastodonAcctOrAPObjectId}`) + return null + } + } +} + +async function _getAccount(handle: Handle, localDomain: string, db: Database): Promise { + if (handle.domain !== null && handle.domain !== localDomain) { + return await getRemoteAccount(handle, localDomain, db) + } else if (handle.domain === null || handle.domain === localDomain) { + handle.domain = localDomain + return await getLocalAccount(handle, localDomain, db) } else { + console.error( + `Unable to find account 'handle.localPart' = ${handle.localPart} and 'handle.domain' = ${handle.domain}` + ) return null } } -async function getRemoteAccount(handle: Handle, acct: string, db: Database): Promise { +async function getRemoteAccount(handle: Handle, localDomain: string, db: Database): Promise { + const acct = `${handle.localPart}@${handle.domain}` // TODO: using webfinger isn't the optimal implementation. We could cache // the object in D1 and directly query the remote API, indicated by the actor's // url field. For now, let's keep it simple. @@ -36,13 +60,20 @@ async function getRemoteAccount(handle: Handle, acct: string, db: Database): Pro return await loadExternalMastodonAccount(acct, actor, true) } -async function getLocalAccount(domain: string, db: Database, handle: Handle): Promise { - const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart) +async function getLocalAccount(handle: Handle, localDomain: string, db: Database): Promise { + // const handle = parseHandle(mastodonAcctOrAPObjectId) + // const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart) - const actor = await getActorById(db, actorId) - if (actor === null) { - return null - } + // const actor = await getActorById(db, actorId) + // if (actor === null) { + // return null + // } + + return await loadLocalMastodonAccount(handle, localDomain, db) +} + +export async function getAccountByEmail(domain: string, email: string, db: Database): Promise { + const row: any = await db.prepare(findMastodonAccountIDByEmailQuery).bind(email).first() - return await loadLocalMastodonAccount(db, actor) + return await getAccount(domain, row?.id, db) } diff --git a/backend/src/activitypub/actors/index.ts b/backend/src/activitypub/actors/index.ts index 6a6b32434..16551e7c5 100644 --- a/backend/src/activitypub/actors/index.ts +++ b/backend/src/activitypub/actors/index.ts @@ -1,9 +1,10 @@ import { defaultImages } from 'wildebeest/config/accounts' import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops' -import { type APObject, sanitizeContent, getTextContent } from '../objects' +import { type APObject, sanitizeContent, getTextContent } from 'wildebeest/backend/src/activitypub/objects' import { addPeer } from 'wildebeest/backend/src/activitypub/peers' import { type Database } from 'wildebeest/backend/src/database' import { Buffer } from 'buffer' +import { createMastodonId } from 'wildebeest/backend/src/utils/id' const PERSON = 'Person' const isTesting = typeof jest !== 'undefined' @@ -34,6 +35,10 @@ export interface Person extends Actor { } } +export interface WBActor extends Actor { + mastodon_id?: string +} + export async function get(url: string | URL): Promise { const headers = { accept: 'application/activity+json', @@ -99,7 +104,10 @@ export async function getAndCache(url: URL, db: Database): Promise { throw new Error('missing fields on Actor') } - const properties = actor + const properties = actor as WBActor + if (properties.mastodon_id === undefined) { + properties.mastodon_id = createMastodonId(actor.id.toString()) + } const sql = ` INSERT INTO actors (id, type, properties) @@ -129,10 +137,11 @@ export async function getPersonByEmail(db: Database, email: string): Promise return null } const row: any = results[0] - return personFromRow(row) + return await personFromRow(row, db) +} + +export async function getPersonByMastodonId(mastodon_id: string, db: Database): Promise { + const stmt = db.prepare("SELECT * FROM actors WHERE properties ->> '$.mastodon_id' =?").bind(mastodon_id) + const { results } = await stmt.all() + if (!results || results.length === 0) { + return null + } + const row: any = results[0] + return await personFromRow(row, db) +} + +export async function getPersonByMastodonAcct(acct: string, db: Database): Promise { + const stmt = db.prepare("SELECT * FROM actors WHERE properties ->> '$.preferredUsername' =?").bind(acct) + const { results } = await stmt.all() + if (!results || results.length === 0) { + return null + } + const row: any = results[0] + return await personFromRow(row, db) } -export function personFromRow(row: any): Person { - const properties = JSON.parse(row.properties) as PersonProperties +export async function personFromRow(row: any, db: Database): Promise { + const properties: PersonProperties = JSON.parse(row.properties) as PersonProperties + // Old actors weren't created with `mastodon_id` add it now if missing + if (properties.mastodon_id === undefined) { + console.warn(`${properties.preferredUsername ?? 'unknown'} is missing 'mastodon_id'; adding now.`) + const mastodon_id: string = createMastodonId(row.id) + const { success, error } = await db + .prepare(`UPDATE actors SET properties = json_set(properties, '$.mastodon_id', ?) WHERE id=?`) + .bind(mastodon_id) + .run() + if (!success) { + throw new Error(`Unable to set 'mastodon_id' due to SQL error: ${error}`) + } else { + console.info(`Successfully added 'mastodon_id'`) + properties.mastodon_id = mastodon_id + } + } + const icon = properties.icon ?? { type: 'Image', mediaType: 'image/jpeg', @@ -275,26 +325,6 @@ export function personFromRow(row: any): Person { domain = new URL(row.original_actor_id).hostname } - // Old local actors weren't created with inbox/outbox/etc properties, so add - // them if missing. - { - if (properties.inbox === undefined) { - properties.inbox = id + '/inbox' - } - - if (properties.outbox === undefined) { - properties.outbox = id + '/outbox' - } - - if (properties.following === undefined) { - properties.following = id + '/following' - } - - if (properties.followers === undefined) { - properties.followers = id + '/followers' - } - } - return { // Hidden values [emailSymbol]: row.email, diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts index 07fe9af27..bfcc750ab 100644 --- a/backend/src/errors/index.ts +++ b/backend/src/errors/index.ts @@ -46,6 +46,21 @@ export function internalServerError(): Response { return generateErrorResponse('Internal Server Error', 500) } +export function malformedMastodonAccountRequest(id: string): Response { + console.warn(`Mastodon account ID is not a numeric value: ${id}`) + return resourceNotFound('account', id) +} + +export function malformedMastodonAccountLookup(acct: string): Response { + console.warn(`Lookup value is not a username or Webfinger address: '${acct}'`) + return resourceNotFound('account', acct) +} + +export function mastodonAccountNotFound(acct: string): Response { + console.warn(`Mastodon account not found: '${acct}'`) + return resourceNotFound('account', acct) +} + export function statusNotFound(id: string): Response { return resourceNotFound('status', id) } diff --git a/backend/src/mastodon/account.ts b/backend/src/mastodon/account.ts index 170b6750c..567a7f52d 100644 --- a/backend/src/mastodon/account.ts +++ b/backend/src/mastodon/account.ts @@ -1,42 +1,51 @@ import { MastodonAccount } from 'wildebeest/backend/src/types/account' import { unwrapPrivateKey } from 'wildebeest/backend/src/utils/key-ops' -import { Actor } from '../activitypub/actors' +import { getPersonByMastodonAcct, Actor, WBActor, Person } from 'wildebeest/backend/src/activitypub/actors' +import { parseHandle, type Handle } from 'wildebeest/backend/src/utils/parse' +import { isHandle } from 'wildebeest/backend/src/utils/handle' import { defaultImages } from 'wildebeest/config/accounts' import * as apOutbox from 'wildebeest/backend/src/activitypub/actors/outbox' import * as apFollow from 'wildebeest/backend/src/activitypub/actors/follow' import { type Database } from 'wildebeest/backend/src/database' +import { mastodonAccountStatisticsQuery } from 'wildebeest/backend/src/mastodon/sql/account' -function toMastodonAccount(acct: string, res: Actor): MastodonAccount { - const avatar = res.icon?.url.toString() ?? defaultImages.avatar - const header = res.image?.url.toString() ?? defaultImages.header +function toMastodonAccount(handle: Handle, localDomain: string, person: WBActor): MastodonAccount { + const avatar: string = person.icon?.url.toString() ?? defaultImages.avatar + const header: string = person.image?.url.toString() ?? defaultImages.header return { - acct, - - id: acct, - username: res.preferredUsername || res.name || 'unnamed', - url: res.url ? res.url.toString() : '', - display_name: res.name || res.preferredUsername || '', - note: res.summary || '', - created_at: res.published || new Date().toISOString(), - - avatar, + id: person.mastodon_id!, + username: handle.localPart, + acct: + handle.domain === null || handle.domain === localDomain + ? handle.localPart + : `${handle.localPart}@${handle.domain}`, + url: person.url ? person.url.toString() : '', + + display_name: person.name || person.preferredUsername || '', + note: person.summary || '', + avatar: avatar, avatar_static: avatar, - - header, + header: header, header_static: header, - locked: false, + fields: [], + emojis: [], + bot: false, - discoverable: true, group: false, - emojis: [], - fields: [], + discoverable: true, + noindex: undefined, + moved: undefined, + suspended: undefined, + limited: undefined, + created_at: person.published || new Date().toISOString(), + last_status_at: undefined, + statuses_count: 0, followers_count: 0, following_count: 0, - statuses_count: 0, } } @@ -45,43 +54,49 @@ export async function loadExternalMastodonAccount( acct: string, actor: Actor, loadStats: boolean = false -): Promise { - const account = toMastodonAccount(acct, actor) +): Promise { + if (!isHandle(acct)) { + const message: string = `'acct' must be a handle: 'acct' === ${acct}` + console.error(message) + return null + } + + const handle: Handle = parseHandle(acct) + const account = toMastodonAccount(handle, '', actor as WBActor) if (loadStats === true) { account.statuses_count = await apOutbox.countStatuses(actor) account.followers_count = await apFollow.countFollowers(actor) account.following_count = await apFollow.countFollowing(actor) } + return account } // Load a local user and return it as a MastodonAccount -export async function loadLocalMastodonAccount(db: Database, res: Actor): Promise { - const query = ` -SELECT - (SELECT count(*) - FROM outbox_objects - INNER JOIN objects ON objects.id = outbox_objects.object_id - WHERE outbox_objects.actor_id=? - AND objects.type = 'Note') AS statuses_count, - - (SELECT count(*) - FROM actor_following - WHERE actor_following.actor_id=?) AS following_count, - - (SELECT count(*) - FROM actor_following - WHERE actor_following.target_actor_id=?) AS followers_count - ` - - // For local user the acct is only the local part of the email address. - const acct = res.preferredUsername || 'unknown' - const account = toMastodonAccount(acct, res) - - const row: any = await db.prepare(query).bind(res.id.toString(), res.id.toString(), res.id.toString()).first() - account.statuses_count = row.statuses_count - account.followers_count = row.followers_count - account.following_count = row.following_count +export async function loadLocalMastodonAccount( + handle: Handle, + localDomain: string, + db: Database +): Promise { + if (handle.domain === null || handle.domain !== localDomain) { + const message: string = `'handle.domain' must be equal to 'localDomain' for local accounts: ${handle.domain} !== ${localDomain}` + console.warn(message) + return null + } + + const person: Person | null = await getPersonByMastodonAcct(handle.localPart, db) + if (person === null) { + const message: string = `Mastodon account not found: '${handle.localPart}'` + console.warn(message) + return null + } + + const account = toMastodonAccount(handle, localDomain, person) + + const stats: MastodonAccountStatistics = await calculateMastodonAccountStatistic(person.id.toString(), db) + account.statuses_count = stats.statuses_count + account.followers_count = stats.followers_count + account.following_count = stats.following_count return account } @@ -89,12 +104,21 @@ SELECT export async function getSigningKey(instanceKey: string, db: Database, actor: Actor): Promise { const stmt = db.prepare('SELECT privkey, privkey_salt FROM actors WHERE id=?').bind(actor.id.toString()) const { privkey, privkey_salt } = (await stmt.first()) as any + return unwrapPrivateKey(instanceKey, new Uint8Array(privkey), new Uint8Array(privkey_salt)) +} + +async function calculateMastodonAccountStatistic(actor_id: string, db: Database): Promise { + const row: any = await db.prepare(mastodonAccountStatisticsQuery).bind(actor_id, actor_id, actor_id).first() - if (privkey.buffer && privkey_salt.buffer) { - // neon.tech - return unwrapPrivateKey(instanceKey, new Uint8Array(privkey.buffer), new Uint8Array(privkey_salt.buffer)) - } else { - // D1 - return unwrapPrivateKey(instanceKey, new Uint8Array(privkey), new Uint8Array(privkey_salt)) + return { + statuses_count: row?.statuses_count ?? 0, + followers_count: row?.followers_count ?? 0, + following_count: row?.following_count ?? 0, } } + +type MastodonAccountStatistics = { + statuses_count: number + followers_count: number + following_count: number +} diff --git a/backend/src/mastodon/notification.ts b/backend/src/mastodon/notification.ts index 8d9690add..498aa07d6 100644 --- a/backend/src/mastodon/notification.ts +++ b/backend/src/mastodon/notification.ts @@ -17,6 +17,7 @@ import type { } from 'wildebeest/backend/src/types/notification' import { getSubscriptionForAllClients } from 'wildebeest/backend/src/mastodon/subscription' import type { Cache } from 'wildebeest/backend/src/cache' +import { MastodonAccount } from 'wildebeest/backend/src/types/account' export async function createNotification( db: Database, @@ -235,45 +236,47 @@ export async function getNotifications(db: Database, actor: Actor, domain: strin } const acct = urlToHandle(notifFromActorId) - const notifFromAccount = await loadExternalMastodonAccount(acct, notifFromActor) - - const notif: Notification = { - id: result.notif_id.toString(), - type: result.type, - created_at: new Date(result.notif_cdate).toISOString(), - account: notifFromAccount, - } + const notifFromAccount: MastodonAccount | null = await loadExternalMastodonAccount(acct, notifFromActor) + if (notifFromAccount !== null) { + const notif: Notification = { + id: result.notif_id.toString(), + type: result.type, + created_at: new Date(result.notif_cdate).toISOString(), + account: notifFromAccount, + } - if (result.type === 'mention' || result.type === 'favourite') { - const actorId = new URL(result.original_actor_id) - const actor = await actors.getAndCache(actorId, db) - - const acct = urlToHandle(actorId) - const account = await loadExternalMastodonAccount(acct, actor) - - notif.status = { - id: result.mastodon_id, - content: properties.content, - uri: result.id, - url: new URL(`/@${actor.preferredUsername}/${result.mastodon_id}`, 'https://' + domain), - created_at: new Date(result.cdate).toISOString(), - - account, - - // TODO: stub values - emojis: [], - media_attachments: [], - tags: [], - mentions: [], - replies_count: 0, - reblogs_count: 0, - favourites_count: 0, - visibility: 'public', - spoiler_text: '', + if (result.type === 'mention' || result.type === 'favourite') { + const actorId = new URL(result.original_actor_id) + const actor = await actors.getAndCache(actorId, db) + + const acct = urlToHandle(actorId) + const account: MastodonAccount | null = await loadExternalMastodonAccount(acct, actor) + if (account !== null) { + notif.status = { + id: result.mastodon_id, + content: properties.content, + uri: result.id, + url: new URL(`/@${actor.preferredUsername}/${result.mastodon_id}`, 'https://' + domain), + created_at: new Date(result.cdate).toISOString(), + + account, + + // TODO: stub values + emojis: [], + media_attachments: [], + tags: [], + mentions: [], + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + visibility: 'public', + spoiler_text: '', + } + } } - } - out.push(notif) + out.push(notif) + } } return out diff --git a/backend/src/mastodon/sql/account.ts b/backend/src/mastodon/sql/account.ts new file mode 100644 index 000000000..2929d5a42 --- /dev/null +++ b/backend/src/mastodon/sql/account.ts @@ -0,0 +1,38 @@ +// Prepared statements for Mastodon Account API endpoints +export const mastodonAccountStatisticsQuery = ` +SELECT + ( + SELECT COUNT(outbox.object_id) + FROM outbox_objects AS outbox + LEFT JOIN objects AS notes ON + outbox.target='https://www.w3.org/ns/activitystreams#Public' AND + notes.type = 'Note' AND + outbox.object_id = notes.id + WHERE + notes.id IS NOT NULL AND + outbox.actor_id=? + GROUP BY outbox.object_id + ) AS statuses_count, + ( + SELECT COUNT(relationships.id) + FROM actor_following AS relationships + WHERE relationships.target_actor_id=? + GROUP BY relationships.id + ) AS followers_count, + ( + SELECT COUNT(relationships.id) + FROM actor_following AS relationships + WHERE relationships.actor_id=? + GROUP BY relationships.id + ) AS following_count +;` + +export const findMastodonAccountIDByEmailQuery = ` +SELECT + id +FROM actors +WHERE + email=? +ORDER BY cdate DESC +LIMIT 1 +;` diff --git a/backend/src/mastodon/status.ts b/backend/src/mastodon/status.ts index ccbc53212..577027505 100644 --- a/backend/src/mastodon/status.ts +++ b/backend/src/mastodon/status.ts @@ -18,6 +18,7 @@ import { addObjectInOutbox } from '../activitypub/actors/outbox' import type { APObject } from 'wildebeest/backend/src/activitypub/objects' import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import { type Database } from 'wildebeest/backend/src/database' +import { MastodonAccount } from 'wildebeest/backend/src/types/account' export async function getMentions(input: string, instanceDomain: string, db: Database): Promise> { const mentions: Array = [] @@ -60,7 +61,10 @@ export async function toMastodonStatusFromObject( const actor = await actors.getAndCache(actorId, db) const acct = urlToHandle(actorId) - const account = await loadExternalMastodonAccount(acct, actor) + const mastodonAccount: MastodonAccount | null = await loadExternalMastodonAccount(acct, actor) + if (mastodonAccount === null) { + return null + } // FIXME: temporarly disable favourites and reblogs counts const favourites = [] @@ -90,7 +94,7 @@ export async function toMastodonStatusFromObject( uri: obj.id, url: new URL(`/@${actor.preferredUsername}/${obj[mastodonIdSymbol]}`, 'https://' + domain), created_at: obj.published || '', - account, + account: mastodonAccount, favourites_count: favourites.length, reblogs_count: reblogs.length, @@ -108,14 +112,20 @@ export async function toMastodonStatusFromRow(domain: string, db: Database, row: const properties = JSON.parse(row.properties) const actorId = new URL(row.publisher_actor_id) - const author = actors.personFromRow({ - id: row.actor_id, - cdate: row.actor_cdate, - properties: row.actor_properties, - }) + const author = await actors.personFromRow( + { + id: row.actor_id, + cdate: row.actor_cdate, + properties: row.actor_properties, + }, + db + ) const acct = urlToHandle(actorId) - const account = await loadExternalMastodonAccount(acct, author) + const mastodonAccount: MastodonAccount | null = await loadExternalMastodonAccount(acct, author) + if (mastodonAccount === null) { + return null + } if (row.favourites_count === undefined || row.reblogs_count === undefined || row.replies_count === undefined) { throw new Error('logic error; missing fields.') @@ -139,7 +149,7 @@ export async function toMastodonStatusFromRow(domain: string, db: Database, row: media_attachments: mediaAttachments, tags: [], mentions: [], - account, + account: mastodonAccount, spoiler_text: properties.spoiler_text ?? '', // TODO: stub values @@ -165,12 +175,15 @@ export async function toMastodonStatusFromRow(domain: string, db: Database, row: const actorId = new URL(properties.attributedTo) const acct = urlToHandle(actorId) const author = await actors.getAndCache(actorId, db) - const account = await loadExternalMastodonAccount(acct, author) + const mastodonAccount: MastodonAccount | null = await loadExternalMastodonAccount(acct, author) + if (mastodonAccount === null) { + return null + } // Restore reblogged status status.reblog = { ...status, - account, + account: mastodonAccount, } } diff --git a/backend/src/middleware/main.ts b/backend/src/middleware/main.ts index f226e8b79..8efd45791 100644 --- a/backend/src/middleware/main.ts +++ b/backend/src/middleware/main.ts @@ -28,7 +28,7 @@ async function loadContextData(db: Database, clientId: string, email: string, ct return false } - const person = actors.personFromRow(row) + const person = await actors.personFromRow(row, db) ctx.data.connectedActor = person ctx.data.identity = { email } diff --git a/backend/src/types/account.ts b/backend/src/types/account.ts index 819f4e918..30a83cd69 100644 --- a/backend/src/types/account.ts +++ b/backend/src/types/account.ts @@ -1,3 +1,5 @@ +import { CustomEmoji } from 'wildebeest/backend/src/types/custom_emoji' + // https://docs.joinmastodon.org/entities/Account/ // https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Account.java export interface MastodonAccount { @@ -5,51 +7,68 @@ export interface MastodonAccount { username: string acct: string url: string - display_name: string - note: string + display_name: string + note: MastodonHTML avatar: string avatar_static: string - header: string header_static: string + locked: boolean + fields: Array + emojis: Array - created_at: string + bot: boolean + group: boolean - locked?: boolean - bot?: boolean - discoverable?: boolean - group?: boolean + discoverable: boolean + noindex?: boolean + moved?: MastodonAccount + suspended?: boolean + limited?: boolean + created_at: string + last_status_at?: string + statuses_count: number followers_count: number following_count: number - statuses_count: number +} - emojis: Array - fields: Array +export type MastodonHTML = string +export type Emoji = CustomEmoji + +// https://docs.joinmastodon.org/entities/Account/#Field +export type Field = { + name: string + value: string + verified_at?: string } +// https://docs.joinmastodon.org/entities/Account/#source-privacy +export type Privacy = 'public' | 'unlisted' | 'private' | 'direct' + // https://docs.joinmastodon.org/entities/Relationship/ // https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java export type Relationship = { id: string } -export type Privacy = 'public' | 'unlisted' | 'private' | 'direct' - // https://docs.joinmastodon.org/entities/Account/#CredentialAccount export interface CredentialAccount extends MastodonAccount { - source: { - note: string - fields: Array - privacy: Privacy - sensitive: boolean - language: string - follow_requests_count: number - } + source: Source role: Role } +// https://docs.joinmastodon.org/entities/Account/#source +export type Source = { + note: string + fields: Array + privacy: Privacy + sensitive: boolean + language: string + follow_requests_count: number +} + // https://docs.joinmastodon.org/entities/Role/ export type Role = { id: string @@ -63,8 +82,6 @@ export type Role = { updated_at: string } -export type Field = { - name: string - value: string - verified_at?: string +export interface MutedAccount extends MastodonAccount { + mute_expires_at: string } diff --git a/backend/src/types/custom_emoji.ts b/backend/src/types/custom_emoji.ts new file mode 100644 index 000000000..c29d21c72 --- /dev/null +++ b/backend/src/types/custom_emoji.ts @@ -0,0 +1,8 @@ +// https://docs.joinmastodon.org/entities/CustomEmoji/ +export interface CustomEmoji { + shortcode: string + url: string + static_url: string + visible_in_picker: boolean + category: string +} diff --git a/backend/src/utils/handle.ts b/backend/src/utils/handle.ts index a9ad68694..e37635eb5 100644 --- a/backend/src/utils/handle.ts +++ b/backend/src/utils/handle.ts @@ -8,3 +8,11 @@ export function urlToHandle(input: URL): string { const localPart = parts[parts.length - 1] return `${localPart}@${host}` } + +export function isHandle(input: string): boolean { + // Loosely based on https://github.com/mastodon/mastodon/blob/aa98c8fbeb02fecac2681464fd7c0445deb466b1/app/models/account.rb#LL65 + // and https://www.regextester.com/103452 but removes the potentially exploitable patterns + const r: RegExp = + /^([a-z0-9_]+(?:[a-z0-9_-]+[a-z0-9_]+)?(?:@(?:(?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\.)+[a-zA-Z]{2,63})?)$/i + return input.search(r) !== -1 +} diff --git a/backend/src/utils/id.ts b/backend/src/utils/id.ts new file mode 100644 index 000000000..a0022a9b9 --- /dev/null +++ b/backend/src/utils/id.ts @@ -0,0 +1,22 @@ +// This method generates a Mastodon-compatible identifier +// see: https://github.com/mastodon/mastodon/blob/main/lib/mastodon/snowflake.rb +export function createMastodonId(text: string) { + const time_part = BigInt(Date.now()) << 16n + const sequence_base = BigInt( + '0x' + stringToHex(text + crypto.randomUUID().toString() + time_part.toString()).substring(0, 4) + ) + const tail = (sequence_base + crypto.getRandomValues(new BigUint64Array(1))[0]) & 65535n + return (time_part | tail).toString() +} + +const stringToHex = (str: string) => { + let hex: string = '' + for (let i = 0, l = str.length; i < l; i++) { + hex += str.charCodeAt(i).toString(16) + } + return hex +} + +export function isNumeric(value: any): boolean { + return !isNaN(value - parseFloat(value)) +} diff --git a/backend/test/mastodon/accounts.spec.ts b/backend/test/mastodon/accounts.spec.ts index 6ab164069..d166b93c5 100644 --- a/backend/test/mastodon/accounts.spec.ts +++ b/backend/test/mastodon/accounts.spec.ts @@ -11,7 +11,8 @@ import * as accounts_followers from 'wildebeest/functions/api/v1/accounts/[id]/f import * as accounts_follow from 'wildebeest/functions/api/v1/accounts/[id]/follow' import * as accounts_unfollow from 'wildebeest/functions/api/v1/accounts/[id]/unfollow' import * as accounts_statuses from 'wildebeest/functions/api/v1/accounts/[id]/statuses' -import * as accounts_get from 'wildebeest/functions/api/v1/accounts/[id]' +import * as accounts_lookup from 'wildebeest/functions/api/v1/accounts/lookup' +// import * as accounts_get from 'wildebeest/functions/api/v1/accounts/[id]' import { isUUID, isUrlValid, makeDB, assertCORS, assertJSON, makeQueue } from '../utils' import * as accounts_verify_creds from 'wildebeest/functions/api/v1/accounts/verify_credentials' import * as accounts_update_creds from 'wildebeest/functions/api/v1/accounts/update_credentials' @@ -21,1051 +22,1077 @@ import { insertLike } from 'wildebeest/backend/src/mastodon/like' import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog' import * as filters from 'wildebeest/functions/api/v1/filters' import { createStatus } from 'wildebeest/backend/src/mastodon/status' +import { actorURL } from 'wildebeest/backend/src/activitypub/actors' const userKEK = 'test_kek2' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) const domain = 'cloudflare.com' describe('Mastodon APIs', () => { - describe('accounts', () => { - beforeEach(() => { - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') { - return new Response( - JSON.stringify({ - links: [ - { - rel: 'self', - type: 'application/activity+json', - href: 'https://social.com/sven', - }, - ], - }) - ) - } - - if (input.toString() === 'https://social.com/sven') { - return new Response( - JSON.stringify({ - id: 'sven@remote.com', - type: 'Person', - preferredUsername: 'sven', - name: 'sven ssss', - - icon: { url: 'icon.jpg' }, - image: { url: 'image.jpg' }, - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - }) - - test('missing identity', async () => { - const data = { - cloudflareAccess: { - JWT: { - getIdentity() { - return null - }, - }, - }, - } - - const context: any = { data } - const res = await accounts_verify_creds.onRequest(context) - assert.equal(res.status, 401) - }) - - test('verify the credentials', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const connectedActor = actor - - const context: any = { data: { connectedActor }, env: { DATABASE: db } } - const res = await accounts_verify_creds.onRequest(context) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) - - const data = await res.json() - assert.equal(data.display_name, 'sven') - // Mastodon app expects the id to be a number (as string), it uses - // it to construct an URL. ActivityPub uses URL as ObjectId so we - // make sure we don't return the URL. - assert(!isUrlValid(data.id)) - }) - - test('update credentials', async () => { - const db = await makeDB() - const queue = makeQueue() - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const updates = new FormData() - updates.set('display_name', 'newsven') - updates.set('note', 'hein') - - const req = new Request('https://example.com', { - method: 'PATCH', - body: updates, - }) - const res = await accounts_update_creds.handleRequest( - db, - req, - connectedActor, - 'CF_ACCOUNT_ID', - 'CF_API_TOKEN', - userKEK, - queue - ) - assert.equal(res.status, 200) - - const data = await res.json() - assert.equal(data.display_name, 'newsven') - assert.equal(data.note, 'hein') - - const updatedActor: any = await getActorById(db, connectedActor.id) - assert(updatedActor) - assert.equal(updatedActor.name, 'newsven') - assert.equal(updatedActor.summary, 'hein') - }) - - test('update credentials sends update to follower', async () => { - const db = await makeDB() - const queue = makeQueue() - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - await addFollowing(db, actor2, connectedActor, 'sven2@' + domain) - await acceptFollowing(db, actor2, connectedActor) - - const updates = new FormData() - updates.set('display_name', 'newsven') - - const req = new Request('https://example.com', { - method: 'PATCH', - body: updates, - }) - const res = await accounts_update_creds.handleRequest( - db, - req, - connectedActor, - 'CF_ACCOUNT_ID', - 'CF_API_TOKEN', - userKEK, - queue - ) - assert.equal(res.status, 200) - - assert.equal(queue.messages.length, 1) - - assert.equal(queue.messages[0].type, MessageType.Deliver) - assert.equal(queue.messages[0].activity.type, 'Update') - assert.equal(queue.messages[0].actorId, connectedActor.id.toString()) - assert.equal(queue.messages[0].toActorId, actor2.id.toString()) - }) - - test('update credentials avatar and header', async () => { - globalThis.fetch = async (input: RequestInfo, data: any) => { - if (input === 'https://api.cloudflare.com/client/v4/accounts/CF_ACCOUNT_ID/images/v1') { - assert.equal(data.method, 'POST') - const file: any = (data.body as { get: (str: string) => any }).get('file') - return new Response( - JSON.stringify({ - success: true, - result: { - variants: [ - 'https://example.com/' + file.name + '/avatar', - 'https://example.com/' + file.name + '/header', - ], - }, - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - const queue = makeQueue() - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const updates = new FormData() - updates.set('avatar', new File(['bytes'], 'selfie.jpg', { type: 'image/jpeg' })) - updates.set('header', new File(['bytes2'], 'mountain.jpg', { type: 'image/jpeg' })) + describe('/v1', () => { + describe('/accounts', () => { + describe('/lookup', () => { + test('lookup using remote Mastodon account handle should succeed', async () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asven%40social.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/someone', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/someone') { + return new Response( + JSON.stringify({ + id: 'https://social.com/someone', + url: 'https://social.com/@someone', + type: 'Person', + preferredUsername: 'sven', + name: 'Sven Cool', + outbox: 'https://social.com/someone/outbox', + following: 'https://social.com/someone/following', + followers: 'https://social.com/someone/followers', + }) + ) + } + + if (input.toString() === 'https://social.com/someone/following') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://social.com/someone/following', + type: 'OrderedCollection', + totalItems: 123, + first: 'https://social.com/someone/following/page', + }) + ) + } + + if (input.toString() === 'https://social.com/someone/followers') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://social.com/someone/followers', + type: 'OrderedCollection', + totalItems: 321, + first: 'https://social.com/someone/followers/page', + }) + ) + } + + if (input.toString() === 'https://social.com/someone/outbox') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://social.com/someone/outbox', + type: 'OrderedCollection', + totalItems: 890, + first: 'https://social.com/someone/outbox/page', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } - const req = new Request('https://example.com', { - method: 'PATCH', - body: updates, + const db = await makeDB() + const res = await accounts_lookup.handleRequest(domain, 'sven@social.com', db) + assert.equal(res.status, 200) + + const data = await res.json() + // Note the sanitization + assert.equal(data.username, 'badsven') + assert.equal(data.display_name, 'Sven Cool') + assert.equal(data.acct, 'sven@social.com') + + assert(isUrlValid(data.url)) + assert(data.url, 'https://social.com/@someone') + + assert.equal(data.followers_count, 321) + assert.equal(data.following_count, 123) + assert.equal(data.statuses_count, 890) + }) + + test('lookup using local Mastodon account username should succeed', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + const actor3 = await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') + await addFollowing(db, actor, actor2, 'sven2@' + domain) + await acceptFollowing(db, actor, actor2) + await addFollowing(db, actor, actor3, 'sven3@' + domain) + await acceptFollowing(db, actor, actor3) + await addFollowing(db, actor3, actor, 'sven@' + domain) + await acceptFollowing(db, actor3, actor) + + await createStatus(domain, db, actor, 'my first status') + + const res = await accounts_lookup.handleRequest(domain, 'sven3', db) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.username, 'sven3') + assert.equal(data.acct, 'sven3') + assert.equal(data.followers_count, 1) + assert.equal(data.following_count, 1) + assert.equal(data.statuses_count, 0) + assert(isUrlValid(data.url)) + assert((data.url as string).includes(domain)) + }) + + test('lookup using unknown local Mastodon account username should fail', async () => { + const db = await makeDB() + const res = await accounts_lookup.handleRequest(domain, 'sven', db) + assert.equal(res.status, 404) + }) + + test('lookup using Mastodon ID should fail', async () => { + const db = await makeDB() + const res = await accounts_lookup.handleRequest(domain, '12339500194940588', db) + assert.equal(res.status, 404) + }) + + test('lookup using ActivityPub ID should fail', async () => { + const db = await makeDB() + const res = await accounts_lookup.handleRequest(domain, actorURL(domain, 'sven').toString(), db) + assert.equal(res.status, 404) + }) }) - const res = await accounts_update_creds.handleRequest( - db, - req, - connectedActor, - 'CF_ACCOUNT_ID', - 'CF_API_TOKEN', - userKEK, - queue - ) - assert.equal(res.status, 200) - - const data = await res.json() - assert.equal(data.avatar, 'https://example.com/selfie.jpg/avatar') - assert.equal(data.header, 'https://example.com/mountain.jpg/header') - }) - test('get remote actor by id', async () => { - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asven%40social.com') { - return new Response( - JSON.stringify({ - links: [ - { - rel: 'self', - type: 'application/activity+json', - href: 'https://social.com/someone', + describe('/id', () => { + beforeEach(() => { + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/sven', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/sven') { + return new Response( + JSON.stringify({ + id: 'sven@remote.com', + type: 'Person', + preferredUsername: 'sven', + name: 'sven ssss', + + icon: { url: 'icon.jpg' }, + image: { url: 'image.jpg' }, + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + }) + + test('missing identity', async () => { + const data = { + cloudflareAccess: { + JWT: { + getIdentity() { + return null }, - ], - }) - ) - } - - if (input.toString() === 'https://social.com/someone') { - return new Response( - JSON.stringify({ - id: 'https://social.com/someone', - url: 'https://social.com/@someone', - type: 'Person', - preferredUsername: 'sven', - name: 'Sven Cool', - outbox: 'https://social.com/someone/outbox', - following: 'https://social.com/someone/following', - followers: 'https://social.com/someone/followers', - }) - ) - } - - if (input.toString() === 'https://social.com/someone/following') { - return new Response( - JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'https://social.com/someone/following', - type: 'OrderedCollection', - totalItems: 123, - first: 'https://social.com/someone/following/page', - }) - ) - } - - if (input.toString() === 'https://social.com/someone/followers') { - return new Response( - JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'https://social.com/someone/followers', - type: 'OrderedCollection', - totalItems: 321, - first: 'https://social.com/someone/followers/page', - }) - ) - } - - if (input.toString() === 'https://social.com/someone/outbox') { - return new Response( - JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'https://social.com/someone/outbox', - type: 'OrderedCollection', - totalItems: 890, - first: 'https://social.com/someone/outbox/page', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - const res = await accounts_get.handleRequest(domain, 'sven@social.com', db) - assert.equal(res.status, 200) - - const data = await res.json() - // Note the sanitization - assert.equal(data.username, 'badsven') - assert.equal(data.display_name, 'Sven Cool') - assert.equal(data.acct, 'sven@social.com') - - assert(isUrlValid(data.url)) - assert(data.url, 'https://social.com/@someone') - - assert.equal(data.followers_count, 321) - assert.equal(data.following_count, 123) - assert.equal(data.statuses_count, 890) - }) - - test('get unknown local actor by id', async () => { - const db = await makeDB() - const res = await accounts_get.handleRequest(domain, 'sven', db) - assert.equal(res.status, 404) - }) - - test('get local actor by id', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - const actor3 = await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') - await addFollowing(db, actor, actor2, 'sven2@' + domain) - await acceptFollowing(db, actor, actor2) - await addFollowing(db, actor, actor3, 'sven3@' + domain) - await acceptFollowing(db, actor, actor3) - await addFollowing(db, actor3, actor, 'sven@' + domain) - await acceptFollowing(db, actor3, actor) - - await createStatus(domain, db, actor, 'my first status') - - const res = await accounts_get.handleRequest(domain, 'sven', db) - assert.equal(res.status, 200) - - const data = await res.json() - assert.equal(data.username, 'sven') - assert.equal(data.acct, 'sven') - assert.equal(data.followers_count, 1) - assert.equal(data.following_count, 2) - assert.equal(data.statuses_count, 1) - assert(isUrlValid(data.url)) - assert((data.url as string).includes(domain)) - }) - - test('get local actor statuses', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const firstNote = await createStatus(domain, db, actor, 'my first status') - await insertLike(db, actor, firstNote) - await sleep(10) - const secondNote = await createStatus(domain, db, actor, 'my second status') - await insertReblog(db, actor, secondNote) - - const req = new Request('https://' + domain) - const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 2) - - assert(isUUID(data[0].id)) - assert.equal(data[0].content, 'my second status') - assert.equal(data[0].account.acct, 'sven@' + domain) - assert.equal(data[0].favourites_count, 0) - assert.equal(data[0].reblogs_count, 1) - assert.equal(new URL(data[0].uri).pathname, '/ap/o/' + data[0].id) - assert.equal(new URL(data[0].url).pathname, '/@sven/' + data[0].id) - - assert(isUUID(data[1].id)) - assert.equal(data[1].content, 'my first status') - assert.equal(data[1].favourites_count, 1) - assert.equal(data[1].reblogs_count, 0) - }) - - test("get local actor statuses doesn't include replies", async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const note = await createStatus(domain, db, actor, 'a post') - - await sleep(10) - - await createReply(domain, db, actor, note, 'a reply') - - const req = new Request('https://' + domain) - const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) - assert.equal(res.status, 200) - - const data = await res.json>() - - // Only 1 post because the reply is hidden - assert.equal(data.length, 1) - }) - - test('get local actor statuses includes media attachements', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const properties = { url: 'https://example.com/image.jpg' } - const mediaAttachments = [await createImage(domain, db, actor, properties)] - await createStatus(domain, db, actor, 'status from actor', mediaAttachments) - - const req = new Request('https://' + domain) - const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) - assert.equal(res.status, 200) - - const data = await res.json>() - - assert.equal(data.length, 1) - assert.equal(data[0].media_attachments.length, 1) - assert.equal(data[0].media_attachments[0].type, 'image') - assert.equal(data[0].media_attachments[0].url, properties.url) - }) - - test('get pinned statuses', async () => { - const db = await makeDB() - await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const req = new Request('https://' + domain + '?pinned=true') - const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 0) - }) - - test('get local actor statuses with max_id', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - await db - .prepare("INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id')") - .bind('object1', 'Note', JSON.stringify({ content: 'my first status' })) - .run() - await db - .prepare("INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id2')") - .bind('object2', 'Note', JSON.stringify({ content: 'my second status' })) - .run() - await db - .prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)') - .bind('outbox1', actor.id.toString(), 'object1', '2022-12-16 08:14:48') - .run() - await db - .prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)') - .bind('outbox2', actor.id.toString(), 'object2', '2022-12-16 10:14:48') - .run() - - { - // Query statuses after object1, should only see object2. - const req = new Request('https://' + domain + '?max_id=object1') - const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 1) - assert.equal(data[0].content, 'my second status') - assert.equal(data[0].account.acct, 'sven@' + domain) - } - - { - // Query statuses after object2, nothing is after. - const req = new Request('https://' + domain + '?max_id=object2') - const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 0) - } - }) - - test('get local actor statuses with max_id poiting to unknown id', async () => { - const db = await makeDB() - const req = new Request('https://' + domain + '?max_id=object1') - const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) - assert.equal(res.status, 404) - }) - - test('get remote actor statuses', async () => { - const db = await makeDB() - - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - - const note = await createPublicNote(domain, db, 'my localnote status', actorA, [], { - attributedTo: actorA.id.toString(), - }) + }, + }, + } - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') { - return new Response( - JSON.stringify({ - links: [ - { - rel: 'self', - type: 'application/activity+json', - href: 'https://social.com/users/someone', - }, - ], - }) + const context: any = { data } + const res = await accounts_verify_creds.onRequest(context) + assert.equal(res.status, 401) + }) + + test('verify the credentials', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const connectedActor = actor + + const context: any = { data: { connectedActor }, env: { DATABASE: db } } + const res = await accounts_verify_creds.onRequest(context) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const data = await res.json() + assert.equal(data.display_name, 'sven') + // Mastodon app expects the id to be a number (as string), it uses + // it to construct an URL. ActivityPub uses URL as ObjectId so we + // make sure we don't return the URL. + assert(!isUrlValid(data.id)) + }) + + test('update credentials', async () => { + const db = await makeDB() + const queue = makeQueue() + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const updates = new FormData() + updates.set('display_name', 'newsven') + updates.set('note', 'hein') + + const req = new Request('https://example.com', { + method: 'PATCH', + body: updates, + }) + const res = await accounts_update_creds.handleRequest( + db, + req, + connectedActor, + 'CF_ACCOUNT_ID', + 'CF_API_TOKEN', + userKEK, + queue ) - } - - if (input.toString() === 'https://social.com/users/someone') { - return new Response( - JSON.stringify({ - id: 'https://social.com/users/someone', - type: 'Person', - preferredUsername: 'someone', - outbox: 'https://social.com/outbox', - }) - ) - } - - if (input.toString() === 'https://social.com/outbox') { - return new Response( - JSON.stringify({ - first: 'https://social.com/outbox/page1', - }) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.display_name, 'newsven') + assert.equal(data.note, 'hein') + + const updatedActor: any = await getActorById(db, connectedActor.id) + assert(updatedActor) + assert.equal(updatedActor.name, 'newsven') + assert.equal(updatedActor.summary, 'hein') + }) + + test('update credentials sends update to follower', async () => { + const db = await makeDB() + const queue = makeQueue() + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + await addFollowing(db, actor2, connectedActor, 'sven2@' + domain) + await acceptFollowing(db, actor2, connectedActor) + + const updates = new FormData() + updates.set('display_name', 'newsven') + + const req = new Request('https://example.com', { + method: 'PATCH', + body: updates, + }) + const res = await accounts_update_creds.handleRequest( + db, + req, + connectedActor, + 'CF_ACCOUNT_ID', + 'CF_API_TOKEN', + userKEK, + queue ) - } - - if (input.toString() === 'https://social.com/outbox/page1') { - return new Response( - JSON.stringify({ - orderedItems: [ - { - id: 'https://mastodon.social/users/a/statuses/b/activity', - type: 'Create', - actor: 'https://social.com/users/someone', - published: '2022-12-10T23:48:38Z', - object: { - id: 'https://example.com/object1', - type: 'Note', - content: '

p

', - attachment: [ - { - type: 'Document', - mediaType: 'image/jpeg', - url: 'https://example.com/image', - name: null, - blurhash: 'U48;V;_24mx[_1~p.7%MW9?a-;xtxvWBt6ad', - width: 1080, - height: 894, - }, - { - type: 'Document', - mediaType: 'video/mp4', - url: 'https://example.com/video', - name: null, - blurhash: 'UB9jfvtT0gO^N5tSX4XV9uR%^Ni]D%Rj$*nf', - width: 1080, - height: 616, - }, + assert.equal(res.status, 200) + + assert.equal(queue.messages.length, 1) + + assert.equal(queue.messages[0].type, MessageType.Deliver) + assert.equal(queue.messages[0].activity.type, 'Update') + assert.equal(queue.messages[0].actorId, connectedActor.id.toString()) + assert.equal(queue.messages[0].toActorId, actor2.id.toString()) + }) + + test('update credentials avatar and header', async () => { + globalThis.fetch = async (input: RequestInfo, data: any) => { + if (input === 'https://api.cloudflare.com/client/v4/accounts/CF_ACCOUNT_ID/images/v1') { + assert.equal(data.method, 'POST') + const file: any = (data.body as { get: (str: string) => any }).get('file') + return new Response( + JSON.stringify({ + success: true, + result: { + variants: [ + 'https://example.com/' + file.name + '/avatar', + 'https://example.com/' + file.name + '/header', ], }, - }, - { - id: 'https://mastodon.social/users/c/statuses/d/activity', - type: 'Announce', - actor: 'https://social.com/users/someone', - published: '2022-12-10T23:48:38Z', - object: note.id, - }, - ], - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const req = new Request('https://example.com') - const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com') - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 2) - assert.equal(data[0].content, '

p

') - assert.equal(data[0].account.username, 'someone') - - assert.equal(data[0].media_attachments.length, 2) - assert.equal(data[0].media_attachments[0].type, 'image') - assert.equal(data[0].media_attachments[1].type, 'video') + }) + ) + } - // Statuses were imported locally and once was a reblog of an already - // existing local object. - const row: { count: number } = await db.prepare(`SELECT count(*) as count FROM objects`).first() - assert.equal(row.count, 2) - }) - - test('get remote actor statuses ignoring object that fail to download', async () => { - const db = await makeDB() - - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - await createPublicNote(domain, db, 'my localnote status', actor) - - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') { - return new Response( - JSON.stringify({ - links: [ - { - rel: 'self', - type: 'application/activity+json', - href: 'https://social.com/someone', - }, - ], - }) - ) - } - - if (input.toString() === 'https://social.com/someone') { - return new Response( - JSON.stringify({ - id: 'https://social.com/someone', - type: 'Person', - preferredUsername: 'someone', - outbox: 'https://social.com/outbox', - }) - ) - } - - if (input.toString() === 'https://social.com/outbox') { - return new Response( - JSON.stringify({ - first: 'https://social.com/outbox/page1', - }) - ) - } - - if (input.toString() === 'https://nonexistingobject.com/') { - return new Response('', { status: 400 }) - } - - if (input.toString() === 'https://social.com/outbox/page1') { - return new Response( - JSON.stringify({ - orderedItems: [ - { - id: 'https://mastodon.social/users/c/statuses/d/activity', - type: 'Announce', - actor: 'https://mastodon.social/users/someone', - published: '2022-12-10T23:48:38Z', - object: 'https://nonexistingobject.com', - }, - ], - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const req = new Request('https://example.com') - const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com') - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 0) - }) - - test('get remote actor followers', async () => { - const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') { - return new Response( - JSON.stringify({ - links: [ - { - rel: 'self', - type: 'application/activity+json', - href: 'https://example.com/users/sven', - }, - ], - }) - ) - } - - if (input.toString() === 'https://example.com/users/sven') { - return new Response( - JSON.stringify({ - id: 'https://example.com/users/sven', - type: 'Person', - followers: 'https://example.com/users/sven/followers', - }) - ) - } - - if (input.toString() === 'https://example.com/users/sven/followers') { - return new Response( - JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'https://example.com/users/sven/followers', - type: 'OrderedCollection', - totalItems: 3, - first: 'https://example.com/users/sven/followers/1', - }) - ) - } - - if (input.toString() === 'https://example.com/users/sven/followers/1') { - return new Response( - JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'https://example.com/users/sven/followers/1', - type: 'OrderedCollectionPage', - totalItems: 3, - partOf: 'https://example.com/users/sven/followers', - orderedItems: [ - actorA.id.toString(), // local user - 'https://example.com/users/b', // remote user - ], - }) - ) - } - - if (input.toString() === 'https://example.com/users/b') { - return new Response( - JSON.stringify({ - id: 'https://example.com/users/b', - type: 'Person', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const req = new Request(`https://${domain}`) - const res = await accounts_followers.handleRequest(req, db, 'sven@example.com') - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 2) - - assert.equal(data[0].acct, 'a@cloudflare.com') - assert.equal(data[1].acct, 'b@example.com') - }) - - test('get local actor followers', async () => { - globalThis.fetch = async (input: any) => { - if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') { - return new Response( - JSON.stringify({ - id: 'https://example.com/actor', - type: 'Person', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - await addFollowing(db, actor2, actor, 'sven@' + domain) - await acceptFollowing(db, actor2, actor) - - const req = new Request(`https://${domain}`) - const res = await accounts_followers.handleRequest(req, db, 'sven') - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 1) - }) - - test('get local actor following', async () => { - globalThis.fetch = async (input: any) => { - if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') { - return new Response( - JSON.stringify({ - id: 'https://example.com/foo', - type: 'Person', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - await addFollowing(db, actor, actor2, 'sven@' + domain) - await acceptFollowing(db, actor, actor2) - - const req = new Request(`https://${domain}`) - const res = await accounts_following.handleRequest(req, db, 'sven') - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 1) - }) + throw new Error('unexpected request to ' + input) + } - test('get remote actor following', async () => { - const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') { - return new Response( - JSON.stringify({ - links: [ - { - rel: 'self', - type: 'application/activity+json', - href: 'https://example.com/users/sven', - }, - ], - }) - ) - } - - if (input.toString() === 'https://example.com/users/sven') { - return new Response( - JSON.stringify({ - id: 'https://example.com/users/sven', - type: 'Person', - following: 'https://example.com/users/sven/following', - }) - ) - } - - if (input.toString() === 'https://example.com/users/sven/following') { - return new Response( - JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'https://example.com/users/sven/following', - type: 'OrderedCollection', - totalItems: 3, - first: 'https://example.com/users/sven/following/1', - }) - ) - } - - if (input.toString() === 'https://example.com/users/sven/following/1') { - return new Response( - JSON.stringify({ - '@context': 'https://www.w3.org/ns/activitystreams', - id: 'https://example.com/users/sven/following/1', - type: 'OrderedCollectionPage', - totalItems: 3, - partOf: 'https://example.com/users/sven/following', - orderedItems: [ - actorA.id.toString(), // local user - 'https://example.com/users/b', // remote user - ], - }) - ) - } - - if (input.toString() === 'https://example.com/users/b') { - return new Response( - JSON.stringify({ - id: 'https://example.com/users/b', - type: 'Person', - }) + const db = await makeDB() + const queue = makeQueue() + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const updates = new FormData() + updates.set('avatar', new File(['bytes'], 'selfie.jpg', { type: 'image/jpeg' })) + updates.set('header', new File(['bytes2'], 'mountain.jpg', { type: 'image/jpeg' })) + + const req = new Request('https://example.com', { + method: 'PATCH', + body: updates, + }) + const res = await accounts_update_creds.handleRequest( + db, + req, + connectedActor, + 'CF_ACCOUNT_ID', + 'CF_API_TOKEN', + userKEK, + queue ) - } - - throw new Error('unexpected request to ' + input) - } - - const req = new Request(`https://${domain}`) - const res = await accounts_following.handleRequest(req, db, 'sven@example.com') - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 2) - - assert.equal(data[0].acct, 'a@cloudflare.com') - assert.equal(data[1].acct, 'b@example.com') - }) - - test('get remote actor featured_tags', async () => { - const res = await accounts_featured_tags.onRequest() - assert.equal(res.status, 200) - }) - - test('get remote actor lists', async () => { - const res = await accounts_lists.onRequest() - assert.equal(res.status, 200) - }) - - describe('relationships', () => { - test('relationships missing ids', async () => { - const db = await makeDB() - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const req = new Request('https://mastodon.example/api/v1/accounts/relationships') - const res = await accounts_relationships.handleRequest(req, db, connectedActor) - assert.equal(res.status, 400) - }) - - test('relationships with ids', async () => { - const db = await makeDB() - const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first&id[]=second') - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const res = await accounts_relationships.handleRequest(req, db, connectedActor) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) - - const data = await res.json>() - assert.equal(data.length, 2) - assert.equal(data[0].id, 'first') - assert.equal(data[0].following, false) - assert.equal(data[1].id, 'second') - assert.equal(data[1].following, false) - }) - - test('relationships with one id', async () => { - const db = await makeDB() - const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first') - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const res = await accounts_relationships.handleRequest(req, db, connectedActor) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) - - const data = await res.json>() - assert.equal(data.length, 1) - assert.equal(data[0].id, 'first') - assert.equal(data[0].following, false) - }) - - test('relationships following', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - await addFollowing(db, actor, actor2, 'sven2@' + domain) - await acceptFollowing(db, actor, actor2) - - const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain) - const res = await accounts_relationships.handleRequest(req, db, actor) - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 1) - assert.equal(data[0].following, true) - }) - - test('relationships following request', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - await addFollowing(db, actor, actor2, 'sven2@' + domain) - - const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain) - const res = await accounts_relationships.handleRequest(req, db, actor) - assert.equal(res.status, 200) - - const data = await res.json>() - assert.equal(data.length, 1) - assert.equal(data[0].requested, true) - assert.equal(data[0].following, false) - }) - }) - - test('follow local account', async () => { - const db = await makeDB() + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.avatar, 'https://example.com/selfie.jpg/avatar') + assert.equal(data.header, 'https://example.com/mountain.jpg/header') + }) + + test('get local actor statuses', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const firstNote = await createStatus(domain, db, actor, 'my first status') + await insertLike(db, actor, firstNote) + await sleep(10) + const secondNote = await createStatus(domain, db, actor, 'my second status') + await insertReblog(db, actor, secondNote) + + const req = new Request('https://' + domain) + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 2) + + assert(isUUID(data[0].id)) + assert.equal(data[0].content, 'my second status') + assert.equal(data[0].account.acct, 'sven@' + domain) + assert.equal(data[0].favourites_count, 0) + assert.equal(data[0].reblogs_count, 1) + assert.equal(new URL(data[0].uri).pathname, '/ap/o/' + data[0].id) + assert.equal(new URL(data[0].url).pathname, '/@sven/' + data[0].id) + + assert(isUUID(data[1].id)) + assert.equal(data[1].content, 'my first status') + assert.equal(data[1].favourites_count, 1) + assert.equal(data[1].reblogs_count, 0) + }) + + test("get local actor statuses doesn't include replies", async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const note = await createStatus(domain, db, actor, 'a post') + + await sleep(10) + + await createReply(domain, db, actor, note, 'a reply') + + const req = new Request('https://' + domain) + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) + assert.equal(res.status, 200) + + const data = await res.json>() + + // Only 1 post because the reply is hidden + assert.equal(data.length, 1) + }) + + test('get local actor statuses includes media attachements', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const properties = { url: 'https://example.com/image.jpg' } + const mediaAttachments = [await createImage(domain, db, actor, properties)] + await createStatus(domain, db, actor, 'status from actor', mediaAttachments) + + const req = new Request('https://' + domain) + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) + assert.equal(res.status, 200) + + const data = await res.json>() + + assert.equal(data.length, 1) + assert.equal(data[0].media_attachments.length, 1) + assert.equal(data[0].media_attachments[0].type, 'image') + assert.equal(data[0].media_attachments[0].url, properties.url) + }) + + test('get pinned statuses', async () => { + const db = await makeDB() + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const req = new Request('https://' + domain + '?pinned=true') + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 0) + }) + + test('get local actor statuses with max_id', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + await db + .prepare( + "INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id')" + ) + .bind('object1', 'Note', JSON.stringify({ content: 'my first status' })) + .run() + await db + .prepare( + "INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id2')" + ) + .bind('object2', 'Note', JSON.stringify({ content: 'my second status' })) + .run() + await db + .prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)') + .bind('outbox1', actor.id.toString(), 'object1', '2022-12-16 08:14:48') + .run() + await db + .prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)') + .bind('outbox2', actor.id.toString(), 'object2', '2022-12-16 10:14:48') + .run() + + { + // Query statuses after object1, should only see object2. + const req = new Request('https://' + domain + '?max_id=object1') + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].content, 'my second status') + assert.equal(data[0].account.acct, 'sven@' + domain) + } - const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + { + // Query statuses after object2, nothing is after. + const req = new Request('https://' + domain + '?max_id=object2') + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) + assert.equal(res.status, 200) - const req = new Request('https://example.com', { method: 'POST' }) - const res = await accounts_follow.handleRequest(req, db, 'localuser', connectedActor, userKEK) - assert.equal(res.status, 403) - }) + const data = await res.json>() + assert.equal(data.length, 0) + } + }) + + test('get local actor statuses with max_id poiting to unknown id', async () => { + const db = await makeDB() + const req = new Request('https://' + domain + '?max_id=object1') + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain) + assert.equal(res.status, 404) + }) + + test('get remote actor statuses', async () => { + const db = await makeDB() + + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + + const note = await createPublicNote(domain, db, 'my localnote status', actorA, [], { + attributedTo: actorA.id.toString(), + }) + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/users/someone', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/users/someone') { + return new Response( + JSON.stringify({ + id: 'https://social.com/users/someone', + type: 'Person', + preferredUsername: 'someone', + outbox: 'https://social.com/outbox', + }) + ) + } + + if (input.toString() === 'https://social.com/outbox') { + return new Response( + JSON.stringify({ + first: 'https://social.com/outbox/page1', + }) + ) + } + + if (input.toString() === 'https://social.com/outbox/page1') { + return new Response( + JSON.stringify({ + orderedItems: [ + { + id: 'https://mastodon.social/users/a/statuses/b/activity', + type: 'Create', + actor: 'https://social.com/users/someone', + published: '2022-12-10T23:48:38Z', + object: { + id: 'https://example.com/object1', + type: 'Note', + content: '

p

', + attachment: [ + { + type: 'Document', + mediaType: 'image/jpeg', + url: 'https://example.com/image', + name: null, + blurhash: 'U48;V;_24mx[_1~p.7%MW9?a-;xtxvWBt6ad', + width: 1080, + height: 894, + }, + { + type: 'Document', + mediaType: 'video/mp4', + url: 'https://example.com/video', + name: null, + blurhash: 'UB9jfvtT0gO^N5tSX4XV9uR%^Ni]D%Rj$*nf', + width: 1080, + height: 616, + }, + ], + }, + }, + { + id: 'https://mastodon.social/users/c/statuses/d/activity', + type: 'Announce', + actor: 'https://social.com/users/someone', + published: '2022-12-10T23:48:38Z', + object: note.id, + }, + ], + }) + ) + } + + throw new Error('unexpected request to ' + input) + } - describe('follow', () => { - let receivedActivity: any = null - - beforeEach(() => { - receivedActivity = null - - globalThis.fetch = async (input: RequestInfo) => { - const request = new Request(input) - if (request.url === 'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + '') { - return new Response( - JSON.stringify({ - links: [ - { - rel: 'self', - type: 'application/activity+json', - href: `https://${domain}/ap/users/actor`, - }, - ], - }) - ) + const req = new Request('https://example.com') + const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com') + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 2) + assert.equal(data[0].content, '

p

') + assert.equal(data[0].account.username, 'someone') + + assert.equal(data[0].media_attachments.length, 2) + assert.equal(data[0].media_attachments[0].type, 'image') + assert.equal(data[0].media_attachments[1].type, 'video') + + // Statuses were imported locally and once was a reblog of an already + // existing local object. + const row: { count: number } = await db.prepare(`SELECT count(*) as count FROM objects`).first() + assert.equal(row.count, 2) + }) + + test('get remote actor statuses ignoring object that fail to download', async () => { + const db = await makeDB() + + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + await createPublicNote(domain, db, 'my localnote status', actor) + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/someone', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/someone') { + return new Response( + JSON.stringify({ + id: 'https://social.com/someone', + type: 'Person', + preferredUsername: 'someone', + outbox: 'https://social.com/outbox', + }) + ) + } + + if (input.toString() === 'https://social.com/outbox') { + return new Response( + JSON.stringify({ + first: 'https://social.com/outbox/page1', + }) + ) + } + + if (input.toString() === 'https://nonexistingobject.com/') { + return new Response('', { status: 400 }) + } + + if (input.toString() === 'https://social.com/outbox/page1') { + return new Response( + JSON.stringify({ + orderedItems: [ + { + id: 'https://mastodon.social/users/c/statuses/d/activity', + type: 'Announce', + actor: 'https://mastodon.social/users/someone', + published: '2022-12-10T23:48:38Z', + object: 'https://nonexistingobject.com', + }, + ], + }) + ) + } + + throw new Error('unexpected request to ' + input) } - if (request.url === `https://${domain}/ap/users/actor`) { - return new Response( - JSON.stringify({ - id: `https://${domain}/ap/users/actor`, - type: 'Person', - inbox: `https://${domain}/ap/users/actor/inbox`, - }) - ) + const req = new Request('https://example.com') + const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com') + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 0) + }) + + test('get remote actor followers', async () => { + const db = await makeDB() + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://example.com/users/sven', + }, + ], + }) + ) + } + + if (input.toString() === 'https://example.com/users/sven') { + return new Response( + JSON.stringify({ + id: 'https://example.com/users/sven', + type: 'Person', + followers: 'https://example.com/users/sven/followers', + }) + ) + } + + if (input.toString() === 'https://example.com/users/sven/followers') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/users/sven/followers', + type: 'OrderedCollection', + totalItems: 3, + first: 'https://example.com/users/sven/followers/1', + }) + ) + } + + if (input.toString() === 'https://example.com/users/sven/followers/1') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/users/sven/followers/1', + type: 'OrderedCollectionPage', + totalItems: 3, + partOf: 'https://example.com/users/sven/followers', + orderedItems: [ + actorA.id.toString(), // local user + 'https://example.com/users/b', // remote user + ], + }) + ) + } + + if (input.toString() === 'https://example.com/users/b') { + return new Response( + JSON.stringify({ + id: 'https://example.com/users/b', + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) } - if (request.url === `https://${domain}/ap/users/actor/inbox`) { - assert.equal(request.method, 'POST') - receivedActivity = await request.json() - return new Response('') + const req = new Request(`https://${domain}`) + const res = await accounts_followers.handleRequest(req, db, 'sven@example.com') + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 2) + + assert.equal(data[0].acct, 'a@cloudflare.com') + assert.equal(data[1].acct, 'b@example.com') + }) + + test('get local actor followers', async () => { + globalThis.fetch = async (input: any) => { + if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') { + return new Response( + JSON.stringify({ + id: 'https://example.com/actor', + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) } - throw new Error('unexpected request to ' + request.url) - } - }) + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + await addFollowing(db, actor2, actor, 'sven@' + domain) + await acceptFollowing(db, actor2, actor) + + const req = new Request(`https://${domain}`) + const res = await accounts_followers.handleRequest(req, db, 'sven') + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + }) + + test('get local actor following', async () => { + globalThis.fetch = async (input: any) => { + if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') { + return new Response( + JSON.stringify({ + id: 'https://example.com/foo', + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } - test('follow account', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const connectedActor = actor - - const req = new Request('https://example.com', { method: 'POST' }) - const res = await accounts_follow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) - - assert(receivedActivity) - assert.equal(receivedActivity.type, 'Follow') - - const row: { - target_actor_acct: string - target_actor_id: string - state: string - } = await db - .prepare(`SELECT target_actor_acct, target_actor_id, state FROM actor_following WHERE actor_id=?`) - .bind(actor.id.toString()) - .first() - assert(row) - assert.equal(row.target_actor_acct, 'actor@' + domain) - assert.equal(row.target_actor_id, `https://${domain}/ap/users/actor`) - assert.equal(row.state, 'pending') - }) + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + await addFollowing(db, actor, actor2, 'sven@' + domain) + await acceptFollowing(db, actor, actor2) + + const req = new Request(`https://${domain}`) + const res = await accounts_following.handleRequest(req, db, 'sven') + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + }) + + test('get remote actor following', async () => { + const db = await makeDB() + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://example.com/users/sven', + }, + ], + }) + ) + } + + if (input.toString() === 'https://example.com/users/sven') { + return new Response( + JSON.stringify({ + id: 'https://example.com/users/sven', + type: 'Person', + following: 'https://example.com/users/sven/following', + }) + ) + } + + if (input.toString() === 'https://example.com/users/sven/following') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/users/sven/following', + type: 'OrderedCollection', + totalItems: 3, + first: 'https://example.com/users/sven/following/1', + }) + ) + } + + if (input.toString() === 'https://example.com/users/sven/following/1') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/users/sven/following/1', + type: 'OrderedCollectionPage', + totalItems: 3, + partOf: 'https://example.com/users/sven/following', + orderedItems: [ + actorA.id.toString(), // local user + 'https://example.com/users/b', // remote user + ], + }) + ) + } + + if (input.toString() === 'https://example.com/users/b') { + return new Response( + JSON.stringify({ + id: 'https://example.com/users/b', + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } - test('unfollow account', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const follower = await createPerson(domain, db, userKEK, 'actor@cloudflare.com') - await addFollowing(db, actor, follower, 'not needed') - - const connectedActor = actor - - const req = new Request('https://' + domain, { method: 'POST' }) - const res = await accounts_unfollow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK) - assert.equal(res.status, 200) - assertCORS(res) - assertJSON(res) - - assert(receivedActivity) - assert.equal(receivedActivity.type, 'Undo') - assert.equal(receivedActivity.object.type, 'Follow') - - const row = await db - .prepare(`SELECT count(*) as count FROM actor_following WHERE actor_id=?`) - .bind(actor.id.toString()) - .first<{ count: number }>() - assert(row) - assert.equal(row.count, 0) + const req = new Request(`https://${domain}`) + const res = await accounts_following.handleRequest(req, db, 'sven@example.com') + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 2) + + assert.equal(data[0].acct, 'a@cloudflare.com') + assert.equal(data[1].acct, 'b@example.com') + }) + + test('get remote actor featured_tags', async () => { + const res = await accounts_featured_tags.onRequest() + assert.equal(res.status, 200) + }) + + test('get remote actor lists', async () => { + const res = await accounts_lists.onRequest() + assert.equal(res.status, 200) + }) + + describe('relationships', () => { + test('relationships missing ids', async () => { + const db = await makeDB() + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const req = new Request('https://mastodon.example/api/v1/accounts/relationships') + const res = await accounts_relationships.handleRequest(req, db, connectedActor) + assert.equal(res.status, 400) + }) + + test('relationships with ids', async () => { + const db = await makeDB() + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first&id[]=second') + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const res = await accounts_relationships.handleRequest(req, db, connectedActor) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const data = await res.json>() + assert.equal(data.length, 2) + assert.equal(data[0].id, 'first') + assert.equal(data[0].following, false) + assert.equal(data[1].id, 'second') + assert.equal(data[1].following, false) + }) + + test('relationships with one id', async () => { + const db = await makeDB() + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first') + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const res = await accounts_relationships.handleRequest(req, db, connectedActor) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].id, 'first') + assert.equal(data[0].following, false) + }) + + test('relationships following', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + await addFollowing(db, actor, actor2, 'sven2@' + domain) + await acceptFollowing(db, actor, actor2) + + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain) + const res = await accounts_relationships.handleRequest(req, db, actor) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].following, true) + }) + + test('relationships following request', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + await addFollowing(db, actor, actor2, 'sven2@' + domain) + + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain) + const res = await accounts_relationships.handleRequest(req, db, actor) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].requested, true) + assert.equal(data[0].following, false) + }) + }) + + test('follow local account', async () => { + const db = await makeDB() + + const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const req = new Request('https://example.com', { method: 'POST' }) + const res = await accounts_follow.handleRequest(req, db, 'localuser', connectedActor, userKEK) + assert.equal(res.status, 403) + }) + + describe('follow', () => { + let receivedActivity: any = null + + beforeEach(() => { + receivedActivity = null + + globalThis.fetch = async (input: RequestInfo) => { + const request = new Request(input) + if ( + request.url === + 'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + '' + ) { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: `https://${domain}/ap/users/actor`, + }, + ], + }) + ) + } + + if (request.url === `https://${domain}/ap/users/actor`) { + return new Response( + JSON.stringify({ + id: `https://${domain}/ap/users/actor`, + type: 'Person', + inbox: `https://${domain}/ap/users/actor/inbox`, + }) + ) + } + + if (request.url === `https://${domain}/ap/users/actor/inbox`) { + assert.equal(request.method, 'POST') + receivedActivity = await request.json() + return new Response('') + } + + throw new Error('unexpected request to ' + request.url) + } + }) + + test('follow account', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const connectedActor = actor + + const req = new Request('https://example.com', { method: 'POST' }) + const res = await accounts_follow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + assert(receivedActivity) + assert.equal(receivedActivity.type, 'Follow') + + const row: { + target_actor_acct: string + target_actor_id: string + state: string + } = await db + .prepare(`SELECT target_actor_acct, target_actor_id, state FROM actor_following WHERE actor_id=?`) + .bind(actor.id.toString()) + .first() + assert(row) + assert.equal(row.target_actor_acct, 'actor@' + domain) + assert.equal(row.target_actor_id, `https://${domain}/ap/users/actor`) + assert.equal(row.state, 'pending') + }) + + test('unfollow account', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const follower = await createPerson(domain, db, userKEK, 'actor@cloudflare.com') + await addFollowing(db, actor, follower, 'not needed') + + const connectedActor = actor + + const req = new Request('https://' + domain, { method: 'POST' }) + const res = await accounts_unfollow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + assert(receivedActivity) + assert.equal(receivedActivity.type, 'Undo') + assert.equal(receivedActivity.object.type, 'Follow') + + const row = await db + .prepare(`SELECT count(*) as count FROM actor_following WHERE actor_id=?`) + .bind(actor.id.toString()) + .first<{ count: number }>() + assert(row) + assert.equal(row.count, 0) + }) + }) + + test('view filters return empty array', async () => { + const res = await filters.onRequest() + assert.equal(res.status, 200) + assertJSON(res) + + const data = await res.json() + assert.equal(data.length, 0) + }) }) }) - - test('view filters return empty array', async () => { - const res = await filters.onRequest() - assert.equal(res.status, 200) - assertJSON(res) - - const data = await res.json() - assert.equal(data.length, 0) - }) }) }) diff --git a/frontend/src/routes/(frontend)/about/index.tsx b/frontend/src/routes/(frontend)/about/index.tsx index fe9164de6..59dd8da14 100644 --- a/frontend/src/routes/(frontend)/about/index.tsx +++ b/frontend/src/routes/(frontend)/about/index.tsx @@ -14,6 +14,8 @@ import { getAdmins } from 'wildebeest/functions/api/wb/settings/server/admins' import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors' import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' import { AccountCard } from '~/components/AccountCard/AccountCard' +import { urlToHandle } from 'wildebeest/backend/src/utils/handle' +import { type Handle } from 'wildebeest/backend/src/utils/parse' type AboutInfo = { image: string @@ -54,7 +56,8 @@ export const aboutInfoLoader = loader$>(async ({ resolveValue if (adminPerson) { try { - adminAccount = (await loadLocalMastodonAccount(database, adminPerson)) as Account + const handle: Handle = urlToHandle(adminPerson) + adminAccount = (await loadLocalMastodonAccount(handle, platform.DOMAIN, database)) as Account } catch { /* empty */ } diff --git a/functions/api/v1/accounts/[id].ts b/functions/api/v1/accounts/[id].ts index 810138053..675bc9490 100644 --- a/functions/api/v1/accounts/[id].ts +++ b/functions/api/v1/accounts/[id].ts @@ -5,6 +5,9 @@ import { cors } from 'wildebeest/backend/src/utils/cors' import type { ContextData } from 'wildebeest/backend/src/types/context' import type { Env } from 'wildebeest/backend/src/types/env' import { getAccount } from 'wildebeest/backend/src/accounts/getAccount' +import { MastodonAccount } from 'wildebeest/backend/src/types/account' +import { isNumeric } from 'wildebeest/backend/src/utils/id' +import { malformedMastodonAccountRequest } from 'wildebeest/backend/src/errors' const headers = { ...cors(), @@ -17,7 +20,11 @@ export const onRequest: PagesFunction = async ({ request, } export async function handleRequest(domain: string, id: string, db: Database): Promise { - const account = await getAccount(domain, id, db) + if (!isNumeric(id)) { + return malformedMastodonAccountRequest(id) + } + + const account: MastodonAccount | null = await getAccount(domain, id, db) if (account) { return new Response(JSON.stringify(account), { headers }) diff --git a/functions/api/v1/accounts/[id]/followers.ts b/functions/api/v1/accounts/[id]/followers.ts index f093537e1..8902a1321 100644 --- a/functions/api/v1/accounts/[id]/followers.ts +++ b/functions/api/v1/accounts/[id]/followers.ts @@ -72,7 +72,10 @@ async function getLocalFollowers(request: Request, handle: Handle, db: Database) try { const actor = await actors.getAndCache(id, db) - out.push(await loadExternalMastodonAccount(acct, actor)) + const mastodonAccount = await loadExternalMastodonAccount(acct, actor) + if (mastodonAccount !== null) { + out.push(mastodonAccount) + } } catch (err: any) { console.warn(`failed to retrieve follower (${id}): ${err.message}`) } diff --git a/functions/api/v1/accounts/[id]/following.ts b/functions/api/v1/accounts/[id]/following.ts index cc2ecaccc..f222c634c 100644 --- a/functions/api/v1/accounts/[id]/following.ts +++ b/functions/api/v1/accounts/[id]/following.ts @@ -72,7 +72,10 @@ async function getLocalFollowing(request: Request, handle: Handle, db: Database) try { const actor = await actors.getAndCache(id, db) - out.push(await loadExternalMastodonAccount(acct, actor)) + const mastodonAccount = await loadExternalMastodonAccount(acct, actor) + if (mastodonAccount !== null) { + out.push(mastodonAccount) + } } catch (err: any) { console.warn(`failed to retrieve following (${id}): ${err.message}`) } diff --git a/functions/api/v1/accounts/lookup.ts b/functions/api/v1/accounts/lookup.ts new file mode 100644 index 000000000..969a317a7 --- /dev/null +++ b/functions/api/v1/accounts/lookup.ts @@ -0,0 +1,38 @@ +// https://docs.joinmastodon.org/methods/accounts/#lookup + +import { type Database, getDatabase } from 'wildebeest/backend/src/database' +import { unprocessableEntity, malformedMastodonAccountLookup } from 'wildebeest/backend/src/errors' +import { cors } from 'wildebeest/backend/src/utils/cors' +import type { ContextData } from 'wildebeest/backend/src/types/context' +import type { Env } from 'wildebeest/backend/src/types/env' +import { getAccount } from 'wildebeest/backend/src/accounts/getAccount' +import { isNumeric } from 'wildebeest/backend/src/utils/id' +import { isHandle } from 'wildebeest/backend/src/utils/handle' + +const headers = { + ...cors(), + 'content-type': 'application/json; charset=utf-8', +} + +export const onRequestGet: PagesFunction = async ({ request, env }) => { + const requestURL: URL = new URL(request.url) + const acct: string | null = requestURL.searchParams?.get('acct') + if (!acct) { + return unprocessableEntity('`acct` is a required parameter') + } + return handleRequest(requestURL.hostname, acct, await getDatabase(env)) +} + +export async function handleRequest(domain: string, acct: string, db: Database): Promise { + if (isNumeric(acct) || !isHandle(acct)) { + return malformedMastodonAccountLookup(acct) + } + + const account = await getAccount(domain, acct, db) + + if (account) { + return new Response(JSON.stringify(account), { headers }) + } else { + return new Response('', { status: 404 }) + } +} diff --git a/functions/api/v1/accounts/update_credentials.ts b/functions/api/v1/accounts/update_credentials.ts index e015c9721..4018fbdcc 100644 --- a/functions/api/v1/accounts/update_credentials.ts +++ b/functions/api/v1/accounts/update_credentials.ts @@ -11,9 +11,11 @@ import * as images from 'wildebeest/backend/src/media/image' import type { Env } from 'wildebeest/backend/src/types/env' import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import { updateActorProperty } from 'wildebeest/backend/src/activitypub/actors' -import type { CredentialAccount } from 'wildebeest/backend/src/types/account' +import type { MastodonAccount, CredentialAccount } from 'wildebeest/backend/src/types/account' import type { ContextData } from 'wildebeest/backend/src/types/context' import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' +import { urlToHandle } from 'wildebeest/backend/src/utils/handle' +import { parseHandle, type Handle } from 'wildebeest/backend/src/utils/parse' const headers = { ...cors(), @@ -51,7 +53,7 @@ export async function handleRequest( return new Response('', { headers, status: 400 }) } - const domain = new URL(request.url).hostname + const localDomain = new URL(request.url).hostname // update actor { @@ -90,13 +92,17 @@ export async function handleRequest( if (actor === null) { return errors.notAuthorized('user not found') } - const user = await loadLocalMastodonAccount(db, actor) + const handle: Handle = parseHandle(urlToHandle(connectedActor.id)) + const mastodonAccount: MastodonAccount | null = await loadLocalMastodonAccount(handle, localDomain, db) + if (mastodonAccount === null) { + return errors.mastodonAccountNotFound(handle.localPart) + } const res: CredentialAccount = { - ...user, + ...mastodonAccount, source: { - note: user.note, - fields: user.fields, + note: mastodonAccount.note!, + fields: mastodonAccount.fields!, privacy: 'public', sensitive: false, language: 'en', @@ -115,7 +121,7 @@ export async function handleRequest( } // send updates - const activity = activities.create(domain, connectedActor, actor) + const activity = activities.create(localDomain, connectedActor, actor) await deliverFollowers(db, userKEK, connectedActor, activity, queue) return new Response(JSON.stringify(res), { headers }) diff --git a/functions/api/v1/accounts/verify_credentials.ts b/functions/api/v1/accounts/verify_credentials.ts index 84d06f784..4052affee 100644 --- a/functions/api/v1/accounts/verify_credentials.ts +++ b/functions/api/v1/accounts/verify_credentials.ts @@ -1,24 +1,36 @@ // https://docs.joinmastodon.org/methods/accounts/#verify_credentials import { cors } from 'wildebeest/backend/src/utils/cors' +import { urlToHandle } from 'wildebeest/backend/src/utils/handle' +import { parseHandle, type Handle } from 'wildebeest/backend/src/utils/parse' import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' import type { Env } from 'wildebeest/backend/src/types/env' import * as errors from 'wildebeest/backend/src/errors' -import type { CredentialAccount } from 'wildebeest/backend/src/types/account' +import type { MastodonAccount, CredentialAccount } from 'wildebeest/backend/src/types/account' import type { ContextData } from 'wildebeest/backend/src/types/context' import { getDatabase } from 'wildebeest/backend/src/database' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' export const onRequest: PagesFunction = async ({ data, env }) => { if (!data.connectedActor) { return errors.notAuthorized('no connected user') } - const user = await loadLocalMastodonAccount(await getDatabase(env), data.connectedActor) + const connectedActor: Actor = data.connectedActor + const handle: Handle = parseHandle(urlToHandle(connectedActor.id)) + const mastodonAccount: MastodonAccount | null = await loadLocalMastodonAccount( + handle, + env.DOMAIN, + await getDatabase(env) + ) + if (mastodonAccount === null) { + return errors.mastodonAccountNotFound(handle.localPart) + } const res: CredentialAccount = { - ...user, + ...mastodonAccount, source: { - note: user.note, - fields: user.fields, + note: mastodonAccount.note!, + fields: mastodonAccount.fields!, privacy: 'public', sensitive: false, language: 'en', diff --git a/functions/api/v1/notifications/[id].ts b/functions/api/v1/notifications/[id].ts index af67df31d..6ba44dbcd 100644 --- a/functions/api/v1/notifications/[id].ts +++ b/functions/api/v1/notifications/[id].ts @@ -8,6 +8,8 @@ import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/acc import type { Person } from 'wildebeest/backend/src/activitypub/actors' import type { Env } from 'wildebeest/backend/src/types/env' import type { ContextData } from 'wildebeest/backend/src/types/context' +import { MastodonAccount } from 'wildebeest/backend/src/types/account' +import * as errors from 'wildebeest/backend/src/errors' const headers = { 'content-type': 'application/json; charset=utf-8', @@ -46,7 +48,10 @@ export async function handleRequest( } const acct = urlToHandle(from_actor_id) - const fromAccount = await loadExternalMastodonAccount(acct, fromActor) + const fromAccount: MastodonAccount | null = await loadExternalMastodonAccount(acct, fromActor) + if (fromAccount === null) { + return errors.mastodonAccountNotFound(acct) + } const out: Notification = { id: row.notif_id.toString(), diff --git a/functions/api/v2/search.ts b/functions/api/v2/search.ts index 622de8c29..863f6a08e 100644 --- a/functions/api/v2/search.ts +++ b/functions/api/v2/search.ts @@ -52,7 +52,10 @@ export async function handleRequest(db: Database, request: Request): Promise = async ({ env }) => { return handleRequestGet(await getDatabase(env)) @@ -13,14 +14,28 @@ 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) + const persons: Person[] = [] + for (const row of rows) { + try { + const person: Person = await personFromRow(row, db) + persons.push(person) + } catch (e) { + console.error(`Error while reviving Person from Actor: ${e}`) + } + } + return persons } diff --git a/migrations/0008_add_server-settings.sql b/migrations/0008_add_server-settings.sql index 5438347b4..2156e93d7 100644 --- a/migrations/0008_add_server-settings.sql +++ b/migrations/0008_add_server-settings.sql @@ -1,4 +1,4 @@ --- Migration number: 0003 2023-02-24T15:03:27.478Z +-- Migration number: 0008 2023-02-24T15:03:27.478Z CREATE TABLE IF NOT EXISTS server_settings ( setting_name TEXT UNIQUE NOT NULL, diff --git a/migrations/0010_update_actor_properties.sql b/migrations/0010_update_actor_properties.sql new file mode 100644 index 000000000..ef8178121 --- /dev/null +++ b/migrations/0010_update_actor_properties.sql @@ -0,0 +1,6 @@ +-- Migration number: 0010 2023-03-03T20:58:08.319Z + +UPDATE actors AS a SET properties = json_insert(a.properties,'$.inbox', a.id || '/inbox'); +UPDATE actors AS a SET properties = json_insert(a.properties,'$.outbox', a.id || '/outbox'); +UPDATE actors AS a SET properties = json_insert(a.properties,'$.following', a.id || '/following'); +UPDATE actors AS a SET properties = json_insert(a.properties,'$.followers', a.id || '/followers');