Add new follow API endpoint

This commit is contained in:
Jesse Wierzbinski 2023-09-21 17:18:05 -10:00
parent 36b682d662
commit a9688b8178
7 changed files with 275 additions and 30 deletions

View file

@ -66,6 +66,7 @@ Working endpoints are:
- `/api/v1/accounts/:id` - `/api/v1/accounts/:id`
- `/api/v1/accounts/:id/statuses` - `/api/v1/accounts/:id/statuses`
- `/api/v1/accounts/:id/follow`
- `/api/v1/accounts/update_credentials` - `/api/v1/accounts/update_credentials`
- `/api/v1/accounts/verify_credentials` - `/api/v1/accounts/verify_credentials`
- `/api/v1/statuses` - `/api/v1/statuses`

View file

@ -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<APIRelationship> {
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,
};
}
}

View file

@ -7,6 +7,7 @@ import {
JoinTable, JoinTable,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from "typeorm"; } from "typeorm";
@ -17,6 +18,7 @@ import { RawObject } from "./RawObject";
import { Token } from "./Token"; import { Token } from "./Token";
import { Status } from "./Status"; import { Status } from "./Status";
import { APISource } from "~types/entities/source"; import { APISource } from "~types/entities/source";
import { Relationship } from "./Relationship";
const config = getConfig(); const config = getConfig();
@ -35,9 +37,7 @@ export class User extends BaseEntity {
}) })
username!: string; username!: string;
@Column("varchar", { @Column("varchar")
unique: true,
})
display_name!: string; display_name!: string;
@Column("varchar") @Column("varchar")
@ -79,17 +79,12 @@ export class User extends BaseEntity {
@Column("varchar") @Column("varchar")
private_key!: string; private_key!: string;
@OneToMany(() => Relationship, relationship => relationship.owner)
relationships!: Relationship[];
@ManyToOne(() => RawActor, actor => actor.id) @ManyToOne(() => RawActor, actor => actor.id)
actor!: RawActor; actor!: RawActor;
@ManyToMany(() => RawActor, actor => actor.id)
@JoinTable()
following!: RawActor[];
@ManyToMany(() => RawActor, actor => actor.id)
@JoinTable()
followers!: RawActor[];
@ManyToMany(() => RawObject, object => object.id) @ManyToMany(() => RawObject, object => object.id)
@JoinTable() @JoinTable()
pinned_notes!: RawObject[]; pinned_notes!: RawObject[];
@ -98,8 +93,7 @@ export class User extends BaseEntity {
return await User.createQueryBuilder("user") return await User.createQueryBuilder("user")
// Objects is a many-to-many relationship // Objects is a many-to-many relationship
.leftJoinAndSelect("user.actor", "actor") .leftJoinAndSelect("user.actor", "actor")
.leftJoinAndSelect("user.following", "following") .leftJoinAndSelect("user.relationships", "relationships")
.leftJoinAndSelect("user.followers", "followers")
.where("actor.data @> :data", { .where("actor.data @> :data", {
data: JSON.stringify({ data: JSON.stringify({
id, id,
@ -128,6 +122,8 @@ export class User extends BaseEntity {
user.avatar = data.avatar ?? config.defaults.avatar; user.avatar = data.avatar ?? config.defaults.avatar;
user.header = data.header ?? config.defaults.avatar; user.header = data.header ?? config.defaults.avatar;
user.relationships = [];
user.source = { user.source = {
language: null, language: null,
note: "", note: "",
@ -136,9 +132,6 @@ export class User extends BaseEntity {
fields: [], fields: [],
}; };
user.followers = [];
user.following = [];
await user.generateKeys(); await user.generateKeys();
await user.updateActor(); await user.updateActor();
@ -146,6 +139,22 @@ export class User extends BaseEntity {
return user; 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() { async selfDestruct() {
// Clean up tokens // Clean up tokens
const tokens = await Token.findBy({ 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(tokens.map(async token => await token.remove()));
await Promise.all(statuses.map(async status => await status.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() { async updateActor() {

View file

@ -115,7 +115,7 @@ export default async (
return actor; return actor;
} }
user.following.push(actor); // TODO: Add follower
await user.save(); await user.save();
} }
@ -142,9 +142,7 @@ export default async (
return actor; return actor;
} }
user.following = user.following.filter( // TODO: Remove follower
following => following.id !== actor.id
);
await user.save(); await user.save();
} }
@ -168,7 +166,7 @@ export default async (
return actor; return actor;
} }
user.followers.push(actor); // TODO: Add follower
await user.save(); await user.save();
break; break;

View file

@ -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<Response> => {
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());
};

View file

@ -8,12 +8,14 @@ import { RawActivity } from "~database/entities/RawActivity";
import { Token, TokenType } from "~database/entities/Token"; import { Token, TokenType } from "~database/entities/Token";
import { User } from "~database/entities/User"; import { User } from "~database/entities/User";
import { APIAccount } from "~types/entities/account"; import { APIAccount } from "~types/entities/account";
import { APIRelationship } from "~types/entities/relationship";
import { APIStatus } from "~types/entities/status"; import { APIStatus } from "~types/entities/status";
const config = getConfig(); const config = getConfig();
let token: Token; let token: Token;
let user: User; let user: User;
let user2: User;
beforeAll(async () => { beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize(); if (!AppDataSource.isInitialized) await AppDataSource.initialize();
@ -26,6 +28,14 @@ beforeAll(async () => {
display_name: "", display_name: "",
}); });
// Initialize second test user
user2 = await User.createNew({
email: "test2@test.com",
username: "test2",
password: "test2",
display_name: "",
});
const app = new Application(); const app = new Application();
app.name = "Test Application"; app.name = "Test Application";
@ -209,11 +219,31 @@ describe("GET /api/v1/accounts/:id/statuses", () => {
}); });
}); });
afterAll(async () => { describe("POST /api/v1/accounts/:id/follow", () => {
const user = await User.findOneBy({ test("should follow the specified user and return an APIRelationship object", async () => {
username: "test", 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") const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", { .where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}/@test`, actor: `${config.http.base_url}/@test`,
@ -231,8 +261,9 @@ afterAll(async () => {
}) })
); );
if (user) {
await user.selfDestruct(); await user.selfDestruct();
await user.remove(); await user.remove();
}
await user2.selfDestruct();
await user2.remove();
}); });

View file

@ -7,7 +7,11 @@ export const getUserByToken = async (access_token: string | null) => {
where: { where: {
access_token, access_token,
}, },
relations: ["user"], relations: {
user: {
relationships: true,
},
},
}); });
if (!token) return null; if (!token) return null;