From 2cadb68a56891603232a134f2abf7587d3a622af Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 26 Sep 2023 12:19:10 -1000 Subject: [PATCH] guh --- .eslintrc.cjs | 3 + database/entities/RawActor.ts | 202 ++++++++++++----------- database/entities/RawObject.ts | 40 ++++- database/entities/Status.ts | 123 ++++++++++++++ server/api/api/v1/statuses/[id]/index.ts | 57 +++++++ server/api/api/v1/statuses/index.ts | 4 + 6 files changed, 332 insertions(+), 97 deletions(-) create mode 100644 server/api/api/v1/statuses/[id]/index.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8282912f..eade8cf0 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,4 +12,7 @@ module.exports = { ignorePatterns: ["node_modules/", "dist/", ".eslintrc.cjs"], plugins: ["@typescript-eslint"], root: true, + rules: { + "@typescript-eslint/no-unsafe-assignment": "off", + }, }; diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index 44d34f13..3e7db536 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -1,100 +1,112 @@ +/* eslint-disable @typescript-eslint/require-await */ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import { APActor, APOrderedCollectionPage, IconField } from "activitypub-types"; +import { APActor, APImage, APOrderedCollectionPage } from "activitypub-types"; import { getConfig, getHost } from "@config"; import { appendFile } from "fs/promises"; import { errorResponse } from "@response"; import { APIAccount } from "~types/entities/account"; /** - * Stores an ActivityPub actor as raw JSON-LD data + * Represents a raw actor entity in the database. */ -@Entity({ - name: "actors", -}) +@Entity({ name: "actors" }) export class RawActor extends BaseEntity { + /** + * The unique identifier of the actor. + */ @PrimaryGeneratedColumn("uuid") id!: string; + /** + * The ActivityPub actor data associated with the actor. + */ @Column("jsonb") data!: APActor; - @Column("jsonb", { - default: [], - }) + /** + * The list of follower IDs associated with the actor. + */ + @Column("jsonb", { default: [] }) followers!: string[]; + /** + * Retrieves a RawActor entity by actor ID. + * @param id The ID of the actor to retrieve. + * @returns The RawActor entity with the specified ID, or undefined if not found. + */ static async getByActorId(id: string) { return await RawActor.createQueryBuilder("actor") - .where("actor.data->>'id' = :id", { - id, - }) + .where("actor.data->>'id' = :id", { id }) .getOne(); } + /** + * Adds a new RawActor entity to the database if an actor with the same ID does not already exist. + * @param data The ActivityPub actor data to add. + * @returns The newly created RawActor entity, or an error response if the actor already exists or is filtered. + */ static async addIfNotExists(data: APActor) { - if (!(await RawActor.exists(data.id ?? ""))) { - const actor = new RawActor(); - actor.data = data; - actor.followers = []; - - 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; + if (await RawActor.exists(data.id ?? "")) { + return errorResponse("Actor already exists", 409); } - return errorResponse("Actor already exists", 409); + + const actor = new RawActor(); + actor.data = data; + actor.followers = []; + + const config = getConfig(); + + if ( + config.activitypub.discard_avatars.some(instance => + actor.id.includes(instance) + ) + ) { + actor.data.icon = undefined; + } + + if ( + config.activitypub.discard_banners.some(instance => + actor.id.includes(instance) + ) + ) { + actor.data.image = undefined; + } + + if (await actor.isObjectFiltered()) { + return errorResponse("Actor filtered", 409); + } + + await actor.save(); + + return actor; } + /** + * Retrieves the domain of the instance associated with the actor. + * @returns The domain of the instance associated with the actor. + */ getInstanceDomain() { return new URL(this.data.id ?? "").host; } + /** + * Fetches the list of followers associated with the actor and updates the `followers` property. + */ async fetchFollowers() { - // Fetch follower list using ActivityPub - - // Loop to fetch all followers until there are no more pages let followers: APOrderedCollectionPage = await fetch( `${this.data.followers?.toString() ?? ""}?page=1`, { - headers: { - Accept: "application/activity+json", - }, + headers: { Accept: "application/activity+json" }, } ); let followersList = followers.orderedItems ?? []; while (followers.type === "OrderedCollectionPage" && followers.next) { - // Fetch next page - // eslint-disable-next-line @typescript-eslint/no-base-to-string - followers = await fetch(followers.next.toString(), { - headers: { - Accept: "application/activity+json", - }, + followers = await fetch((followers.next as string).toString(), { + headers: { Accept: "application/activity+json" }, }).then(res => res.json()); - // Add new followers to list followersList = { ...followersList, ...(followers.orderedItems ?? []), @@ -104,26 +116,32 @@ export class RawActor extends BaseEntity { this.followers = followersList as string[]; } - // eslint-disable-next-line @typescript-eslint/require-await + /** + * Converts the RawActor entity to an API account object. + * @param isOwnAccount Whether the account is the user's own account. + * @returns The API account object representing the RawActor entity. + */ async toAPIAccount(isOwnAccount = false): Promise { const config = getConfig(); + const { preferredUsername, name, summary, published, icon, image } = + this.data; + return { id: this.id, - username: this.data.preferredUsername ?? "", - display_name: this.data.name ?? this.data.preferredUsername ?? "", - note: this.data.summary ?? "", - url: `${config.http.base_url}/@${ - this.data.preferredUsername - }@${this.getInstanceDomain()}`, - // @ts-expect-error It actually works - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - avatar: (this.data.icon as IconField).url ?? config.defaults.avatar, - // @ts-expect-error It actually works - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - header: this.data.image?.url ?? config.defaults.header, + username: preferredUsername ?? "", + display_name: name ?? preferredUsername ?? "", + note: summary ?? "", + url: `${ + config.http.base_url + }/@${preferredUsername}@${this.getInstanceDomain()}`, + avatar: + ((icon as APImage).url as string | undefined) ?? + config.defaults.avatar, + header: + ((image as APImage).url as string | undefined) ?? + config.defaults.header, locked: false, - created_at: new Date(this.data.published ?? 0).toISOString(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + created_at: new Date(published ?? 0).toISOString(), followers_count: 0, following_count: 0, statuses_count: 0, @@ -143,10 +161,8 @@ export class RawActor extends BaseEntity { header_static: "", acct: this.getInstanceDomain() == getHost() - ? `${this.data.preferredUsername}` - : `${ - this.data.preferredUsername - }@${this.getInstanceDomain()}`, + ? `${preferredUsername}` + : `${preferredUsername}@${this.getInstanceDomain()}`, limited: false, moved: null, noindex: false, @@ -158,24 +174,23 @@ export class RawActor extends BaseEntity { }; } + /** + * Determines whether the actor is filtered based on the instance's filter rules. + * @returns Whether the actor is filtered. + */ async isObjectFiltered() { const config = getConfig(); + const { type, preferredUsername, name, id } = this.data; const usernameFilterResult = await Promise.all( config.filters.username_filters.map(async filter => { - if ( - this.data.type === "Person" && - this.data.preferredUsername?.match(filter) - ) { - // Log filter - - if (config.logging.log_filters) + if (type === "Person" && preferredUsername?.match(filter)) { + if (config.logging.log_filters) { await appendFile( process.cwd() + "/logs/filters.log", - `${new Date().toISOString()} Filtered actor username: "${ - this.data.preferredUsername - }" (ID: ${this.data.id}) based on rule: ${filter}\n` + `${new Date().toISOString()} Filtered actor username: "${preferredUsername}" (ID: ${id}) based on rule: ${filter}\n` ); + } return true; } }) @@ -183,19 +198,13 @@ export class RawActor extends BaseEntity { const displayNameFilterResult = await Promise.all( config.filters.displayname_filters.map(async filter => { - if ( - this.data.type === "Person" && - this.data.name?.match(filter) - ) { - // Log filter - - if (config.logging.log_filters) + if (type === "Person" && name?.match(filter)) { + if (config.logging.log_filters) { await appendFile( process.cwd() + "/logs/filters.log", - `${new Date().toISOString()} Filtered actor username: "${ - this.data.preferredUsername - }" (ID: ${this.data.id}) based on rule: ${filter}\n` + `${new Date().toISOString()} Filtered actor username: "${preferredUsername}" (ID: ${id}) based on rule: ${filter}\n` ); + } return true; } }) @@ -207,6 +216,11 @@ export class RawActor extends BaseEntity { ); } + /** + * Determines whether an actor with the specified ID exists in the database. + * @param id The ID of the actor to check for. + * @returns Whether an actor with the specified ID exists in the database. + */ static async exists(id: string) { return !!(await RawActor.getByActorId(id)); } diff --git a/database/entities/RawObject.ts b/database/entities/RawObject.ts index fa5d9498..e3470a07 100644 --- a/database/entities/RawObject.ts +++ b/database/entities/RawObject.ts @@ -1,10 +1,11 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import { APActor, APObject, DateTime } from "activitypub-types"; +import { APActor, APImage, APObject, DateTime } from "activitypub-types"; import { getConfig } from "@config"; import { appendFile } from "fs/promises"; import { APIStatus } from "~types/entities/status"; import { RawActor } from "./RawActor"; import { APIAccount } from "~types/entities/account"; +import { APIEmoji } from "~types/entities/emoji"; /** * Stores an ActivityPub object as raw JSON-LD data @@ -27,7 +28,38 @@ export class RawObject extends BaseEntity { .getOne(); } + // eslint-disable-next-line @typescript-eslint/require-await + async parseEmojis() { + const emojis = this.data.tag as { + id: string; + type: string; + name: string; + updated: string; + icon: { + type: "Image"; + mediaType: string; + url: string; + }; + }[]; + + return emojis.map(emoji => ({ + shortcode: emoji.name, + static_url: (emoji.icon as APImage).url, + url: (emoji.icon as APImage).url, + visible_in_picker: true, + category: "custom", + })) as APIEmoji[]; + } + async toAPI(): Promise { + const mentions = ( + await Promise.all( + (this.data.to as string[]).map( + async person => await RawActor.getByActorId(person) + ) + ) + ).filter(m => m) as RawActor[]; + return { account: (await ( @@ -41,11 +73,13 @@ export class RawObject extends BaseEntity { application: null, card: null, content: this.data.content as string, - emojis: [], + emojis: await this.parseEmojis(), favourited: false, favourites_count: 0, media_attachments: [], - mentions: [], + mentions: await Promise.all( + mentions.map(async m => await m.toAPIAccount()) + ), in_reply_to_account_id: null, language: null, muted: false, diff --git a/database/entities/Status.ts b/database/entities/Status.ts index f3088899..1494ca73 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -7,7 +7,9 @@ import { JoinTable, ManyToMany, ManyToOne, + OneToOne, PrimaryGeneratedColumn, + RemoveOptions, UpdateDateColumn, } from "typeorm"; import { APIStatus } from "~types/entities/status"; @@ -15,6 +17,8 @@ import { User } from "./User"; import { Application } from "./Application"; import { Emoji } from "./Emoji"; import { RawActivity } from "./RawActivity"; +import { RawObject } from "./RawObject"; +import { RawActor } from "./RawActor"; const config = getConfig(); @@ -42,6 +46,9 @@ export class Status extends BaseEntity { }) reblog?: Status; + @OneToOne(() => Status) + object!: RawObject; + @Column("boolean") isReblog!: boolean; @@ -53,6 +60,16 @@ export class Status extends BaseEntity { @Column("varchar") visibility!: APIStatus["visibility"]; + @ManyToOne(() => RawObject, { + nullable: true, + }) + in_reply_to_post!: RawObject; + + @ManyToOne(() => RawActor, { + nullable: true, + }) + in_reply_to_account!: RawActor; + @Column("boolean") sensitive!: boolean; @@ -78,6 +95,15 @@ export class Status extends BaseEntity { @JoinTable() announces!: RawActivity[]; + async remove(options?: RemoveOptions | undefined) { + // Delete object + await this.object.remove(options); + + await super.remove(options); + + return this; + } + static async createNew(data: { account: User; application: Application | null; @@ -86,6 +112,10 @@ export class Status extends BaseEntity { sensitive: boolean; spoiler_text: string; emojis: Emoji[]; + reply?: { + object: RawObject; + actor: RawActor; + }; }) { const newStatus = new Status(); @@ -101,6 +131,99 @@ export class Status extends BaseEntity { newStatus.isReblog = false; newStatus.announces = []; + newStatus.object = new RawObject(); + + if (data.reply) { + newStatus.in_reply_to_post = data.reply.object; + newStatus.in_reply_to_account = data.reply.actor; + } + + newStatus.object.data = { + id: `${config.http.base_url}/@${data.account.username}/statuses/${newStatus.id}`, + type: "Note", + summary: data.spoiler_text, + content: data.content, // TODO: Format as HTML + inReplyTo: data.reply?.object + ? data.reply.object.data.id + : undefined, + attributedTo: `${config.http.base_url}/@${data.account.username}`, + }; + + // Get people mentioned in the content + const mentionedPeople = [ + ...data.content.matchAll(/@([a-zA-Z0-9_]+)/g), + ].map(match => { + return `${config.http.base_url}/@${match[1]}`; + }); + + // Map this to Actors + const mentionedActors = ( + await Promise.all( + mentionedPeople.map(async person => { + // Check if post is in format @username or @username@instance.com + // If is @username, the user is a local user + const instanceUrl = + person.split("@").length === 3 + ? person.split("@")[2] + : null; + + if (instanceUrl) { + const actor = await RawActor.createQueryBuilder("actor") + .where("actor.data->>'id' = :id", { + // Where ID contains the instance URL + id: `%${instanceUrl}%`, + }) + // Where actor preferredUsername is the username + .andWhere( + "actor.data->>'preferredUsername' = :username", + { + username: person.split("@")[1], + } + ) + .getOne(); + + return actor?.data.id; + } else { + const actor = await User.findOne({ + where: { + username: person.split("@")[1], + }, + relations: { + actor: true, + }, + }); + + return actor?.actor.data.id; + } + }) + ) + ).map(actor => actor as string); + + newStatus.object.data.to = mentionedActors; + + if (data.visibility === "private") { + newStatus.object.data.cc = [ + `${config.http.base_url}/@${data.account.username}/followers`, + ]; + } else if (data.visibility === "direct") { + // Add nothing else + } else if (data.visibility === "public") { + newStatus.object.data.to = [ + ...newStatus.object.data.to, + "https://www.w3.org/ns/activitystreams#Public", + ]; + newStatus.object.data.cc = [ + `${config.http.base_url}/@${data.account.username}/followers`, + ]; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + else if (data.visibility === "unlisted") { + newStatus.object.data.to = [ + ...newStatus.object.data.to, + "https://www.w3.org/ns/activitystreams#Public", + ]; + } + // TODO: Add default language await newStatus.save(); diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts new file mode 100644 index 00000000..f2031d9d --- /dev/null +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -0,0 +1,57 @@ +import { getUserByToken } from "@auth"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { RawObject } from "~database/entities/RawObject"; +import { Status } from "~database/entities/Status"; + +/** + * Fetch a user + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + // Check auth token + const token = req.headers.get("Authorization")?.split(" ")[1] || null; + const user = await getUserByToken(token); + + // TODO: Add checks for user's permissions to view this status + + let foundStatus: RawObject | null; + try { + foundStatus = await RawObject.findOneBy({ + id, + }); + } catch (e) { + return errorResponse("Invalid ID", 404); + } + + if (!foundStatus) return errorResponse("Record not found", 404); + + if (req.method === "GET") { + return jsonResponse(await foundStatus.toAPI()); + } else if (req.method === "DELETE") { + if ((await foundStatus.toAPI()).account.id !== user?.id) { + return errorResponse("Unauthorized", 401); + } + + // Get associated Status object + const status = await Status.createQueryBuilder("status") + .leftJoinAndSelect("status.object", "object") + .where("object.id = :id", { id: foundStatus.id }) + .getOne(); + + if (!status) { + return errorResponse("Status not found", 404); + } + + // Delete status and all associated objects + await status.object.remove(); + + return jsonResponse({}); + } + + return jsonResponse({}); +}; diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index ba132405..25a48862 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -53,8 +53,12 @@ export default async (req: Request): Promise => { visibility?: "public" | "unlisted" | "private" | "direct"; language?: string; scheduled_at?: string; + local_only?: boolean; + content_type?: string; }>(req); + // TODO: Parse Markdown statuses + // Validate status if (!status) { return errorResponse("Status is required", 422);