From ce2ed0754eebd069c0f693136ca8416895ec1c9f Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 21 Sep 2023 16:15:12 -1000 Subject: [PATCH] Add new API endpoint (accounts/verify_credentials) --- database/entities/RawActor.ts | 2 +- database/entities/RawObject.ts | 5 - database/entities/User.ts | 13 +- index.ts | 4 +- server/api/[username]/actor/index.ts | 18 +- .../v1/accounts/verify_credentials/index.ts | 35 +++ tests/actor.test.ts | 39 ++-- tests/api.test.ts | 49 +++- tests/inbox.test.ts | 216 +++++++++--------- tests/oauth.test.ts | 26 +-- utils/config.ts | 8 +- 11 files changed, 234 insertions(+), 181 deletions(-) create mode 100644 server/api/api/v1/accounts/verify_credentials/index.ts diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index f6db5acf..44d34f13 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -112,7 +112,7 @@ export class RawActor extends BaseEntity { username: this.data.preferredUsername ?? "", display_name: this.data.name ?? this.data.preferredUsername ?? "", note: this.data.summary ?? "", - url: `${config.http.base_url}:${config.http.port}/@${ + url: `${config.http.base_url}/@${ this.data.preferredUsername }@${this.getInstanceDomain()}`, // @ts-expect-error It actually works diff --git a/database/entities/RawObject.ts b/database/entities/RawObject.ts index 8a41557f..fa5d9498 100644 --- a/database/entities/RawObject.ts +++ b/database/entities/RawObject.ts @@ -5,7 +5,6 @@ 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 @@ -28,10 +27,6 @@ export class RawObject extends BaseEntity { .getOne(); } - async isPinned() { - - } - async toAPI(): Promise { return { account: diff --git a/database/entities/User.ts b/database/entities/User.ts index 5846601c..89bdac71 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -16,6 +16,7 @@ import { APActor } from "activitypub-types"; import { RawObject } from "./RawObject"; import { Token } from "./Token"; import { Status } from "./Status"; +import { APISource } from "~types/entities/source"; const config = getConfig(); @@ -57,6 +58,9 @@ export class User extends BaseEntity { }) is_admin!: boolean; + @Column("jsonb") + source!: APISource; + @Column("varchar") avatar!: string; @@ -124,6 +128,14 @@ export class User extends BaseEntity { user.avatar = data.avatar ?? config.defaults.avatar; user.header = data.header ?? config.defaults.avatar; + user.source = { + language: null, + note: "", + privacy: "public", + sensitive: false, + fields: [], + }; + user.followers = []; user.following = []; @@ -257,7 +269,6 @@ export class User extends BaseEntity { emojis: [], fields: [], limited: false, - source: undefined, statuses_count: 0, discoverable: undefined, role: undefined, diff --git a/index.ts b/index.ts index b7deb068..6f4185aa 100644 --- a/index.ts +++ b/index.ts @@ -21,8 +21,8 @@ if (!(await requests_log.exists())) { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); Bun.serve({ - port: config.http.port, - hostname: config.http.base_url || "0.0.0.0", // defaults to "0.0.0.0" + port: config.http.bind_port, + hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0" async fetch(req) { if (config.logging.log_requests_verbose) { await appendFile( diff --git a/server/api/[username]/actor/index.ts b/server/api/[username]/actor/index.ts index f5e8377c..052f2175 100644 --- a/server/api/[username]/actor/index.ts +++ b/server/api/[username]/actor/index.ts @@ -89,7 +89,7 @@ export default async ( Hashtag: "as:Hashtag", }, ], - id: `${config.http.base_url}:${config.http.port}/@${user.username}`, + id: `${config.http.base_url}/@${user.username}`, type: "Person", preferredUsername: user.username, // TODO: Add user display name name: user.username, @@ -104,21 +104,21 @@ export default async ( 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`, + inbox: `${config.http.base_url}/@${user.username}/inbox`, + outbox: `${config.http.base_url}/@${user.username}/outbox`, + followers: `${config.http.base_url}/@${user.username}/followers`, + following: `${config.http.base_url}/@${user.username}/following`, + liked: `${config.http.base_url}/@${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}/@${ + id: `${getHost()}${config.http.base_url}/@${ user.username }/actor#main-key`, - owner: `${config.http.base_url}:${config.http.port}/@${user.username}`, + owner: `${config.http.base_url}/@${user.username}`, // Split the public key into PEM format publicKeyPem: `-----BEGIN PUBLIC KEY-----\n${user.public_key .match(/.{1,64}/g) @@ -131,7 +131,7 @@ export default async ( // TODO: Add user attachments (I.E. profile metadata) ], endpoints: { - sharedInbox: `${config.http.base_url}:${config.http.port}/inbox`, + sharedInbox: `${config.http.base_url}/inbox`, }, }); }; diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts new file mode 100644 index 00000000..9f7f7460 --- /dev/null +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -0,0 +1,35 @@ +import { getUserByToken } from "@auth"; +import { errorResponse, jsonResponse } from "@response"; + +/** + * Patches a user + */ +export default async (req: Request): Promise => { + // Check if request is a PATCH request + if (req.method !== "GET") + return errorResponse("This method requires a GET request", 405); + + // Check auth token + const token = req.headers.get("Authorization")?.split(" ")[1] || null; + + if (!token) + return errorResponse("This method requires an authenticated user", 422); + + const user = await getUserByToken(token); + + if (!user) return errorResponse("Unauthorized", 401); + + // TODO: Add Source fields + return jsonResponse({ + ...(await user.toAPI()), + source: user.source, + // TODO: Add role support + role: { + id: 0, + name: "", + permissions: "", + color: "", + highlighted: false, + }, + }); +}; diff --git a/tests/actor.test.ts b/tests/actor.test.ts index 3bfd448d..c3d29c81 100644 --- a/tests/actor.test.ts +++ b/tests/actor.test.ts @@ -23,15 +23,12 @@ beforeAll(async () => { 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/actor`, - { - method: "GET", - headers: { - Accept: "application/activity+json", - }, - } - ); + const response = await fetch(`${config.http.base_url}/@test/actor`, { + method: "GET", + headers: { + Accept: "application/activity+json", + }, + }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe( @@ -41,26 +38,16 @@ describe("POST /@test/actor", () => { const actor: APActor = await response.json(); expect(actor.type).toBe("Person"); - expect(actor.id).toBe( - `${config.http.base_url}:${config.http.port}/@test` - ); + expect(actor.id).toBe(`${config.http.base_url}/@test`); expect(actor.preferredUsername).toBe("test"); - expect(actor.inbox).toBe( - `${config.http.base_url}:${config.http.port}/@test/inbox` - ); - expect(actor.outbox).toBe( - `${config.http.base_url}:${config.http.port}/@test/outbox` - ); - expect(actor.followers).toBe( - `${config.http.base_url}:${config.http.port}/@test/followers` - ); - expect(actor.following).toBe( - `${config.http.base_url}:${config.http.port}/@test/following` - ); + expect(actor.inbox).toBe(`${config.http.base_url}/@test/inbox`); + expect(actor.outbox).toBe(`${config.http.base_url}/@test/outbox`); + expect(actor.followers).toBe(`${config.http.base_url}/@test/followers`); + expect(actor.following).toBe(`${config.http.base_url}/@test/following`); expect((actor as any).publicKey).toBeDefined(); expect((actor as any).publicKey.id).toBeDefined(); expect((actor as any).publicKey.owner).toBe( - `${config.http.base_url}:${config.http.port}/@test` + `${config.http.base_url}/@test` ); expect((actor as any).publicKey.publicKeyPem).toBeDefined(); expect((actor as any).publicKey.publicKeyPem).toMatch( @@ -77,7 +64,7 @@ afterAll(async () => { const activities = await RawActivity.createQueryBuilder("activity") .where("activity.data->>'actor' = :actor", { - actor: `${config.http.base_url}:${config.http.port}/@test`, + actor: `${config.http.base_url}/@test`, }) .leftJoinAndSelect("activity.objects", "objects") .getMany(); diff --git a/tests/api.test.ts b/tests/api.test.ts index ef6bfc7d..2e0544ff 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -54,7 +54,7 @@ beforeAll(async () => { describe("POST /api/v1/accounts/:id", () => { 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`, + `${config.http.base_url}/api/v1/accounts/999999`, { method: "GET", headers: { @@ -72,7 +72,7 @@ describe("POST /api/v1/accounts/:id", () => { describe("POST /api/v1/statuses", () => { test("should create a new status and return an APIStatus object", async () => { const response = await fetch( - `${config.http.base_url}:${config.http.port}/api/v1/statuses`, + `${config.http.base_url}/api/v1/statuses`, { method: "POST", headers: { @@ -117,7 +117,7 @@ describe("POST /api/v1/statuses", () => { 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`, + `${config.http.base_url}/api/v1/accounts/update_credentials`, { method: "PATCH", headers: { @@ -139,6 +139,47 @@ describe("PATCH /api/v1/accounts/update_credentials", () => { }); }); +describe("GET /api/v1/accounts/verify_credentials", () => { + test("should return the authenticated user's account information", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/verify_credentials`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const account: APIAccount = await response.json(); + + expect(account.username).toBe(user.username); + expect(account.bot).toBe(false); + expect(account.locked).toBe(false); + expect(account.created_at).toBeDefined(); + expect(account.followers_count).toBe(0); + expect(account.following_count).toBe(0); + expect(account.statuses_count).toBe(0); + expect(account.note).toBe(""); + expect(account.url).toBe(`${config.http.base_url}/@${user.username}`); + expect(account.avatar).toBeDefined(); + expect(account.avatar_static).toBeDefined(); + expect(account.header).toBeDefined(); + expect(account.header_static).toBeDefined(); + expect(account.emojis).toEqual([]); + expect(account.fields).toEqual([]); + expect(account.source?.fields).toEqual([]); + expect(account.source?.privacy).toBe("public"); + expect(account.source?.language).toBeNull(); + expect(account.source?.note).toBe(""); + expect(account.source?.sensitive).toBe(false); + }); +}); + afterAll(async () => { const user = await User.findOneBy({ username: "test", @@ -146,7 +187,7 @@ afterAll(async () => { const activities = await RawActivity.createQueryBuilder("activity") .where("activity.data->>'actor' = :actor", { - actor: `${config.http.base_url}:${config.http.port}/@test`, + actor: `${config.http.base_url}/@test`, }) .leftJoinAndSelect("activity.objects", "objects") .getMany(); diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts index ad9d0ab8..09f56eb5 100644 --- a/tests/inbox.test.ts +++ b/tests/inbox.test.ts @@ -23,37 +23,34 @@ describe("POST /@test/inbox", () => { test("should store a new Note object", async () => { const activityId = `https://example.com/objects/${crypto.randomUUID()}`; - const response = await fetch( - `${config.http.base_url}:${config.http.port}/@test/inbox/`, - { - method: "POST", - headers: { - "Content-Type": "application/activity+json", + const response = await fetch(`${config.http.base_url}/@test/inbox/`, { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Create", + id: activityId, + actor: { + id: `${config.http.base_url}/@test`, + type: "Person", + preferredUsername: "test", }, - body: JSON.stringify({ + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [], + published: "2021-01-01T00:00:00.000Z", + object: { "@context": "https://www.w3.org/ns/activitystreams", - type: "Create", - id: activityId, - actor: { - id: `${config.http.base_url}:${config.http.port}/@test`, - type: "Person", - preferredUsername: "test", - }, - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [], + id: "https://example.com/notes/1", + type: "Note", + content: "Hello, world!", + summary: null, + inReplyTo: null, published: "2021-01-01T00:00:00.000Z", - object: { - "@context": "https://www.w3.org/ns/activitystreams", - id: "https://example.com/notes/1", - type: "Note", - content: "Hello, world!", - summary: null, - inReplyTo: null, - published: "2021-01-01T00:00:00.000Z", - }, - }), - } - ); + }, + }), + }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); @@ -85,37 +82,34 @@ describe("POST /@test/inbox", () => { test("should try to update that Note object", async () => { const activityId = `https://example.com/objects/${crypto.randomUUID()}`; - const response = await fetch( - `${config.http.base_url}:${config.http.port}/@test/inbox/`, - { - method: "POST", - headers: { - "Content-Type": "application/activity+json", + const response = await fetch(`${config.http.base_url}/@test/inbox/`, { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Update", + id: activityId, + actor: { + id: `${config.http.base_url}/@test`, + type: "Person", + preferredUsername: "test", }, - body: JSON.stringify({ + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [], + published: "2021-01-02T00:00:00.000Z", + object: { "@context": "https://www.w3.org/ns/activitystreams", - type: "Update", - id: activityId, - actor: { - id: `${config.http.base_url}:${config.http.port}/@test`, - type: "Person", - preferredUsername: "test", - }, - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [], - published: "2021-01-02T00:00:00.000Z", - object: { - "@context": "https://www.w3.org/ns/activitystreams", - id: "https://example.com/notes/1", - type: "Note", - content: "This note has been edited!", - summary: null, - inReplyTo: null, - published: "2021-01-01T00:00:00.000Z", - }, - }), - } - ); + id: "https://example.com/notes/1", + type: "Note", + content: "This note has been edited!", + summary: null, + inReplyTo: null, + published: "2021-01-01T00:00:00.000Z", + }, + }), + }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); @@ -146,37 +140,34 @@ describe("POST /@test/inbox", () => { test("should delete the Note object", async () => { const activityId = `https://example.com/objects/${crypto.randomUUID()}`; - const response = await fetch( - `${config.http.base_url}:${config.http.port}/@test/inbox/`, - { - method: "POST", - headers: { - "Content-Type": "application/activity+json", + const response = await fetch(`${config.http.base_url}/@test/inbox/`, { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Delete", + id: activityId, + actor: { + id: `${config.http.base_url}/@test`, + type: "Person", + preferredUsername: "test", }, - body: JSON.stringify({ + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [], + published: "2021-01-03T00:00:00.000Z", + object: { "@context": "https://www.w3.org/ns/activitystreams", - type: "Delete", - id: activityId, - actor: { - id: `${config.http.base_url}:${config.http.port}/@test`, - type: "Person", - preferredUsername: "test", - }, - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [], - published: "2021-01-03T00:00:00.000Z", - object: { - "@context": "https://www.w3.org/ns/activitystreams", - id: "https://example.com/notes/1", - type: "Note", - content: "This note has been edited!", - summary: null, - inReplyTo: null, - published: "2021-01-01T00:00:00.000Z", - }, - }), - } - ); + id: "https://example.com/notes/1", + type: "Note", + content: "This note has been edited!", + summary: null, + inReplyTo: null, + published: "2021-01-01T00:00:00.000Z", + }, + }), + }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); @@ -196,7 +187,7 @@ describe("POST /@test/inbox", () => { expect(activity?.actors).toHaveLength(1); expect(activity?.actors[0].data).toEqual({ preferredUsername: "test", - id: `${config.http.base_url}:${config.http.port}/@test`, + id: `${config.http.base_url}/@test`, type: "Person", }); @@ -211,33 +202,30 @@ describe("POST /@test/inbox", () => { test("should return a 404 error when trying to delete a non-existent Note object", async () => { const activityId = `https://example.com/objects/${crypto.randomUUID()}`; - const response = await fetch( - `${config.http.base_url}:${config.http.port}/@test/inbox/`, - { - method: "POST", - headers: { - "Content-Type": "application/activity+json", + const response = await fetch(`${config.http.base_url}/@test/inbox/`, { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Delete", + id: activityId, + actor: { + id: `${config.http.base_url}/@test`, + type: "Person", + preferredUsername: "test", }, - body: JSON.stringify({ + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [], + published: "2021-01-03T00:00:00.000Z", + object: { "@context": "https://www.w3.org/ns/activitystreams", - type: "Delete", - id: activityId, - actor: { - id: `${config.http.base_url}:${config.http.port}/@test`, - type: "Person", - preferredUsername: "test", - }, - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [], - published: "2021-01-03T00:00:00.000Z", - object: { - "@context": "https://www.w3.org/ns/activitystreams", - id: "https://example.com/notes/2345678909876543", - type: "Note", - }, - }), - } - ); + id: "https://example.com/notes/2345678909876543", + type: "Note", + }, + }), + }); expect(response.status).toBe(404); expect(response.headers.get("content-type")).toBe("application/json"); @@ -262,10 +250,10 @@ afterAll(async () => { .leftJoinAndSelect("activity.objects", "objects") .leftJoinAndSelect("activity.actors", "actors") // activity.actors is a many-to-many relationship with Actor objects (it is an array of Actor objects) - // Get the actors of the activity that have data.id as `${config.http.base_url}:${config.http.port}/@test` + // Get the actors of the activity that have data.id as `${config.http.base_url}/@test` .where("actors.data @> :data", { data: JSON.stringify({ - id: `${config.http.base_url}:${config.http.port}/@test`, + id: `${config.http.base_url}/@test`, }), }) .getMany(); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 6b0e7cfb..62cecb44 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -32,13 +32,10 @@ describe("POST /api/v1/apps/", () => { formData.append("website", "https://example.com"); formData.append("redirect_uris", "https://example.com"); formData.append("scopes", "read write"); - const response = await fetch( - `${config.http.base_url}:${config.http.port}/api/v1/apps/`, - { - method: "POST", - body: formData, - } - ); + const response = await fetch(`${config.http.base_url}/api/v1/apps/`, { + method: "POST", + body: formData, + }); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); @@ -70,7 +67,7 @@ describe("POST /auth/login/", () => { formData.append("username", "test"); formData.append("password", "test"); const response = await fetch( - `${config.http.base_url}:${config.http.port}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scopes=read+write`, + `${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scopes=read+write`, { method: "POST", body: formData, @@ -98,13 +95,10 @@ describe("POST /oauth/token/", () => { formData.append("client_secret", client_secret); formData.append("scope", "read write"); - const response = await fetch( - `${config.http.base_url}:${config.http.port}/oauth/token/`, - { - method: "POST", - body: formData, - } - ); + const response = await fetch(`${config.http.base_url}/oauth/token/`, { + method: "POST", + body: formData, + }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const json = await response.json(); @@ -126,7 +120,7 @@ describe("POST /oauth/token/", () => { describe("GET /api/v1/apps/verify_credentials", () => { test("should return the authenticated application's credentials", async () => { const response = await fetch( - `${config.http.base_url}:${config.http.port}/api/v1/apps/verify_credentials`, + `${config.http.base_url}/api/v1/apps/verify_credentials`, { method: "GET", headers: { diff --git a/utils/config.ts b/utils/config.ts index 6410fd05..3ebdd360 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -10,8 +10,9 @@ export interface ConfigType { }; http: { - port: number; base_url: string; + bind: string; + bind_port: string; }; validation: { @@ -68,8 +69,9 @@ export interface ConfigType { export const configDefaults: ConfigType = { http: { - port: 3000, - base_url: "http://0.0.0.0", + bind: "http://0.0.0.0", + bind_port: "8000", + base_url: "http://fediproject.localhost:8000", }, database: { host: "localhost",