MOW-87: implement remote followers/following

pull/141/head
Sven Sauleau 2023-01-19 13:56:56 +00:00
rodzic a9d2b676fb
commit 4a0d413c00
8 zmienionych plików z 330 dodań i 104 usunięć

Wyświetl plik

@ -1,24 +1,35 @@
import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/core' import * as actors from 'wildebeest/backend/src/activitypub/actors'
import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/objects/collection'
import { getMetadata, loadItems } from 'wildebeest/backend/src/activitypub/objects/collection'
const headers = { export async function countFollowing(actor: Actor): Promise<number> {
accept: 'application/activity+json', const collection = await getMetadata(actor.following)
return collection.totalItems
} }
export async function getFollowingMetadata(actor: Actor): Promise<OrderedCollection<unknown>> { export async function countFollowers(actor: Actor): Promise<number> {
const res = await fetch(actor.following, { headers }) const collection = await getMetadata(actor.followers)
if (!res.ok) { return collection.totalItems
throw new Error(`${actor.following} returned ${res.status}`)
}
return res.json<OrderedCollection<unknown>>()
} }
export async function getFollowersMetadata(actor: Actor): Promise<OrderedCollection<unknown>> { export async function getFollowers(actor: Actor): Promise<OrderedCollection<string>> {
const res = await fetch(actor.followers, { headers }) const collection = await getMetadata(actor.followers)
if (!res.ok) { collection.items = await loadItems<string>(collection)
throw new Error(`${actor.followers} returned ${res.status}`) return collection
} }
return res.json<OrderedCollection<unknown>>() export async function getFollowing(actor: Actor): Promise<OrderedCollection<string>> {
const collection = await getMetadata(actor.following)
collection.items = await loadItems<string>(collection)
return collection
}
export async function loadActors(db: D1Database, collection: OrderedCollection<string>): Promise<Array<Actor>> {
const promises = collection.items.map((item) => {
const actorId = new URL(item)
return actors.getAndCache(actorId, db)
})
return Promise.all(promises)
} }

Wyświetl plik

@ -1,7 +1,8 @@
import type { Object } from 'wildebeest/backend/src/activitypub/objects' import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { Activity } from 'wildebeest/backend/src/activitypub/activities' import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { OrderedCollection, OrderedCollectionPage } from 'wildebeest/backend/src/activitypub/core' import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/objects/collection'
import { getMetadata, loadItems } from 'wildebeest/backend/src/activitypub/objects/collection'
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities' import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
export async function addObjectInOutbox( export async function addObjectInOutbox(
@ -30,35 +31,14 @@ export async function addObjectInOutbox(
} }
} }
const headers = {
accept: 'application/activity+json',
}
export async function getMetadata(actor: Actor): Promise<OrderedCollection<unknown>> {
const res = await fetch(actor.outbox, { headers })
if (!res.ok) {
throw new Error(`${actor.outbox} returned ${res.status}`)
}
return res.json<OrderedCollection<unknown>>()
}
export async function get(actor: Actor): Promise<OrderedCollection<Activity>> { export async function get(actor: Actor): Promise<OrderedCollection<Activity>> {
const collection = await getMetadata(actor) const collection = await getMetadata(actor.outbox)
collection.items = await loadItems(collection, 20) collection.items = await loadItems(collection, 20)
return collection return collection
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars export async function countStatuses(actor: Actor): Promise<number> {
async function loadItems<T>(collection: OrderedCollection<T>, max: number): Promise<Array<T>> { const metadata = await getMetadata(actor.outbox)
// FIXME: implement max and multi page support return metadata.totalItems
const res = await fetch(collection.first, { headers })
if (!res.ok) {
throw new Error(`${collection.first} returned ${res.status}`)
}
const data = await res.json<OrderedCollectionPage<T>>()
return data.orderedItems
} }

Wyświetl plik

@ -1,15 +0,0 @@
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
export interface Collection<T> extends Object {
totalItems: number
current?: string
first: URL
last: URL
items: Array<T>
}
export interface OrderedCollection<T> extends Collection<T> {}
export interface OrderedCollectionPage<T> extends Object {
orderedItems: Array<T>
}

Wyświetl plik

@ -0,0 +1,41 @@
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
export interface Collection<T> extends Object {
totalItems: number
current?: string
first: URL
last: URL
items: Array<T>
}
export interface OrderedCollection<T> extends Collection<T> {}
export interface OrderedCollectionPage<T> extends Object {
orderedItems: Array<T>
}
const headers = {
accept: 'application/activity+json',
}
export async function getMetadata(url: URL): Promise<OrderedCollection<any>> {
const res = await fetch(url, { headers })
if (!res.ok) {
throw new Error(`${url} returned ${res.status}`)
}
return res.json<OrderedCollection<any>>()
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function loadItems<T>(collection: OrderedCollection<T>, max?: number): Promise<Array<T>> {
// FIXME: implement max and multi page support
const res = await fetch(collection.first, { headers })
if (!res.ok) {
throw new Error(`${collection.first} returned ${res.status}`)
}
const data = await res.json<OrderedCollectionPage<T>>()
return data.orderedItems
}

Wyświetl plik

@ -42,14 +42,14 @@ function toMastodonAccount(acct: string, res: Actor): MastodonAccount {
// Load an external user, using ActivityPub queries, and return it as a MastodonAccount // Load an external user, using ActivityPub queries, and return it as a MastodonAccount
export async function loadExternalMastodonAccount( export async function loadExternalMastodonAccount(
acct: string, acct: string,
res: Actor, actor: Actor,
loadStats: boolean = false loadStats: boolean = false
): Promise<MastodonAccount> { ): Promise<MastodonAccount> {
const account = toMastodonAccount(acct, res) const account = toMastodonAccount(acct, actor)
if (loadStats === true) { if (loadStats === true) {
account.statuses_count = (await apOutbox.getMetadata(res)).totalItems account.statuses_count = await apOutbox.countStatuses(actor)
account.followers_count = (await apFollow.getFollowersMetadata(res)).totalItems account.followers_count = await apFollow.countFollowers(actor)
account.following_count = (await apFollow.getFollowingMetadata(res)).totalItems account.following_count = await apFollow.countFollowing(actor)
} }
return account return account
} }

Wyświetl plik

@ -638,10 +638,82 @@ describe('Mastodon APIs', () => {
test('get remote actor followers', async () => { test('get remote actor followers', async () => {
const db = await makeDB() const db = await makeDB()
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
globalThis.fetch = async (input: any) => {
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 req = new Request(`https://${domain}`)
const res = await accounts_followers.handleRequest(req, db, 'sven@example.com', connectedActor) const res = await accounts_followers.handleRequest(req, db, 'sven@example.com')
assert.equal(res.status, 403) assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
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 () => { test('get local actor followers', async () => {
@ -664,9 +736,8 @@ describe('Mastodon APIs', () => {
await addFollowing(db, actor2, actor, 'sven@' + domain) await addFollowing(db, actor2, actor, 'sven@' + domain)
await acceptFollowing(db, actor2, actor) await acceptFollowing(db, actor2, actor)
const connectedActor = actor
const req = new Request(`https://${domain}`) const req = new Request(`https://${domain}`)
const res = await accounts_followers.handleRequest(req, db, 'sven', connectedActor) const res = await accounts_followers.handleRequest(req, db, 'sven')
assert.equal(res.status, 200) assert.equal(res.status, 200)
const data = await res.json<Array<any>>() const data = await res.json<Array<any>>()
@ -693,9 +764,8 @@ describe('Mastodon APIs', () => {
await addFollowing(db, actor, actor2, 'sven@' + domain) await addFollowing(db, actor, actor2, 'sven@' + domain)
await acceptFollowing(db, actor, actor2) await acceptFollowing(db, actor, actor2)
const connectedActor = actor
const req = new Request(`https://${domain}`) const req = new Request(`https://${domain}`)
const res = await accounts_following.handleRequest(req, db, 'sven', connectedActor) const res = await accounts_following.handleRequest(req, db, 'sven')
assert.equal(res.status, 200) assert.equal(res.status, 200)
const data = await res.json<Array<any>>() const data = await res.json<Array<any>>()
@ -704,11 +774,82 @@ describe('Mastodon APIs', () => {
test('get remote actor following', async () => { test('get remote actor following', async () => {
const db = await makeDB() const db = await makeDB()
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
globalThis.fetch = async (input: any) => {
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)
}
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const req = new Request(`https://${domain}`) const req = new Request(`https://${domain}`)
const res = await accounts_following.handleRequest(req, db, 'sven@example.com', connectedActor) const res = await accounts_following.handleRequest(req, db, 'sven@example.com')
assert.equal(res.status, 403) assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
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 () => { test('get remote actor featured_tags', async () => {

Wyświetl plik

@ -1,36 +1,70 @@
// https://docs.joinmastodon.org/methods/accounts/#followers // https://docs.joinmastodon.org/methods/accounts/#followers
import type { Handle } from 'wildebeest/backend/src/utils/parse'
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
import { cors } from 'wildebeest/backend/src/utils/cors' import { cors } from 'wildebeest/backend/src/utils/cors'
import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
import { parseHandle } from 'wildebeest/backend/src/utils/parse' import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import * as actors from 'wildebeest/backend/src/activitypub/actors' import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
import { MastodonAccount } from 'wildebeest/backend/src/types/account' import { MastodonAccount } from 'wildebeest/backend/src/types/account'
import type { Person } from 'wildebeest/backend/src/activitypub/actors'
import type { ContextData } from 'wildebeest/backend/src/types/context' import type { ContextData } from 'wildebeest/backend/src/types/context'
import { getFollowers } from 'wildebeest/backend/src/mastodon/follow'
import type { Env } from 'wildebeest/backend/src/types/env' import type { Env } from 'wildebeest/backend/src/types/env'
import { domainNotAuthorized } from 'wildebeest/backend/src/errors' import * as actors from 'wildebeest/backend/src/activitypub/actors'
import * as webfinger from 'wildebeest/backend/src/webfinger'
import { getFollowers, loadActors } from 'wildebeest/backend/src/activitypub/actors/follow'
import * as localFollow from 'wildebeest/backend/src/mastodon/follow'
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ params, request, env, data }) => { export const onRequest: PagesFunction<Env, any, ContextData> = async ({ params, request, env }) => {
return handleRequest(request, env.DATABASE, params.id as string, data.connectedActor) return handleRequest(request, env.DATABASE, params.id as string)
} }
export async function handleRequest( export async function handleRequest(request: Request, db: D1Database, id: string): Promise<Response> {
request: Request,
db: D1Database,
id: string,
connectedActor: Person
): Promise<Response> {
const handle = parseHandle(id) const handle = parseHandle(id)
const domain = new URL(request.url).hostname const domain = new URL(request.url).hostname
if (handle.domain !== null && handle.domain !== domain) {
return domainNotAuthorized() if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
// Retrieve the infos from a local user
return getLocalFollowers(request, handle, db)
} else if (handle.domain !== null) {
// Retrieve the infos of a remote actor
return getRemoteFollowers(request, handle, db)
} else {
return new Response('', { status: 403 })
}
}
async function getRemoteFollowers(request: Request, handle: Handle, db: D1Database): Promise<Response> {
const acct = `${handle.localPart}@${handle.domain}`
const link = await webfinger.queryAcctLink(handle.domain!, acct)
if (link === null) {
return new Response('', { status: 404 })
} }
const actor = await actors.getAndCache(link, db)
const followersIds = await getFollowers(actor)
const followers = await loadActors(db, followersIds)
const promises = followers.map((actor) => {
const acct = urlToHandle(actor.id)
return loadExternalMastodonAccount(acct, actor, false)
})
const out = await Promise.all(promises)
const headers = {
...cors(),
'content-type': 'application/json; charset=utf-8',
}
return new Response(JSON.stringify(out), { headers })
}
async function getLocalFollowers(request: Request, handle: Handle, db: D1Database): Promise<Response> {
const domain = new URL(request.url).hostname
const actorId = actorURL(domain, handle.localPart)
const actor = await actors.getAndCache(actorId, db)
const followers = await localFollow.getFollowers(db, actor)
const out: Array<MastodonAccount> = [] const out: Array<MastodonAccount> = []
const followers = await getFollowers(db, connectedActor)
for (let i = 0, len = followers.length; i < len; i++) { for (let i = 0, len = followers.length; i < len; i++) {
const id = new URL(followers[i]) const id = new URL(followers[i])

Wyświetl plik

@ -1,36 +1,70 @@
// https://docs.joinmastodon.org/methods/accounts/#following // https://docs.joinmastodon.org/methods/accounts/#following
import type { Handle } from 'wildebeest/backend/src/utils/parse'
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
import { cors } from 'wildebeest/backend/src/utils/cors' import { cors } from 'wildebeest/backend/src/utils/cors'
import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { parseHandle } from 'wildebeest/backend/src/utils/parse' import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import { urlToHandle } from 'wildebeest/backend/src/utils/handle' import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import { MastodonAccount } from 'wildebeest/backend/src/types/account' import { MastodonAccount } from 'wildebeest/backend/src/types/account'
import type { Person } from 'wildebeest/backend/src/activitypub/actors'
import type { ContextData } from 'wildebeest/backend/src/types/context' import type { ContextData } from 'wildebeest/backend/src/types/context'
import { getFollowingId } from 'wildebeest/backend/src/mastodon/follow' import * as localFollow from 'wildebeest/backend/src/mastodon/follow'
import type { Env } from 'wildebeest/backend/src/types/env' import type { Env } from 'wildebeest/backend/src/types/env'
import { domainNotAuthorized } from 'wildebeest/backend/src/errors' import * as actors from 'wildebeest/backend/src/activitypub/actors'
import * as webfinger from 'wildebeest/backend/src/webfinger'
import { getFollowing, loadActors } from 'wildebeest/backend/src/activitypub/actors/follow'
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ params, request, env, data }) => { export const onRequest: PagesFunction<Env, any, ContextData> = async ({ params, request, env }) => {
return handleRequest(request, env.DATABASE, params.id as string, data.connectedActor) return handleRequest(request, env.DATABASE, params.id as string)
} }
export async function handleRequest( export async function handleRequest(request: Request, db: D1Database, id: string): Promise<Response> {
request: Request,
db: D1Database,
id: string,
connectedActor: Person
): Promise<Response> {
const handle = parseHandle(id) const handle = parseHandle(id)
const domain = new URL(request.url).hostname const domain = new URL(request.url).hostname
if (handle.domain !== null && handle.domain !== domain) {
return domainNotAuthorized() if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
// Retrieve the infos from a local user
return getLocalFollowing(request, handle, db)
} else if (handle.domain !== null) {
// Retrieve the infos of a remote actor
return getRemoteFollowing(request, handle, db)
} else {
return new Response('', { status: 403 })
}
}
async function getRemoteFollowing(request: Request, handle: Handle, db: D1Database): Promise<Response> {
const acct = `${handle.localPart}@${handle.domain}`
const link = await webfinger.queryAcctLink(handle.domain!, acct)
if (link === null) {
return new Response('', { status: 404 })
} }
const actor = await actors.getAndCache(link, db)
const followingIds = await getFollowing(actor)
const following = await loadActors(db, followingIds)
const promises = following.map((actor) => {
const acct = urlToHandle(actor.id)
return loadExternalMastodonAccount(acct, actor, false)
})
const out = await Promise.all(promises)
const headers = {
...cors(),
'content-type': 'application/json; charset=utf-8',
}
return new Response(JSON.stringify(out), { headers })
}
async function getLocalFollowing(request: Request, handle: Handle, db: D1Database): Promise<Response> {
const domain = new URL(request.url).hostname
const actorId = actorURL(domain, handle.localPart)
const actor = await actors.getAndCache(actorId, db)
const following = await localFollow.getFollowingId(db, actor)
const out: Array<MastodonAccount> = [] const out: Array<MastodonAccount> = []
const following = await getFollowingId(db, connectedActor)
for (let i = 0, len = following.length; i < len; i++) { for (let i = 0, len = following.length; i < len; i++) {
const id = new URL(following[i]) const id = new URL(following[i])