Merge pull request #141 from cloudflare/sven/MOW-87

MOW-87: implement remote followers/following
pull/142/head
Sven Sauleau 2023-01-19 15:04:53 +01:00 zatwierdzone przez GitHub
commit c15f6bec5e
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
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 { 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 = {
accept: 'application/activity+json',
export async function countFollowing(actor: Actor): Promise<number> {
const collection = await getMetadata(actor.following)
return collection.totalItems
}
export async function getFollowingMetadata(actor: Actor): Promise<OrderedCollection<unknown>> {
const res = await fetch(actor.following, { headers })
if (!res.ok) {
throw new Error(`${actor.following} returned ${res.status}`)
}
return res.json<OrderedCollection<unknown>>()
export async function countFollowers(actor: Actor): Promise<number> {
const collection = await getMetadata(actor.followers)
return collection.totalItems
}
export async function getFollowersMetadata(actor: Actor): Promise<OrderedCollection<unknown>> {
const res = await fetch(actor.followers, { headers })
if (!res.ok) {
throw new Error(`${actor.followers} returned ${res.status}`)
}
return res.json<OrderedCollection<unknown>>()
export async function getFollowers(actor: Actor): Promise<OrderedCollection<string>> {
const collection = await getMetadata(actor.followers)
collection.items = await loadItems<string>(collection)
return collection
}
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 { Activity } from 'wildebeest/backend/src/activitypub/activities'
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'
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>> {
const collection = await getMetadata(actor)
const collection = await getMetadata(actor.outbox)
collection.items = await loadItems(collection, 20)
return collection
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
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
export async function countStatuses(actor: Actor): Promise<number> {
const metadata = await getMetadata(actor.outbox)
return metadata.totalItems
}

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
export async function loadExternalMastodonAccount(
acct: string,
res: Actor,
actor: Actor,
loadStats: boolean = false
): Promise<MastodonAccount> {
const account = toMastodonAccount(acct, res)
const account = toMastodonAccount(acct, actor)
if (loadStats === true) {
account.statuses_count = (await apOutbox.getMetadata(res)).totalItems
account.followers_count = (await apFollow.getFollowersMetadata(res)).totalItems
account.following_count = (await apFollow.getFollowingMetadata(res)).totalItems
account.statuses_count = await apOutbox.countStatuses(actor)
account.followers_count = await apFollow.countFollowers(actor)
account.following_count = await apFollow.countFollowing(actor)
}
return account
}

Wyświetl plik

@ -638,10 +638,82 @@ describe('Mastodon APIs', () => {
test('get remote actor followers', async () => {
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 res = await accounts_followers.handleRequest(req, db, 'sven@example.com', connectedActor)
assert.equal(res.status, 403)
const res = await accounts_followers.handleRequest(req, db, 'sven@example.com')
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 () => {
@ -664,9 +736,8 @@ describe('Mastodon APIs', () => {
await addFollowing(db, actor2, actor, 'sven@' + domain)
await acceptFollowing(db, actor2, actor)
const connectedActor = actor
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)
const data = await res.json<Array<any>>()
@ -693,9 +764,8 @@ describe('Mastodon APIs', () => {
await addFollowing(db, actor, actor2, 'sven@' + domain)
await acceptFollowing(db, actor, actor2)
const connectedActor = actor
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)
const data = await res.json<Array<any>>()
@ -704,11 +774,82 @@ describe('Mastodon APIs', () => {
test('get remote actor following', async () => {
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 res = await accounts_following.handleRequest(req, db, 'sven@example.com', connectedActor)
assert.equal(res.status, 403)
const res = await accounts_following.handleRequest(req, db, 'sven@example.com')
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 () => {

Wyświetl plik

@ -1,36 +1,70 @@
// 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 { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
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 type { Person } from 'wildebeest/backend/src/activitypub/actors'
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 { 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 }) => {
return handleRequest(request, env.DATABASE, params.id as string, data.connectedActor)
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ params, request, env }) => {
return handleRequest(request, env.DATABASE, params.id as string)
}
export async function handleRequest(
request: Request,
db: D1Database,
id: string,
connectedActor: Person
): Promise<Response> {
export async function handleRequest(request: Request, db: D1Database, id: string): Promise<Response> {
const handle = parseHandle(id)
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 followers = await getFollowers(db, connectedActor)
for (let i = 0, len = followers.length; i < len; i++) {
const id = new URL(followers[i])

Wyświetl plik

@ -1,36 +1,70 @@
// 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 { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
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 type { Person } from 'wildebeest/backend/src/activitypub/actors'
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 { 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 }) => {
return handleRequest(request, env.DATABASE, params.id as string, data.connectedActor)
export const onRequest: PagesFunction<Env, any, ContextData> = async ({ params, request, env }) => {
return handleRequest(request, env.DATABASE, params.id as string)
}
export async function handleRequest(
request: Request,
db: D1Database,
id: string,
connectedActor: Person
): Promise<Response> {
export async function handleRequest(request: Request, db: D1Database, id: string): Promise<Response> {
const handle = parseHandle(id)
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 following = await getFollowingId(db, connectedActor)
for (let i = 0, len = following.length; i < len; i++) {
const id = new URL(following[i])