summary refs log tree commit diff
diff options
context:
space:
mode:
authorMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2023-09-30 01:33:52 +0000
committerMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2023-09-30 01:33:52 +0000
commit34867e2154467f37b137fafe833d46db63746f93 (patch)
treec113dff7ccf22e606b0537616e14dd0a8298e5ae
parentAccept Follow for federated guild joins. TODO: Channels and role federation (diff)
downloadserver-34867e2154467f37b137fafe833d46db63746f93.tar.xz
basic channel federation
-rw-r--r--src/activitypub/federation/OrderedCollection.ts3
-rw-r--r--src/activitypub/federation/inbox/index.ts27
-rw-r--r--src/activitypub/federation/transforms.ts60
-rw-r--r--src/activitypub/federation/utils.ts26
-rw-r--r--src/activitypub/routes/guilds/#guild_id/following.ts35
-rw-r--r--src/util/entities/Channel.ts5
6 files changed, 150 insertions, 6 deletions
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 <T extends AnyAPObject>(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<string, (activity: AnyAPObject) => Promise<unknown>>;
 
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<APGroup[]> => {
+			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({