mirror of
https://github.com/versia-pub/server.git
synced 2026-01-26 12:16:01 +01:00
Add new follow API endpoint
This commit is contained in:
parent
36b682d662
commit
a9688b8178
|
|
@ -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`
|
||||||
|
|
|
||||||
116
database/entities/Relationship.ts
Normal file
116
database/entities/Relationship.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
66
server/api/api/v1/accounts/[id]/follow.ts
Normal file
66
server/api/api/v1/accounts/[id]/follow.ts
Normal 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());
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue