From a9688b8178dd057c61733ff21802d952e917021f Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 21 Sep 2023 17:18:05 -1000 Subject: [PATCH] Add new follow API endpoint --- README.md | 1 + database/entities/Relationship.ts | 116 ++++++++++++++++++++++ database/entities/User.ts | 61 +++++++++--- server/api/[username]/inbox/index.ts | 8 +- server/api/api/v1/accounts/[id]/follow.ts | 66 ++++++++++++ tests/api.test.ts | 47 +++++++-- utils/auth.ts | 6 +- 7 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 database/entities/Relationship.ts create mode 100644 server/api/api/v1/accounts/[id]/follow.ts diff --git a/README.md b/README.md index 818dfe08..353039da 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Working endpoints are: - `/api/v1/accounts/:id` - `/api/v1/accounts/:id/statuses` +- `/api/v1/accounts/:id/follow` - `/api/v1/accounts/update_credentials` - `/api/v1/accounts/verify_credentials` - `/api/v1/statuses` diff --git a/database/entities/Relationship.ts b/database/entities/Relationship.ts new file mode 100644 index 00000000..9f366ea4 --- /dev/null +++ b/database/entities/Relationship.ts @@ -0,0 +1,116 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "./User"; +import { APIRelationship } from "~types/entities/relationship"; + +/** + * Stores Mastodon API relationships + */ +@Entity({ + name: "relationships", +}) +export class Relationship extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @ManyToOne(() => User, user => user.relationships) + owner!: User; + + @ManyToOne(() => User) + subject!: User; + + @Column("varchar") + following!: boolean; + + @Column("varchar") + showing_reblogs!: boolean; + + @Column("varchar") + notifying!: boolean; + + @Column("varchar") + followed_by!: boolean; + + @Column("varchar") + blocking!: boolean; + + @Column("varchar") + blocked_by!: boolean; + + @Column("varchar") + muting!: boolean; + + @Column("varchar") + muting_notifications!: boolean; + + @Column("varchar") + requested!: boolean; + + @Column("varchar") + domain_blocking!: boolean; + + @Column("varchar") + endorsed!: boolean; + + @Column("jsonb") + languages!: string[]; + + @Column("varchar") + note!: string; + + @CreateDateColumn() + created_at!: Date; + + @UpdateDateColumn() + updated_at!: Date; + + static async createNew(owner: User, other: User) { + const newRela = new Relationship(); + newRela.owner = owner; + newRela.subject = other; + newRela.languages = []; + newRela.following = false; + newRela.showing_reblogs = false; + newRela.notifying = false; + newRela.followed_by = false; + newRela.blocking = false; + newRela.blocked_by = false; + newRela.muting = false; + newRela.muting_notifications = false; + newRela.requested = false; + newRela.domain_blocking = false; + newRela.endorsed = false; + newRela.note = ""; + + await newRela.save(); + + return newRela; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async toAPI(): Promise { + return { + blocked_by: this.blocked_by, + blocking: this.blocking, + domain_blocking: this.domain_blocking, + endorsed: this.endorsed, + followed_by: this.followed_by, + following: this.following, + id: this.subject.id, + muting: this.muting, + muting_notifications: this.muting_notifications, + notifying: this.notifying, + requested: this.requested, + showing_reblogs: this.showing_reblogs, + languages: this.languages, + note: this.note, + }; + } +} diff --git a/database/entities/User.ts b/database/entities/User.ts index 89bdac71..ae88d6c2 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -7,6 +7,7 @@ import { JoinTable, ManyToMany, ManyToOne, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; @@ -17,6 +18,7 @@ import { RawObject } from "./RawObject"; import { Token } from "./Token"; import { Status } from "./Status"; import { APISource } from "~types/entities/source"; +import { Relationship } from "./Relationship"; const config = getConfig(); @@ -35,9 +37,7 @@ export class User extends BaseEntity { }) username!: string; - @Column("varchar", { - unique: true, - }) + @Column("varchar") display_name!: string; @Column("varchar") @@ -79,17 +79,12 @@ export class User extends BaseEntity { @Column("varchar") private_key!: string; + @OneToMany(() => Relationship, relationship => relationship.owner) + relationships!: Relationship[]; + @ManyToOne(() => RawActor, actor => actor.id) actor!: RawActor; - @ManyToMany(() => RawActor, actor => actor.id) - @JoinTable() - following!: RawActor[]; - - @ManyToMany(() => RawActor, actor => actor.id) - @JoinTable() - followers!: RawActor[]; - @ManyToMany(() => RawObject, object => object.id) @JoinTable() pinned_notes!: RawObject[]; @@ -98,8 +93,7 @@ export class User extends BaseEntity { return await User.createQueryBuilder("user") // Objects is a many-to-many relationship .leftJoinAndSelect("user.actor", "actor") - .leftJoinAndSelect("user.following", "following") - .leftJoinAndSelect("user.followers", "followers") + .leftJoinAndSelect("user.relationships", "relationships") .where("actor.data @> :data", { data: JSON.stringify({ id, @@ -128,6 +122,8 @@ export class User extends BaseEntity { user.avatar = data.avatar ?? config.defaults.avatar; user.header = data.header ?? config.defaults.avatar; + user.relationships = []; + user.source = { language: null, note: "", @@ -136,9 +132,6 @@ export class User extends BaseEntity { fields: [], }; - user.followers = []; - user.following = []; - await user.generateKeys(); await user.updateActor(); @@ -146,6 +139,22 @@ export class User extends BaseEntity { return user; } + async getRelationshipToOtherUser(other: User) { + const relationship = await Relationship.findOne({ + where: { + owner: { + id: this.id, + }, + subject: { + id: other.id, + }, + }, + relations: ["owner", "subject"], + }); + + return relationship; + } + async selfDestruct() { // Clean up tokens const tokens = await Token.findBy({ @@ -164,6 +173,26 @@ export class User extends BaseEntity { await Promise.all(tokens.map(async token => await token.remove())); await Promise.all(statuses.map(async status => await status.remove())); + + // Get relationships + const relationships = await this.getRelationships(); + // Delete them all + await Promise.all( + relationships.map(async relationship => await relationship.remove()) + ); + } + + async getRelationships() { + const relationships = await Relationship.find({ + where: { + owner: { + id: this.id, + }, + }, + relations: ["subject"], + }); + + return relationships; } async updateActor() { diff --git a/server/api/[username]/inbox/index.ts b/server/api/[username]/inbox/index.ts index 54febea9..977a0570 100644 --- a/server/api/[username]/inbox/index.ts +++ b/server/api/[username]/inbox/index.ts @@ -115,7 +115,7 @@ export default async ( return actor; } - user.following.push(actor); + // TODO: Add follower await user.save(); } @@ -142,9 +142,7 @@ export default async ( return actor; } - user.following = user.following.filter( - following => following.id !== actor.id - ); + // TODO: Remove follower await user.save(); } @@ -168,7 +166,7 @@ export default async ( return actor; } - user.followers.push(actor); + // TODO: Add follower await user.save(); break; diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts new file mode 100644 index 00000000..bf1caf69 --- /dev/null +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -0,0 +1,66 @@ +import { getUserByToken } from "@auth"; +import { parseRequest } from "@request"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { Relationship } from "~database/entities/Relationship"; +import { User } from "~database/entities/User"; + +/** + * Follow 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; + + if (!token) + return errorResponse("This method requires an authenticated user", 422); + + const self = await getUserByToken(token); + + if (!self) return errorResponse("Unauthorized", 401); + + const { languages, notify, reblogs } = await parseRequest<{ + reblogs?: boolean; + notify?: boolean; + languages?: string[]; + }>(req); + + const user = await User.findOneBy({ + id, + }); + + if (!user) return errorResponse("User not found", 404); + + // Check if already following + let relationship = await self.getRelationshipToOtherUser(user); + + if (!relationship) { + // Create new relationship + + const newRelationship = await Relationship.createNew(self, user); + + self.relationships.push(newRelationship); + await self.save(); + + relationship = newRelationship; + } + + if (!relationship.following) { + relationship.following = true; + } + if (reblogs) { + relationship.showing_reblogs = true; + } + if (notify) { + relationship.notifying = true; + } + if (languages) { + relationship.languages = languages; + } + return jsonResponse(await relationship.toAPI()); +}; diff --git a/tests/api.test.ts b/tests/api.test.ts index 20a84af7..8e8c8a70 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -8,12 +8,14 @@ import { RawActivity } from "~database/entities/RawActivity"; import { Token, TokenType } from "~database/entities/Token"; import { User } from "~database/entities/User"; import { APIAccount } from "~types/entities/account"; +import { APIRelationship } from "~types/entities/relationship"; import { APIStatus } from "~types/entities/status"; const config = getConfig(); let token: Token; let user: User; +let user2: User; beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); @@ -26,6 +28,14 @@ beforeAll(async () => { display_name: "", }); + // Initialize second test user + user2 = await User.createNew({ + email: "test2@test.com", + username: "test2", + password: "test2", + display_name: "", + }); + const app = new Application(); app.name = "Test Application"; @@ -209,11 +219,31 @@ describe("GET /api/v1/accounts/:id/statuses", () => { }); }); -afterAll(async () => { - const user = await User.findOneBy({ - username: "test", - }); +describe("POST /api/v1/accounts/:id/follow", () => { + test("should follow the specified user and return an APIRelationship object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/follow`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const account: APIRelationship = await response.json(); + + expect(account.id).toBe(user2.id); + expect(account.following).toBe(true); + }); +}); + +afterAll(async () => { const activities = await RawActivity.createQueryBuilder("activity") .where("activity.data->>'actor' = :actor", { actor: `${config.http.base_url}/@test`, @@ -231,8 +261,9 @@ afterAll(async () => { }) ); - if (user) { - await user.selfDestruct(); - await user.remove(); - } + await user.selfDestruct(); + await user.remove(); + + await user2.selfDestruct(); + await user2.remove(); }); diff --git a/utils/auth.ts b/utils/auth.ts index 5010d3b5..9afc53da 100644 --- a/utils/auth.ts +++ b/utils/auth.ts @@ -7,7 +7,11 @@ export const getUserByToken = async (access_token: string | null) => { where: { access_token, }, - relations: ["user"], + relations: { + user: { + relationships: true, + }, + }, }); if (!token) return null;