From 1a1bee83a74f906c24051c65a2a977c4be499ede Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Mon, 18 Sep 2023 10:29:56 -1000 Subject: [PATCH] Add more utilities, implement Accept and Reject objects --- database/entities/RawActivity.ts | 21 ++-- database/entities/RawActor.ts | 35 ++++++ database/entities/User.ts | 109 +++++++++++++++++- server/api/[username]/actor.json/index.ts | 2 +- server/api/[username]/inbox/index.ts | 69 +++++++++-- .../v1/accounts/update_credentials/index.ts | 2 +- tests/inbox.test.ts | 2 +- tests/oauth.test.ts | 2 +- utils/config.ts | 13 +++ 9 files changed, 230 insertions(+), 25 deletions(-) diff --git a/database/entities/RawActivity.ts b/database/entities/RawActivity.ts index 844f5c41..498b3c43 100644 --- a/database/entities/RawActivity.ts +++ b/database/entities/RawActivity.ts @@ -10,6 +10,7 @@ import { APActivity, APActor, APObject, APTombstone } from "activitypub-types"; import { RawObject } from "./RawObject"; import { RawActor } from "./RawActor"; import { getConfig } from "@config"; +import { errorResponse } from "@response"; /** * Stores an ActivityPub activity as raw JSON-LD data @@ -81,19 +82,19 @@ export class RawActivity extends BaseEntity { // Check if object body contains any filtered terms if (await rawObject.isObjectFiltered()) - return new Error("Object filtered"); + return errorResponse("Object filtered", 409); await rawObject.save(); return rawObject; } else { - return new Error("Object does not exist"); + return errorResponse("Object does not exist", 404); } } static async deleteObjectIfExists(object: APObject) { const dbObject = await RawObject.getById(object.id ?? ""); - if (!dbObject) return new Error("Object not found"); + if (!dbObject) return errorResponse("Object does not exist", 404); const config = getConfig(); @@ -139,7 +140,7 @@ export class RawActivity extends BaseEntity { activity.actor as APActor ); - if (actor instanceof Error) { + if (actor instanceof Response) { return actor; } @@ -147,14 +148,14 @@ export class RawActivity extends BaseEntity { activity.object as APObject ); - if (object instanceof Error) { + if (object instanceof Response) { return object; } await rawActivity.save(); return rawActivity; } else { - return new Error("Activity already exists"); + return errorResponse("Activity already exists", 409); } } @@ -165,7 +166,7 @@ export class RawActivity extends BaseEntity { // Check if object body contains any filtered terms if (await rawObject.isObjectFiltered()) - return new Error("Object filtered"); + return errorResponse("Object filtered", 409); await rawObject.save(); @@ -173,7 +174,7 @@ export class RawActivity extends BaseEntity { return rawObject; } else { - return new Error("Object already exists"); + return errorResponse("Object already exists", 409); } } @@ -201,7 +202,7 @@ export class RawActivity extends BaseEntity { } if (await rawActor.isObjectFiltered()) { - return new Error("Actor filtered"); + return errorResponse("Actor filtered", 409); } await rawActor.save(); @@ -210,7 +211,7 @@ export class RawActivity extends BaseEntity { return rawActor; } else { - return new Error("Actor already exists"); + return errorResponse("Actor already exists", 409); } } } diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index 6ba3a53f..85793ff2 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -2,6 +2,7 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { APActor } from "activitypub-types"; import { getConfig } from "@config"; import { appendFile } from "fs/promises"; +import { errorResponse } from "@response"; /** * Stores an ActivityPub actor as raw JSON-LD data @@ -24,6 +25,40 @@ export class RawActor extends BaseEntity { .getOne(); } + static async addIfNotExists(data: APActor) { + if (!(await RawActor.exists(data.id ?? ""))) { + const actor = new RawActor(); + actor.data = data; + + const config = getConfig(); + + if ( + config.activitypub.discard_avatars.find(instance => + actor.id.includes(instance) + ) + ) { + actor.data.icon = undefined; + } + + if ( + config.activitypub.discard_banners.find(instance => + actor.id.includes(instance) + ) + ) { + actor.data.image = undefined; + } + + if (await actor.isObjectFiltered()) { + return errorResponse("Actor filtered", 409); + } + + await actor.save(); + + return actor; + } + return errorResponse("Actor already exists", 409); + } + async isObjectFiltered() { const config = getConfig(); diff --git a/database/entities/User.ts b/database/entities/User.ts index 5e76928b..dfbb8e94 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -4,10 +4,15 @@ import { Column, CreateDateColumn, Entity, + JoinTable, + ManyToMany, + ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; import { APIAccount } from "~types/entities/account"; +import { RawActor } from "./RawActor"; +import { APActor } from "activitypub-types"; const config = getConfig(); @@ -42,7 +47,18 @@ export class User extends BaseEntity { @Column("varchar", { default: "", }) - bio!: string; + note!: string; + + @Column("boolean", { + default: false, + }) + is_admin!: boolean; + + @Column("varchar") + avatar!: string; + + @Column("varchar") + header!: string; @CreateDateColumn() created_at!: Date; @@ -56,6 +72,95 @@ export class User extends BaseEntity { @Column("varchar") private_key!: string; + @ManyToOne(() => RawActor, actor => actor.id) + actor!: RawActor; + + @ManyToMany(() => RawActor, actor => actor.id) + @JoinTable() + following!: RawActor[]; + + static async getByActorId(id: string) { + return await User.createQueryBuilder("user") + // Objects is a many-to-many relationship + .leftJoinAndSelect("user.actor", "actor") + .leftJoinAndSelect("user.following", "following") + .where("actor.data @> :data", { + data: JSON.stringify({ + id, + }), + }) + .getOne(); + } + + static async createNew(data: { + username: string; + display_name?: string; + password: string; + email: string; + bio?: string; + avatar?: string; + header?: string; + }) { + const config = getConfig(); + const user = new User(); + + user.username = data.username; + user.display_name = data.display_name ?? data.username; + user.password = await Bun.password.hash(data.password); + user.email = data.email; + user.note = data.bio ?? ""; + user.avatar = data.avatar ?? config.defaults.avatar; + user.header = data.header ?? config.defaults.avatar; + + await user.generateKeys(); + await user.updateActor(); + + await user.save(); + return user; + } + + async updateActor() { + // Check if actor exists + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const actor = this.actor ? this.actor : new RawActor(); + + actor.data = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + ], + id: `${config.http.base_url}/@${this.username}`, + type: "Person", + preferredUsername: this.username, + name: this.display_name, + inbox: `${config.http.base_url}/@${this.username}/inbox`, + outbox: `${config.http.base_url}/@${this.username}/outbox`, + followers: `${config.http.base_url}/@${this.username}/followers`, + following: `${config.http.base_url}/@${this.username}/following`, + manuallyApprovesFollowers: false, + summary: this.note, + icon: { + type: "Image", + url: this.avatar, + }, + image: { + type: "Image", + url: this.header, + }, + publicKey: { + id: `${config.http.base_url}/@${this.username}/actor#main-key`, + owner: `${config.http.base_url}/@${this.username}/actor`, + publicKeyPem: this.public_key, + }, + } as APActor; + + await actor.save(); + + this.actor = actor; + await this.save(); + return actor; + } + async generateKeys(): Promise { // openssl genrsa -out private.pem 2048 // openssl rsa -in private.pem -outform PEM -pubout -out public.pem @@ -110,7 +215,7 @@ export class User extends BaseEntity { locked: false, moved: null, noindex: false, - note: this.bio, + note: this.note, suspended: false, url: `${config.http.base_url}/@${this.username}`, username: this.username, diff --git a/server/api/[username]/actor.json/index.ts b/server/api/[username]/actor.json/index.ts index 6f70b437..5ad9e764 100644 --- a/server/api/[username]/actor.json/index.ts +++ b/server/api/[username]/actor.json/index.ts @@ -86,7 +86,7 @@ export default async ( type: "Person", preferredUsername: user.username, // TODO: Add user display name name: user.username, - summary: user.bio, + summary: user.note, icon: /*{ type: "Image", url: user.avatar, diff --git a/server/api/[username]/inbox/index.ts b/server/api/[username]/inbox/index.ts index 86aec01a..c1e6fc20 100644 --- a/server/api/[username]/inbox/index.ts +++ b/server/api/[username]/inbox/index.ts @@ -4,6 +4,7 @@ import { errorResponse, jsonResponse } from "@response"; import { APAccept, APActivity, + APActor, APCreate, APDelete, APFollow, @@ -14,6 +15,8 @@ import { } from "activitypub-types"; import { MatchedRoute } from "bun"; import { RawActivity } from "~database/entities/RawActivity"; +import { RawActor } from "~database/entities/RawActor"; +import { User } from "~database/entities/User"; /** * ActivityPub user inbox endpoint @@ -44,8 +47,8 @@ export default async ( // Check is Activity already exists const activity = await RawActivity.addIfNotExists(body); - if (activity instanceof Error) { - return errorResponse(activity.message, 409); + if (activity instanceof Response) { + return activity; } break; } @@ -58,14 +61,14 @@ export default async ( body.object as APObject ); - if (object instanceof Error) { - return errorResponse(object.message, 409); + if (object instanceof Response) { + return object; } const activity = await RawActivity.addIfNotExists(body); - if (activity instanceof Error) { - return errorResponse(activity.message, 409); + if (activity instanceof Response) { + return activity; } break; @@ -78,19 +81,67 @@ export default async ( await RawActivity.deleteObjectIfExists(body.object as APObject); // Store the Delete event in the database - const activity = RawActivity.addIfNotExists(body); + const activity = await RawActivity.addIfNotExists(body); + + if (activity instanceof Response) { + return activity; + } break; } case "Accept" as APAccept: { // Body is an APAccept object // Add the actor to the object actor's followers list - // TODO: Add actor to object actor's followers list + + if ((body.object as APFollow).type === "Follow") { + const user = await User.getByActorId( + ((body.object as APFollow).actor as APActor).id ?? "" + ); + + if (!user) { + return errorResponse("User not found", 404); + } + + const actor = await RawActor.addIfNotExists( + body.actor as APActor + ); + + if (actor instanceof Response) { + return actor; + } + + user.following.push(actor); + + await user.save(); + } break; } case "Reject" as APReject: { // Body is an APReject object // Mark the follow request as not pending - // TODO: Implement + + if ((body.object as APFollow).type === "Follow") { + const user = await User.getByActorId( + ((body.object as APFollow).actor as APActor).id ?? "" + ); + + if (!user) { + return errorResponse("User not found", 404); + } + + const actor = await RawActor.addIfNotExists( + body.actor as APActor + ); + + if (actor instanceof Response) { + return actor; + } + + user.following = user.following.filter( + following => following.id !== actor.id + ); + + await user.save(); + } break; } } diff --git a/server/api/v1/accounts/update_credentials/index.ts b/server/api/v1/accounts/update_credentials/index.ts index b10c5a8d..0eb179de 100644 --- a/server/api/v1/accounts/update_credentials/index.ts +++ b/server/api/v1/accounts/update_credentials/index.ts @@ -63,7 +63,7 @@ export default async (req: Request): Promise => { ); } - user.bio = note; + user.note = note; } if (avatar) { diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts index bf1a74a8..4532ae23 100644 --- a/tests/inbox.test.ts +++ b/tests/inbox.test.ts @@ -17,7 +17,7 @@ beforeAll(async () => { user.username = "test"; user.password = await Bun.password.hash("test"); user.display_name = ""; - user.bio = ""; + user.note = ""; await user.generateKeys(); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 49fa167d..95c3b80a 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -21,7 +21,7 @@ beforeAll(async () => { user.username = "test"; user.password = await Bun.password.hash("test"); user.display_name = ""; - user.bio = ""; + user.note = ""; await user.generateKeys(); diff --git a/utils/config.ts b/utils/config.ts index 94076c25..6410fd05 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -31,6 +31,13 @@ export interface ConfigType { url_scheme_whitelist: string[]; }; + defaults: { + visibility: string; + language: string; + avatar: string; + header: string; + }; + activitypub: { use_tombstones: boolean; reject_activities: string[]; @@ -134,6 +141,12 @@ export const configDefaults: ConfigType = { "ssb", ], }, + defaults: { + visibility: "public", + language: "en", + avatar: "", + header: "", + }, activitypub: { use_tombstones: true, reject_activities: [],