diff --git a/src/activitypub/routes/channel/#channel_id/inbox.ts b/src/activitypub/routes/channel/#channel_id/inbox.ts
index ee7f4519..2dd36143 100644
--- a/src/activitypub/routes/channel/#channel_id/inbox.ts
+++ b/src/activitypub/routes/channel/#channel_id/inbox.ts
@@ -1,3 +1,4 @@
+import { messageFromAP } from "@spacebar/ap";
import { route } from "@spacebar/api";
import { Message, emitEvent } from "@spacebar/util";
import { Router } from "express";
@@ -11,7 +12,11 @@ router.post("/", route({}), async (req, res) => {
if (body.type != "Create") throw new HTTPError("not implemented");
- const message = await Message.fromAP(body.object);
+ const message = await messageFromAP(body.object);
+
+ if ((await Message.count({ where: { id: message.id } })) != 0)
+ return res.status(200);
+
await message.save();
await emitEvent({
diff --git a/src/activitypub/routes/user/inbox.ts b/src/activitypub/routes/user/inbox.ts
new file mode 100644
index 00000000..2065d7f2
--- /dev/null
+++ b/src/activitypub/routes/user/inbox.ts
@@ -0,0 +1,12 @@
+import { route } from "@spacebar/api";
+import { Router } from "express";
+import { HTTPError } from "lambert-server";
+
+const router = Router();
+export default router;
+
+router.post("/", route({}), async (req, res) => {
+ const body = req.body;
+
+ if (body.type != "Create") throw new HTTPError("not implemented");
+});
diff --git a/src/activitypub/routes/user.ts b/src/activitypub/routes/user/index.ts
index 67664dea..67664dea 100644
--- a/src/activitypub/routes/user.ts
+++ b/src/activitypub/routes/user/index.ts
diff --git a/src/activitypub/util/APError.ts b/src/activitypub/util/APError.ts
new file mode 100644
index 00000000..184ddc32
--- /dev/null
+++ b/src/activitypub/util/APError.ts
@@ -0,0 +1,3 @@
+import { HTTPError } from "lambert-server";
+
+export class APError extends HTTPError {}
diff --git a/src/activitypub/util/index.ts b/src/activitypub/util/index.ts
index 7204e7ae..767e75d9 100644
--- a/src/activitypub/util/index.ts
+++ b/src/activitypub/util/index.ts
@@ -1 +1,3 @@
+export * from "./APError";
export * from "./OrderedCollection";
+export * from "./transforms/index";
diff --git a/src/activitypub/util/transforms/Message.ts b/src/activitypub/util/transforms/Message.ts
new file mode 100644
index 00000000..3669121c
--- /dev/null
+++ b/src/activitypub/util/transforms/Message.ts
@@ -0,0 +1,141 @@
+import { APError } from "@spacebar/ap";
+import { DEFAULT_FETCH_OPTIONS } from "@spacebar/api";
+import {
+ Channel,
+ Config,
+ Member,
+ Message,
+ OrmUtils,
+ Snowflake,
+ User,
+ UserSettings,
+} from "@spacebar/util";
+import { APNote, APPerson, AnyAPObject } from "activitypub-types";
+import fetch from "node-fetch";
+import { ProxyAgent } from "proxy-agent";
+import TurndownService from "turndown";
+
+const fetchOpts = OrmUtils.mergeDeep(DEFAULT_FETCH_OPTIONS, {
+ headers: {
+ Accept: "application/activity+json",
+ },
+});
+
+const hasAPContext = (data: object) => {
+ if (!("@context" in data)) return false;
+ const context = data["@context"];
+ const activitystreams = "https://www.w3.org/ns/activitystreams";
+ if (Array.isArray(context))
+ return context.find((x) => x == activitystreams);
+ return context == activitystreams;
+};
+
+export const resolveAPObject = async <T>(data: string | T): Promise<T> => {
+ // we were already given an AP object
+ if (typeof data != "string") return data;
+
+ const agent = new ProxyAgent();
+ const ret = await fetch(data, {
+ ...fetchOpts,
+ agent,
+ });
+
+ const json = await ret.json();
+
+ if (!hasAPContext(json)) throw new APError("Object is not APObject");
+
+ return json;
+};
+
+export const messageFromAP = async (data: APNote): Promise<Message> => {
+ if (!data.id) throw new APError("Message must have ID");
+ if (data.type != "Note") throw new APError("Message must be Note");
+
+ const to = Array.isArray(data.to)
+ ? data.to.filter((x) =>
+ typeof x == "string" ? x.includes("channel") : false,
+ )[0]
+ : data.to;
+ if (!to || typeof to != "string")
+ throw new APError("Message not deliverable");
+
+ // TODO: use a regex
+ const channel_id = to.split("/").reverse()[0];
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: { guild: true },
+ });
+
+ if (!data.attributedTo)
+ throw new APError("Message must have author (attributedTo)");
+ const attrib = await resolveAPObject(
+ Array.isArray(data.attributedTo)
+ ? data.attributedTo[0] // hmm
+ : data.attributedTo,
+ );
+
+ if (!APObjectIsPerson(attrib))
+ throw new APError("Message attributedTo must be Person");
+
+ const user = await userFromAP(attrib);
+ const member = channel.guild
+ ? await Member.findOneOrFail({
+ where: { id: user.id, guild_id: channel.guild.id },
+ })
+ : undefined;
+
+ return Message.create({
+ id: data.id,
+ content: new TurndownService().turndown(data.content),
+ timestamp: data.published,
+ author: user,
+ guild: channel.guild,
+ member,
+ channel,
+
+ type: 0,
+ sticker_items: [],
+ attachments: [],
+ embeds: [],
+ reactions: [],
+ mentions: [],
+ mention_roles: [],
+ mention_channels: [],
+ });
+};
+
+export const APObjectIsPerson = (object: AnyAPObject): object is APPerson => {
+ return object.type == "Person";
+};
+
+export const userFromAP = async (data: APPerson): Promise<User> => {
+ if (!data.id) throw new APError("User must have ID");
+
+ const url = new URL(data.id);
+ const email = `${url.pathname.split("/").reverse()[0]}@${url.hostname}`;
+
+ return User.create({
+ id: Snowflake.generate(),
+ username: data.preferredUsername,
+ discriminator: url.hostname,
+ bio: new TurndownService().turndown(data.summary),
+ email,
+ data: {
+ hash: "#",
+ valid_tokens_since: new Date(),
+ },
+ extended_settings: "{}",
+ settings: UserSettings.create(),
+ publicKey: "",
+ privateKey: "",
+ premium: false,
+
+ premium_since: Config.get().defaults.user.premium
+ ? new Date()
+ : undefined,
+ rights: Config.get().register.defaultRights,
+ premium_type: Config.get().defaults.user.premiumType ?? 0,
+ verified: Config.get().defaults.user.verified ?? true,
+ created_at: new Date(),
+ });
+};
diff --git a/src/activitypub/util/transforms/index.ts b/src/activitypub/util/transforms/index.ts
new file mode 100644
index 00000000..6596816a
--- /dev/null
+++ b/src/activitypub/util/transforms/index.ts
@@ -0,0 +1 @@
+export * from "./Message";
diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
index 65dfa926..bbbb2ac1 100644
--- a/src/util/entities/Message.ts
+++ b/src/util/entities/Message.ts
@@ -16,13 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-import type {
- APAnnounce,
- APNote,
- APPerson,
- AnyAPObject,
-} from "activitypub-types";
-import fetch from "node-fetch";
+import type { APAnnounce, APNote } from "activitypub-types";
import {
Column,
CreateDateColumn,
@@ -35,7 +29,7 @@ import {
OneToMany,
RelationId,
} from "typeorm";
-import { Config, Snowflake } from "..";
+import { Config } from "..";
import { InteractionType } from "../interfaces/Interaction";
import { Application } from "./Application";
import { Attachment } from "./Attachment";
@@ -262,6 +256,7 @@ export class Message extends BaseClass {
};
}
+ // TODO: move to AP module
toAP(): APNote {
const { webDomain } = Config.get().federation;
@@ -275,70 +270,6 @@ export class Message extends BaseClass {
content: this.content,
};
}
-
- static async fromAP(data: APNote): Promise<Message> {
- if (!data.attributedTo)
- throw new Error("sb Message must have author (attributedTo)");
-
- let attrib = Array.isArray(data.attributedTo)
- ? data.attributedTo[0]
- : data.attributedTo;
- if (typeof attrib == "string") {
- // fetch it
- attrib = (await fetch(attrib, {
- headers: { Accept: "application/activity+json" },
- }).then((x) => x.json())) as AnyAPObject;
- }
-
- if (attrib.type != "Person")
- throw new Error("only Person can be author of sb Message"); //hm
-
- let to = data.to;
-
- if (Array.isArray(to))
- to = to.filter((x) => {
- if (typeof x == "string") return x.includes("channel");
- return false;
- })[0];
-
- if (!to) throw new Error("not deliverable");
-
- const channel_id = (to as string).split("/").reverse()[0];
-
- const channel = await Channel.findOneOrFail({
- where: { id: channel_id },
- relations: { guild: true },
- });
-
- const user = await User.fromAP(attrib as APPerson);
- let member;
- if (
- (await Member.count({
- where: { id: user.id, guild_id: channel.guild_id },
- })) == 0
- )
- member = await Member.addToGuild(user.id, channel.guild.id);
-
- return Message.create({
- id: Snowflake.generate(),
- author: user,
- member,
- content: data.content, // convert html to markdown
- timestamp: data.published,
- channel,
- guild: channel.guild,
-
- sticker_items: [],
- guild_id: channel.guild_id,
- attachments: [],
- embeds: [],
- reactions: [],
- type: 0,
- mentions: [],
- mention_roles: [],
- mention_channels: [],
- });
- }
}
export interface MessageComponent {
diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts
index 25902ba7..f9213693 100644
--- a/src/util/entities/User.ts
+++ b/src/util/entities/User.ts
@@ -37,7 +37,6 @@ import { Session } from "./Session";
import { UserSettings } from "./UserSettings";
import crypto from "crypto";
-import fetch from "node-fetch";
import { promisify } from "util";
const generateKeyPair = promisify(crypto.generateKeyPair);
@@ -293,6 +292,7 @@ export class User extends BaseClass {
return user as UserPrivate;
}
+ // TODO: move to AP module
toAP(): APPersonButMore {
const { webDomain } = Config.get().federation;
@@ -323,49 +323,6 @@ export class User extends BaseClass {
};
}
- static async fromAP(data: APPerson | string): Promise<User> {
- if (typeof data == "string") {
- data = (await fetch(data, {
- headers: { Accept: "application/json" },
- }).then((x) => x.json())) as APPerson;
- }
-
- const cache = await User.findOne({
- where: {
- email: `${data.preferredUsername}@${
- new URL(data.id!).hostname
- }`,
- },
- });
- if (cache) return cache;
-
- return User.create({
- id: Snowflake.generate(), // hm
- username: data.preferredUsername,
- discriminator: new URL(data.id!).hostname,
- premium: false,
- bio: data.summary, // TODO: convert to markdown
-
- email: `${data.preferredUsername}@${new URL(data.id!).hostname}`,
- data: {
- hash: "#",
- valid_tokens_since: new Date(),
- },
- extended_settings: "{}",
- settings: UserSettings.create(),
- publicKey: "",
- privateKey: "",
-
- premium_since: Config.get().defaults.user.premium
- ? new Date()
- : undefined,
- rights: Config.get().register.defaultRights,
- premium_type: Config.get().defaults.user.premiumType ?? 0,
- verified: Config.get().defaults.user.verified ?? true,
- created_at: new Date(),
- }).save();
- }
-
static async getPublicUser(user_id: string, opts?: FindOneOptions<User>) {
return await User.findOneOrFail({
where: { id: user_id },
|