diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 29417e7f..eecfbc98 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, Entity, + JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn, @@ -66,14 +67,44 @@ export class Status extends BaseEntity { application!: Application | null; @ManyToMany(() => Emoji, emoji => emoji.id) + @JoinTable() emojis!: Emoji[]; - @ManyToMany(() => RawActivity, activity => activity.id, {}) + @ManyToMany(() => RawActivity, activity => activity.id) + @JoinTable() likes!: RawActivity[]; - @ManyToMany(() => RawActivity, activity => activity.id, {}) + @ManyToMany(() => RawActivity, activity => activity.id) + @JoinTable() announces!: RawActivity[]; + static async createNew(data: { + account: User; + application: Application | null; + content: string; + visibility: APIStatus["visibility"]; + sensitive: boolean; + spoiler_text: string; + emojis: Emoji[]; + }) { + const newStatus = new Status(); + + newStatus.account = data.account; + newStatus.application = data.application ?? null; + newStatus.content = data.content; + newStatus.visibility = data.visibility; + newStatus.sensitive = data.sensitive; + newStatus.spoiler_text = data.spoiler_text; + newStatus.emojis = data.emojis; + newStatus.likes = []; + newStatus.announces = []; + newStatus.isReblog = false; + newStatus.announces = []; + + await newStatus.save(); + return newStatus; + } + async toAPI(): Promise { return { account: await this.account.toAPI(), @@ -84,7 +115,7 @@ export class Status extends BaseEntity { this.emojis.map(async emoji => await emoji.toAPI()) ), favourited: false, - favourites_count: 0, + favourites_count: this.likes.length, id: this.id, in_reply_to_account_id: null, in_reply_to_id: null, @@ -96,13 +127,13 @@ export class Status extends BaseEntity { poll: null, reblog: this.isReblog ? (await this.reblog?.toAPI()) ?? null : null, reblogged: false, - reblogs_count: 0, + reblogs_count: this.announces.length, replies_count: 0, sensitive: false, spoiler_text: "", tags: [], card: null, - content: "", + content: this.content, uri: `${config.http.base_url}/@${this.account.username}/${this.id}`, url: `${config.http.base_url}/@${this.account.username}/${this.id}`, visibility: "public", diff --git a/database/entities/User.ts b/database/entities/User.ts index 99e16e40..5846601c 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -14,6 +14,8 @@ import { APIAccount } from "~types/entities/account"; import { RawActor } from "./RawActor"; import { APActor } from "activitypub-types"; import { RawObject } from "./RawObject"; +import { Token } from "./Token"; +import { Status } from "./Status"; const config = getConfig(); @@ -132,6 +134,26 @@ export class User extends BaseEntity { return user; } + async selfDestruct() { + // Clean up tokens + const tokens = await Token.findBy({ + user: { + id: this.id, + }, + }); + + const statuses = await Status.findBy({ + account: { + id: this.id, + }, + }); + + // Delete both + await Promise.all(tokens.map(async token => await token.remove())); + + await Promise.all(statuses.map(async status => await status.remove())); + } + async updateActor() { // Check if actor exists // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -218,7 +240,7 @@ export class User extends BaseEntity { avatar_static: "", bot: false, created_at: this.created_at.toISOString(), - display_name: "", + display_name: this.display_name, followers_count: 0, following_count: 0, group: false, diff --git a/server/api/[username]/actor.json/index.ts b/server/api/[username]/actor.json/index.ts deleted file mode 100644 index 5ad9e764..00000000 --- a/server/api/[username]/actor.json/index.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { errorResponse, jsonLdResponse } from "@response"; -import { MatchedRoute } from "bun"; -import { User } from "~database/entities/User"; -import { getHost } from "@config"; -import { compact } from "jsonld"; - -/** - * ActivityPub user actor endpoinmt - */ -export default async ( - req: Request, - matchedRoute: MatchedRoute -): Promise => { - const username = matchedRoute.params.username.split("@")[0]; - - const user = await User.findOneBy({ username }); - - if (!user) { - return errorResponse("User not found", 404); - } - - return jsonLdResponse( - await compact({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - { - manuallyApprovesFollowers: "as:manuallyApprovesFollowers", - toot: "http://joinmastodon.org/ns#", - featured: { - "@id": "toot:featured", - "@type": "@id", - }, - featuredTags: { - "@id": "toot:featuredTags", - "@type": "@id", - }, - alsoKnownAs: { - "@id": "as:alsoKnownAs", - "@type": "@id", - }, - movedTo: { - "@id": "as:movedTo", - "@type": "@id", - }, - schema: "http://schema.org#", - PropertyValue: "schema:PropertyValue", - value: "schema:value", - discoverable: "toot:discoverable", - Device: "toot:Device", - Ed25519Signature: "toot:Ed25519Signature", - Ed25519Key: "toot:Ed25519Key", - Curve25519Key: "toot:Curve25519Key", - EncryptedMessage: "toot:EncryptedMessage", - publicKeyBase64: "toot:publicKeyBase64", - deviceId: "toot:deviceId", - claim: { - "@type": "@id", - "@id": "toot:claim", - }, - fingerprintKey: { - "@type": "@id", - "@id": "toot:fingerprintKey", - }, - identityKey: { - "@type": "@id", - "@id": "toot:identityKey", - }, - devices: { - "@type": "@id", - "@id": "toot:devices", - }, - messageFranking: "toot:messageFranking", - messageType: "toot:messageType", - cipherText: "toot:cipherText", - suspended: "toot:suspended", - Emoji: "toot:Emoji", - focalPoint: { - "@container": "@list", - "@id": "toot:focalPoint", - }, - Hashtag: "as:Hashtag", - }, - ], - id: `${getHost()}/@${user.username}/actor`, - type: "Person", - preferredUsername: user.username, // TODO: Add user display name - name: user.username, - summary: user.note, - icon: /*{ - type: "Image", - url: user.avatar, - mediaType: mimetype - }*/ undefined, // TODO: Add avatar - image: /*{ - type: "Image", - url: user.avatar, - mediaType: mimetype - }*/ undefined, // TODO: Add banner - inbox: `${getHost()}/@${user.username}/inbox`, - outbox: `${getHost()}/@${user.username}/outbox`, - followers: `${getHost()}/@${user.username}/followers`, - following: `${getHost()}/@${user.username}/following`, - liked: `${getHost()}/@${user.username}/liked`, - discoverable: true, - alsoKnownAs: [ - // TODO: Add accounts from which the user migrated - ], - manuallyApprovesFollowers: false, // TODO: Change - publicKey: { - id: `${getHost()}/@${user.username}/actor#main-key`, - owner: `${getHost()}/@${user.username}/actor`, - // TODO: Add user public key - }, - tag: [ - // TODO: Add emojis here, and hashtags - ], - attachment: [ - // TODO: Add user attachments (I.E. profile metadata) - ], - endpoints: { - sharedInbox: `${getHost()}/inbox`, - }, - }) - ); -}; diff --git a/server/api/[username]/actor/index.ts b/server/api/[username]/actor/index.ts new file mode 100644 index 00000000..f5e8377c --- /dev/null +++ b/server/api/[username]/actor/index.ts @@ -0,0 +1,137 @@ +import { errorResponse, jsonLdResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { User } from "~database/entities/User"; +import { getConfig, getHost } from "@config"; + +/** + * ActivityPub user actor endpoinmt + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + // Check for Accept header + const accept = req.headers.get("Accept"); + + if (!accept || !accept.includes("application/activity+json")) { + return errorResponse("This endpoint requires an Accept header", 406); + } + + const config = getConfig(); + + const username = matchedRoute.params.username.replace("@", ""); + + const user = await User.findOneBy({ username }); + + if (!user) { + return errorResponse("User not found", 404); + } + + return jsonLdResponse({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + manuallyApprovesFollowers: "as:manuallyApprovesFollowers", + toot: "http://joinmastodon.org/ns#", + featured: { + "@id": "toot:featured", + "@type": "@id", + }, + featuredTags: { + "@id": "toot:featuredTags", + "@type": "@id", + }, + alsoKnownAs: { + "@id": "as:alsoKnownAs", + "@type": "@id", + }, + movedTo: { + "@id": "as:movedTo", + "@type": "@id", + }, + schema: "http://schema.org#", + PropertyValue: "schema:PropertyValue", + value: "schema:value", + discoverable: "toot:discoverable", + Device: "toot:Device", + Ed25519Signature: "toot:Ed25519Signature", + Ed25519Key: "toot:Ed25519Key", + Curve25519Key: "toot:Curve25519Key", + EncryptedMessage: "toot:EncryptedMessage", + publicKeyBase64: "toot:publicKeyBase64", + deviceId: "toot:deviceId", + claim: { + "@type": "@id", + "@id": "toot:claim", + }, + fingerprintKey: { + "@type": "@id", + "@id": "toot:fingerprintKey", + }, + identityKey: { + "@type": "@id", + "@id": "toot:identityKey", + }, + devices: { + "@type": "@id", + "@id": "toot:devices", + }, + messageFranking: "toot:messageFranking", + messageType: "toot:messageType", + cipherText: "toot:cipherText", + suspended: "toot:suspended", + Emoji: "toot:Emoji", + focalPoint: { + "@container": "@list", + "@id": "toot:focalPoint", + }, + Hashtag: "as:Hashtag", + }, + ], + id: `${config.http.base_url}:${config.http.port}/@${user.username}`, + type: "Person", + preferredUsername: user.username, // TODO: Add user display name + name: user.username, + summary: user.note, + icon: { + type: "Image", + url: user.avatar, + mediaType: "image/png", // TODO: Set user avatar mimetype + }, + image: { + type: "Image", + url: user.header, + mediaType: "image/png", // TODO: Set user header mimetype + }, + inbox: `${config.http.base_url}:${config.http.port}/@${user.username}/inbox`, + outbox: `${config.http.base_url}:${config.http.port}/@${user.username}/outbox`, + followers: `${config.http.base_url}:${config.http.port}/@${user.username}/followers`, + following: `${config.http.base_url}:${config.http.port}/@${user.username}/following`, + liked: `${config.http.base_url}:${config.http.port}/@${user.username}/liked`, + discoverable: true, + alsoKnownAs: [ + // TODO: Add accounts from which the user migrated + ], + manuallyApprovesFollowers: false, // TODO: Change + publicKey: { + id: `${getHost()}${config.http.base_url}:${config.http.port}/@${ + user.username + }/actor#main-key`, + owner: `${config.http.base_url}:${config.http.port}/@${user.username}`, + // Split the public key into PEM format + publicKeyPem: `-----BEGIN PUBLIC KEY-----\n${user.public_key + .match(/.{1,64}/g) + ?.join("\n")}\n-----END PUBLIC KEY-----`, + }, + tag: [ + // TODO: Add emojis here, and hashtags + ], + attachment: [ + // TODO: Add user attachments (I.E. profile metadata) + ], + endpoints: { + sharedInbox: `${config.http.base_url}:${config.http.port}/inbox`, + }, + }); +}; diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index 39dca967..ac9c486a 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -16,9 +16,14 @@ export default async ( const token = req.headers.get("Authorization")?.split(" ")[1] || null; const user = await getUserByToken(token); - const foundUser = await RawActor.findOneBy({ - id, - }); + let foundUser: RawActor | null; + try { + foundUser = await RawActor.findOneBy({ + id, + }); + } catch (e) { + return errorResponse("Invalid ID", 404); + } if (!foundUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 9913012f..e44b69cf 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -46,5 +46,7 @@ export default async ( take: limit ?? 20, }); - return jsonResponse(statuses.map(status => status.toAPI())); + return jsonResponse( + await Promise.all(statuses.map(async status => await status.toAPI())) + ); }; diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 0eb179de..5c9a133c 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -119,10 +119,7 @@ export default async (req: Request): Promise => { // user.discoverable = discoverable === "true"; } - return jsonResponse( - { - error: `Not really implemented yet`, - }, - 501 - ); + await user.save(); + + return jsonResponse(await user.toAPI()); }; diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index d42e383c..a7fb451c 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -98,19 +98,17 @@ export default async (req: Request): Promise => { } // Create status - const newStatus = new Status(); - newStatus.account = user; - newStatus.application = application; - newStatus.content = status; - newStatus.spoiler_text = spoiler_text || ""; - newStatus.sensitive = sensitive || false; - newStatus.visibility = visibility || "public"; - newStatus.likes = []; - newStatus.isReblog = false; + const newStatus = await Status.createNew({ + account: user, + application, + content: status, + visibility: visibility || "public", + sensitive: sensitive || false, + spoiler_text: spoiler_text || "", + emojis: [], + }); // TODO: add database jobs to deliver the post - await newStatus.save(); - - return jsonResponse(newStatus); + return jsonResponse(await newStatus.toAPI()); }; diff --git a/tests/actor.test.ts b/tests/actor.test.ts index c70965ad..3bfd448d 100644 --- a/tests/actor.test.ts +++ b/tests/actor.test.ts @@ -5,7 +5,6 @@ import { APActor } from "activitypub-types"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { AppDataSource } from "~database/datasource"; import { RawActivity } from "~database/entities/RawActivity"; -import { Token } from "~database/entities/Token"; import { User } from "~database/entities/User"; const config = getConfig(); @@ -22,10 +21,10 @@ beforeAll(async () => { }); }); -describe("POST /@test", () => { +describe("POST /@test/actor", () => { test("should return a valid ActivityPub Actor when querying an existing user", async () => { const response = await fetch( - `${config.http.base_url}:${config.http.port}/@test`, + `${config.http.base_url}:${config.http.port}/@test/actor`, { method: "GET", headers: { @@ -64,6 +63,9 @@ describe("POST /@test", () => { `${config.http.base_url}:${config.http.port}/@test` ); expect((actor as any).publicKey.publicKeyPem).toBeDefined(); + expect((actor as any).publicKey.publicKeyPem).toMatch( + /(-----BEGIN PUBLIC KEY-----(\n|\r|\r\n)([0-9a-zA-Z+/=]{64}(\n|\r|\r\n))*([0-9a-zA-Z+/=]{1,63}(\n|\r|\r\n))?-----END PUBLIC KEY-----)|(-----BEGIN PRIVATE KEY-----(\n|\r|\r\n)([0-9a-zA-Z+/=]{64}(\n|\r|\r\n))*([0-9a-zA-Z+/=]{1,63}(\n|\r|\r\n))?-----END PRIVATE KEY-----)/ + ); }); }); @@ -73,13 +75,6 @@ afterAll(async () => { username: "test", }); - // Clean up tokens - const tokens = await Token.findBy({ - user: { - username: "test", - }, - }); - const activities = await RawActivity.createQueryBuilder("activity") .where("activity.data->>'actor' = :actor", { actor: `${config.http.base_url}:${config.http.port}/@test`, @@ -97,7 +92,8 @@ afterAll(async () => { }) ); - await Promise.all(tokens.map(async token => await token.remove())); - - if (user) await user.remove(); + if (user) { + await user.selfDestruct(); + await user.remove(); + } }); diff --git a/tests/api.test.ts b/tests/api.test.ts index 49ecebd0..ef6bfc7d 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -48,11 +48,11 @@ beforeAll(async () => { token.token_type = TokenType.BEARER; token.user = user; - await token.save(); + token = await token.save(); }); describe("POST /api/v1/accounts/:id", () => { - test("should return a 404 error when trying to update a non-existent user", async () => { + test("should return a 404 error when trying to fetch a non-existent user", async () => { const response = await fetch( `${config.http.base_url}:${config.http.port}/api/v1/accounts/999999`, { @@ -93,26 +93,14 @@ describe("POST /api/v1/statuses", () => { expect(status.content).toBe("Hello, world!"); expect(status.visibility).toBe("public"); - expect(status.account.id).toBe( - `${config.http.base_url}:${config.http.port}/@test` - ); + expect(status.account.id).toBe(user.id); expect(status.replies_count).toBe(0); expect(status.favourites_count).toBe(0); expect(status.reblogged).toBe(false); expect(status.favourited).toBe(false); - expect(status.reblog?.content).toBe("Hello, world!"); - expect(status.reblog?.visibility).toBe("public"); - expect(status.reblog?.account.id).toBe( - `${config.http.base_url}:${config.http.port}/@test` - ); - expect(status.reblog?.replies_count).toBe(0); - expect(status.reblog?.favourites_count).toBe(0); - expect(status.reblog?.reblogged).toBe(false); - expect(status.reblog?.favourited).toBe(false); expect(status.media_attachments).toEqual([]); expect(status.mentions).toEqual([]); expect(status.tags).toEqual([]); - expect(status.application).toBeNull(); expect(status.sensitive).toBe(false); expect(status.spoiler_text).toBe(""); expect(status.language).toBeNull(); @@ -123,17 +111,15 @@ describe("POST /api/v1/statuses", () => { expect(status.emojis).toEqual([]); expect(status.in_reply_to_id).toBeNull(); expect(status.in_reply_to_account_id).toBeNull(); - expect(status.reblog?.in_reply_to_id).toBeNull(); - expect(status.reblog?.in_reply_to_account_id).toBeNull(); }); }); -describe("POST /api/v1/accounts/update_credentials", () => { +describe("PATCH /api/v1/accounts/update_credentials", () => { test("should update the authenticated user's display name", async () => { const response = await fetch( `${config.http.base_url}:${config.http.port}/api/v1/accounts/update_credentials`, { - method: "POST", + method: "PATCH", headers: { Authorization: `Bearer ${token.access_token}`, "Content-Type": "application/json", @@ -154,18 +140,10 @@ describe("POST /api/v1/accounts/update_credentials", () => { }); afterAll(async () => { - // Clean up user const user = await User.findOneBy({ username: "test", }); - // Clean up tokens - const tokens = await Token.findBy({ - user: { - username: "test", - }, - }); - const activities = await RawActivity.createQueryBuilder("activity") .where("activity.data->>'actor' = :actor", { actor: `${config.http.base_url}:${config.http.port}/@test`, @@ -183,7 +161,8 @@ afterAll(async () => { }) ); - await Promise.all(tokens.map(async token => await token.remove())); - - if (user) await user.remove(); + if (user) { + await user.selfDestruct(); + await user.remove(); + } }); diff --git a/utils/auth.ts b/utils/auth.ts index bd4526c8..5010d3b5 100644 --- a/utils/auth.ts +++ b/utils/auth.ts @@ -3,8 +3,11 @@ import { Token } from "~database/entities/Token"; export const getUserByToken = async (access_token: string | null) => { if (!access_token) return null; - const token = await Token.findOneBy({ - access_token, + const token = await Token.findOne({ + where: { + access_token, + }, + relations: ["user"], }); if (!token) return null; diff --git a/utils/request.ts b/utils/request.ts index 39692a84..f1cfd731 100644 --- a/utils/request.ts +++ b/utils/request.ts @@ -6,7 +6,6 @@ * @param request The request to parse */ export async function parseRequest(request: Request): Promise> { - const formData = await request.formData(); const query = new URL(request.url).searchParams; // if request contains a JSON body @@ -16,6 +15,8 @@ export async function parseRequest(request: Request): Promise> { // If request contains FormData if (request.headers.get("Content-Type")?.includes("multipart/form-data")) { + const formData = await request.formData(); + if ([...formData.entries()].length > 0) { const data: Record = {};