diff --git a/src/activitypub/federation/OrderedCollection.ts b/src/activitypub/federation/OrderedCollection.ts index d530deb8..f3b8feea 100644 --- a/src/activitypub/federation/OrderedCollection.ts +++ b/src/activitypub/federation/OrderedCollection.ts @@ -26,6 +26,9 @@ export const makeOrderedCollection = async (opts: { const elems = await getElements(before, after); + // TODO: we need to specify next/prev props + // and should probably let the caller of this function specify what they are + // along with first/last return { "@context": ACTIVITYSTREAMS_CONTEXT, id: `${id}?page=true`, diff --git a/src/activitypub/federation/inbox/index.ts b/src/activitypub/federation/inbox/index.ts index 3146f60a..079c13de 100644 --- a/src/activitypub/federation/inbox/index.ts +++ b/src/activitypub/federation/inbox/index.ts @@ -15,15 +15,20 @@ import { ActivityIsFollow, AnyAPObject, ObjectIsNote, + ObjectIsOrganization, } from "activitypub-types"; import { Request } from "express"; import { HttpSig } from "../HttpSig"; import { federationQueue } from "../queue"; -import { transformNoteToMessage } from "../transforms"; +import { + transformNoteToMessage, + transformOrganisationToGuild, +} from "../transforms"; import { APFollowWithInvite } from "../types"; import { ACTIVITYSTREAMS_CONTEXT, APError, + createChannelsFromGuildFollows, fetchFederatedUser, hasAPContext, resolveAPObject, @@ -106,13 +111,29 @@ const handlers = { if (typeof inner.object != "string") throw new APError("not implemented"); - const guild = await fetchFederatedUser(inner.object); + const apGuild = await resolveAPObject(inner.object); + if (!ObjectIsOrganization(apGuild)) + throw new APError( + "Accept Follow received for object other than Organisation ( Guild ), Ignoring", + ); + + if (!apGuild.following || typeof apGuild.following != "string") + throw new APError("Guild must be following channels"); + + const guild = await transformOrganisationToGuild(apGuild); + + // create the channels + + await createChannelsFromGuildFollows( + apGuild.following + "?page=true", // TODO: wrong + guild.id, + ); if (typeof inner.actor != "string") throw new APError("not implemented"); const { user } = splitQualifiedMention(inner.actor); - Member.addToGuild(user, guild.entity.id); + Member.addToGuild(user, guild.id); }, } as Record Promise>; diff --git a/src/activitypub/federation/transforms.ts b/src/activitypub/federation/transforms.ts index 9d4b11d8..a11cc62b 100644 --- a/src/activitypub/federation/transforms.ts +++ b/src/activitypub/federation/transforms.ts @@ -1,6 +1,7 @@ import { ActorType, Channel, + ChannelType, Config, DmChannelDTO, FederationKey, @@ -8,6 +9,7 @@ import { Invite, Member, Message, + Role, Snowflake, User, UserSettings, @@ -343,7 +345,25 @@ export const transformOrganisationToGuild = async (org: APOrganization) => { owner_id: owner.entity.id, }); + const role = Role.create({ + id: guild.id, + guild_id: guild.id, + color: 0, + hoist: false, + managed: false, + // NB: in Spacebar, every role will be non-managed, as we use user-groups instead of roles for managed groups + mentionable: false, + name: "@everyone", + permissions: String("2251804225"), + position: 0, + icon: undefined, + unicode_emoji: undefined, + flags: 0, // TODO? + }); + await Promise.all([guild.save(), keys.save()]); + await role.save(); + return guild; }; @@ -372,6 +392,7 @@ export const transformGuildToOrganisation = async ( inbox: `https://${host}/federation/guilds/${guild.id}/inbox`, outbox: `https://${host}/federation/guilds/${guild.id}/outbox`, followers: `https://${host}/federation/guilds/${guild.id}/followers`, + following: `https://${host}/federation/guilds/${guild.id}/following`, publicKey: { id: `https://${host}/federation/guilds/${guild.id}#main-key`, owner: `https://${host}/federation/guilds/${guild.id}`, @@ -379,3 +400,42 @@ export const transformGuildToOrganisation = async ( }, }; }; + +export const transformGroupToChannel = async ( + group: APGroup, + guild_id: string, +) => { + if (!group.id) throw new APError("Channel ( group ) must have ID"); + if (!group.publicKey || !group.publicKey.publicKeyPem) + throw new APError("Federated guild must have public key."); + + const cache = await FederationKey.findOne({ + where: { federatedId: group.id }, + }); + if (cache) return Channel.findOneOrFail({ where: { id: cache.actorId } }); + + const keys = FederationKey.create({ + actorId: Snowflake.generate(), + federatedId: group.id, + username: group.name, + domain: new URL(group.id).hostname, + publicKey: group.publicKey.publicKeyPem, + type: ActorType.CHANNEL, + inbox: group.inbox.toString(), + outbox: group.outbox.toString(), + }); + + const channel = Channel.create({ + id: keys.actorId, + name: group.name, + type: ChannelType.GUILD_TEXT, // TODO + owner_id: undefined, + last_message_id: undefined, + position: 0, // TODO + guild_id, + }); + + await Promise.all([keys.save(), channel.save()]); + + return channel; +}; diff --git a/src/activitypub/federation/utils.ts b/src/activitypub/federation/utils.ts index 20b37022..e879e863 100644 --- a/src/activitypub/federation/utils.ts +++ b/src/activitypub/federation/utils.ts @@ -2,6 +2,7 @@ import { DEFAULT_FETCH_OPTIONS } from "@spacebar/api"; import { ActorType, BaseClass, + ChannelCreateEvent, Config, Debug, FederationActivity, @@ -13,9 +14,11 @@ import { User, UserSettings, WebfingerResponse, + emitEvent, } from "@spacebar/util"; import { APObject, + APOrderedCollection, APPerson, AnyAPObject, ObjectIsGroup, @@ -27,6 +30,7 @@ import fetch from "node-fetch"; import { ProxyAgent } from "proxy-agent"; import TurndownService from "turndown"; import { federationQueue } from "./queue"; +import { transformGroupToChannel } from "./transforms"; import { APFollowWithInvite } from "./types"; export const ACTIVITYSTREAMS_CONTEXT = "https://www.w3.org/ns/activitystreams"; @@ -256,6 +260,28 @@ export const tryFederatedGuildJoin = async (code: string, user_id: string) => { await federationQueue.distribute(follow.toJSON()); }; +export const createChannelsFromGuildFollows = async ( + endpoint: string, + guild_id: string, +) => { + const collection = (await resolveAPObject(endpoint)) as APOrderedCollection; // TODO: validation + if (!collection.orderedItems) + throw new APError("Guild followers did not contain orderedItems"); + + // resolve every channel + for (const channel of collection.orderedItems) { + if (typeof channel == "string" || !ObjectIsGroup(channel)) continue; + + const guildchannel = await transformGroupToChannel(channel, guild_id); + + await emitEvent({ + event: "CHANNEL_CREATE", + data: guildchannel, + guild_id: guildchannel.guild_id, + } as ChannelCreateEvent); + } +}; + export const APObjectIsSpacebarActor = ( object: AnyAPObject, ): object is APPerson => { diff --git a/src/activitypub/routes/guilds/#guild_id/following.ts b/src/activitypub/routes/guilds/#guild_id/following.ts new file mode 100644 index 00000000..c4918572 --- /dev/null +++ b/src/activitypub/routes/guilds/#guild_id/following.ts @@ -0,0 +1,35 @@ +import { makeOrderedCollection, transformChannelToGroup } from "@spacebar/ap"; +import { route } from "@spacebar/api"; +import { Channel, Config } from "@spacebar/util"; +import { APGroup } from "activitypub-types"; +import { Request, Response, Router } from "express"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + const { page, min_id, max_id } = req.query; + + const { host } = Config.get().federation; + + const ret = await makeOrderedCollection({ + page: page != undefined, + min_id: min_id?.toString(), + max_id: max_id?.toString(), + id: `https://${host}/federation/guilds/${guild_id}/followers`, + getTotalElements: () => Channel.count({ where: { guild_id } }), + getElements: async (before, after): Promise => { + const channels = await Channel.find({ + where: { guild_id }, + order: { position: "ASC" }, + }); + + // TODO: actual pagination + + return Promise.all(channels.map((x) => transformChannelToGroup(x))); + }, + }); + + return res.json(ret); +}); + +export default router; diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts index 63e320a8..bb4e6e74 100644 --- a/src/util/entities/Channel.ts +++ b/src/util/entities/Channel.ts @@ -19,6 +19,7 @@ import { HTTPError } from "lambert-server"; import { Column, + CreateDateColumn, Entity, JoinColumn, ManyToOne, @@ -73,7 +74,7 @@ export enum ChannelType { @Entity("channels") export class Channel extends BaseClass { - @Column() + @CreateDateColumn() created_at: Date; @Column({ nullable: true }) @@ -301,7 +302,6 @@ export class Channel extends BaseClass { channel = { ...channel, ...(!opts?.keepId && { id: Snowflake.generate() }), - created_at: new Date(), position: (channel.type === ChannelType.UNHANDLED ? 0 @@ -381,7 +381,6 @@ export class Channel extends BaseClass { name, type, owner_id: undefined, - created_at: new Date(), last_message_id: undefined, recipients: channelRecipients.map((x) => Recipient.create({