diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index 85793ff2..dcaa7b0e 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -1,8 +1,9 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import { APActor } from "activitypub-types"; -import { getConfig } from "@config"; +import { APActor, APOrderedCollectionPage, IconField } 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 @@ -17,7 +18,10 @@ export class RawActor extends BaseEntity { @Column("jsonb") data!: APActor; - static async getById(id: string) { + @Column("jsonb") + followers!: string[]; + + static async getByActorId(id: string) { return await RawActor.createQueryBuilder("actor") .where("actor.data->>'id' = :id", { id, @@ -59,6 +63,98 @@ export class RawActor extends BaseEntity { return errorResponse("Actor already exists", 409); } + getInstanceDomain() { + return new URL(this.data.id ?? "").host; + } + + 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", + }, + } + ); + + 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", + }, + }).then(res => res.json()); + + // Add new followers to list + followersList = { + ...followersList, + ...(followers.orderedItems ?? []), + }; + } + + this.followers = followersList as string[]; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async toAPIAccount(isOwnAccount = false): Promise { + const config = getConfig(); + 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}:${config.http.port}/@${ + 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, + locked: false, + created_at: new Date(this.data.published ?? 0).toISOString(), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + followers_count: 0, + following_count: 0, + statuses_count: 0, + emojis: [], + fields: [], + bot: false, + source: isOwnAccount + ? { + privacy: "public", + sensitive: false, + language: "en", + note: "", + fields: [], + } + : undefined, + avatar_static: "", + header_static: "", + acct: + this.getInstanceDomain() == getHost() + ? `${this.data.preferredUsername}` + : `${ + this.data.preferredUsername + }@${this.getInstanceDomain()}`, + limited: false, + moved: null, + noindex: false, + suspended: false, + discoverable: undefined, + mute_expires_at: undefined, + group: false, + role: undefined, + }; + } + async isObjectFiltered() { const config = getConfig(); @@ -109,6 +205,6 @@ export class RawActor extends BaseEntity { } static async exists(id: string) { - return !!(await RawActor.getById(id)); + return !!(await RawActor.getByActorId(id)); } } diff --git a/database/entities/RawObject.ts b/database/entities/RawObject.ts index 99e0c9a8..8a41557f 100644 --- a/database/entities/RawObject.ts +++ b/database/entities/RawObject.ts @@ -1,7 +1,11 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; -import { APObject } from "activitypub-types"; +import { APActor, 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 { User } from "./User"; /** * Stores an ActivityPub object as raw JSON-LD data @@ -24,6 +28,50 @@ export class RawObject extends BaseEntity { .getOne(); } + async isPinned() { + + } + + async toAPI(): Promise { + return { + account: + (await ( + await RawActor.getByActorId( + (this.data.attributedTo as APActor).id ?? "" + ) + )?.toAPIAccount()) ?? (null as unknown as APIAccount), + created_at: new Date(this.data.published as DateTime).toISOString(), + id: this.id, + in_reply_to_id: null, + application: null, + card: null, + content: this.data.content as string, + emojis: [], + favourited: false, + favourites_count: 0, + media_attachments: [], + mentions: [], + in_reply_to_account_id: null, + language: null, + muted: false, + pinned: false, + poll: null, + reblog: null, + reblogged: false, + reblogs_count: 0, + replies_count: 0, + sensitive: false, + spoiler_text: "", + tags: [], + uri: this.data.id as string, + visibility: "public", + url: this.data.id as string, + bookmarked: false, + quote: null, + quote_id: undefined, + }; + } + async isObjectFiltered() { const config = getConfig(); diff --git a/database/entities/User.ts b/database/entities/User.ts index dfbb8e94..cc6bed9b 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -13,6 +13,7 @@ import { import { APIAccount } from "~types/entities/account"; import { RawActor } from "./RawActor"; import { APActor } from "activitypub-types"; +import { RawObject } from "./RawObject"; const config = getConfig(); @@ -79,11 +80,20 @@ export class User extends BaseEntity { @JoinTable() following!: RawActor[]; + @ManyToMany(() => RawActor, actor => actor.id) + @JoinTable() + followers!: RawActor[]; + + @ManyToMany(() => RawObject, object => object.id) + @JoinTable() + pinned_notes!: RawObject[]; + 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") + .leftJoinAndSelect("user.followers", "followers") .where("actor.data @> :data", { data: JSON.stringify({ id, diff --git a/server/api/[username]/inbox/index.ts b/server/api/[username]/inbox/index.ts index c1e6fc20..7cec2341 100644 --- a/server/api/[username]/inbox/index.ts +++ b/server/api/[username]/inbox/index.ts @@ -144,6 +144,29 @@ export default async ( } break; } + case "Follow" as APFollow: { + // Body is an APFollow object + // Add the actor to the object actor's followers list + + const user = await User.getByActorId( + (body.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.followers.push(actor); + + await user.save(); + break; + } } return jsonResponse({}); diff --git a/server/api/v1/accounts/[id]/index.ts b/server/api/v1/accounts/[id]/index.ts index d4430b15..39dca967 100644 --- a/server/api/v1/accounts/[id]/index.ts +++ b/server/api/v1/accounts/[id]/index.ts @@ -1,6 +1,7 @@ +import { getUserByToken } from "@auth"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { User } from "~database/entities/User"; +import { RawActor } from "~database/entities/RawActor"; /** * Fetch a user @@ -11,11 +12,17 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const user = await User.findOneBy({ + // Check auth token + const token = req.headers.get("Authorization")?.split(" ")[1] || null; + const user = await getUserByToken(token); + + const foundUser = await RawActor.findOneBy({ id, }); - if (!user) return errorResponse("User not found", 404); + if (!foundUser) return errorResponse("User not found", 404); - return jsonResponse(user.toAPI()); + return jsonResponse( + await foundUser.toAPIAccount(user?.id === foundUser.id) + ); }; diff --git a/server/api/v1/accounts/index.ts b/server/api/v1/accounts/index.ts index b7622ae2..a95dae0a 100644 --- a/server/api/v1/accounts/index.ts +++ b/server/api/v1/accounts/index.ts @@ -147,11 +147,11 @@ export default async (req: Request): Promise => { // TODO: Check if locale is valid - const newUser = new User(); - - newUser.username = body.username ?? ""; - newUser.email = body.email ?? ""; - newUser.password = await Bun.password.hash(body.password ?? ""); + await User.createNew({ + username: body.username ?? "", + password: body.password ?? "", + email: body.email ?? "", + }); // TODO: Return access token return new Response();