From 440e99457681ecb20a1a5802155404c9b899c8c2 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 28 Nov 2023 12:57:48 -1000 Subject: [PATCH] Fix timeline rendering --- database/entities/Relationship.ts | 27 ++++++ database/entities/Status.ts | 16 ++-- server/api/api/v1/accounts/search/index.ts | 72 ++++++++++++++++ .../follow_requests/[account_id]/authorize.ts | 79 ++++++++++++++++++ .../v1/follow_requests/[account_id]/reject.ts | 67 +++++++++++++++ server/api/api/v1/follow_requests/index.ts | 82 +++++++++++++++++++ server/api/api/v1/timelines/home.ts | 36 +++++--- 7 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 server/api/api/v1/accounts/search/index.ts create mode 100644 server/api/api/v1/follow_requests/[account_id]/authorize.ts create mode 100644 server/api/api/v1/follow_requests/[account_id]/reject.ts create mode 100644 server/api/api/v1/follow_requests/index.ts diff --git a/database/entities/Relationship.ts b/database/entities/Relationship.ts index 5f290c5d..d9d8b384 100644 --- a/database/entities/Relationship.ts +++ b/database/entities/Relationship.ts @@ -37,6 +37,33 @@ export const createNewRelationship = async ( }); }; +export const checkForBidirectionalRelationships = async ( + user1: User, + user2: User, + createIfNotExists = true +): Promise => { + const relationship1 = await client.relationship.findFirst({ + where: { + ownerId: user1.id, + subjectId: user2.id, + }, + }); + + const relationship2 = await client.relationship.findFirst({ + where: { + ownerId: user2.id, + subjectId: user1.id, + }, + }); + + if (!relationship1 && !relationship2 && createIfNotExists) { + await createNewRelationship(user1, user2); + await createNewRelationship(user2, user1); + } + + return !!relationship1 && !!relationship2; +}; + /** * Converts the relationship to an API-friendly format. * @returns The API-friendly relationship. diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 7731c7f3..6e360a87 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -55,7 +55,9 @@ export const statusAndUserRelations: Prisma.StatusInclude = { }, attachments: true, instance: true, - mentions: true, + mentions: { + include: userRelations, + }, pinnedBy: true, _count: { select: { @@ -307,12 +309,9 @@ export const createNewStatus = async (data: { }; quote?: Status; }) => { - // Get people mentioned in the content - const mentionedPeople = [...data.content.matchAll(/@([a-zA-Z0-9_]+)/g)].map( - match => { - return `${config.http.base_url}/users/${match[1]}`; - } - ); + // Get people mentioned in the content (match @username or @username@domain.com mentions) + const mentionedPeople = + data.content.match(/@[a-zA-Z0-9_]+(@[a-zA-Z0-9_]+)?/g) ?? []; let mentions = data.mentions || []; @@ -438,7 +437,8 @@ export const statusToAPI = async ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition favourites_count: (status.likes ?? []).length, media_attachments: [], - mentions: [], + // @ts-expect-error Prisma TypeScript types dont include relations + mentions: status.mentions.map(mention => userToAPI(mention)), language: null, muted: user ? user.relationships.find(r => r.subjectId == status.authorId) diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts new file mode 100644 index 00000000..5ec36528 --- /dev/null +++ b/server/api/api/v1/accounts/search/index.ts @@ -0,0 +1,72 @@ +import { errorResponse, jsonResponse } from "@response"; +import { + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; +import { applyConfig } from "@api"; +import { parseRequest } from "@request"; +import { client } from "~database/datasource"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/accounts/search", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export default async (req: Request): Promise => { + // TODO: Add checks for disabled or not email verified accounts + + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + const { + following = false, + limit = 40, + offset, + q, + } = await parseRequest<{ + q?: string; + limit?: number; + offset?: number; + resolve?: boolean; + following?: boolean; + }>(req); + + if (limit < 1 || limit > 80) { + return errorResponse("Limit must be between 1 and 80", 400); + } + + // TODO: Add WebFinger resolve + + const accounts = await client.user.findMany({ + where: { + displayName: { + contains: q, + }, + username: { + contains: q, + }, + relationshipSubjects: following + ? { + some: { + ownerId: user.id, + following, + }, + } + : undefined, + }, + take: Number(limit), + skip: Number(offset || 0), + include: userRelations, + }); + + return jsonResponse(accounts.map(acct => userToAPI(acct))); +}; diff --git a/server/api/api/v1/follow_requests/[account_id]/authorize.ts b/server/api/api/v1/follow_requests/[account_id]/authorize.ts new file mode 100644 index 00000000..074aae92 --- /dev/null +++ b/server/api/api/v1/follow_requests/[account_id]/authorize.ts @@ -0,0 +1,79 @@ +import { errorResponse, jsonResponse } from "@response"; +import { getFromRequest, userRelations } from "~database/entities/User"; +import { applyConfig } from "@api"; +import { client } from "~database/datasource"; +import type { MatchedRoute } from "bun"; +import { + checkForBidirectionalRelationships, + relationshipToAPI, +} from "~database/entities/Relationship"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/follow_requests/:account_id/authorize", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + const { account_id } = matchedRoute.params; + + const account = await client.user.findUnique({ + where: { + id: account_id, + }, + include: userRelations, + }); + + if (!account) return errorResponse("Account not found", 404); + + // Check if there is a relationship on both sides + await checkForBidirectionalRelationships(user, account); + + // Authorize follow request + await client.relationship.updateMany({ + where: { + subjectId: user.id, + ownerId: account.id, + requested: true, + }, + data: { + requested: false, + following: true, + }, + }); + + // Update followedBy for other user + await client.relationship.updateMany({ + where: { + subjectId: account.id, + ownerId: user.id, + }, + data: { + followedBy: true, + }, + }); + + const relationship = await client.relationship.findFirst({ + where: { + subjectId: account.id, + ownerId: user.id, + }, + }); + + if (!relationship) return errorResponse("Relationship not found", 404); + + return jsonResponse(relationshipToAPI(relationship)); +}; diff --git a/server/api/api/v1/follow_requests/[account_id]/reject.ts b/server/api/api/v1/follow_requests/[account_id]/reject.ts new file mode 100644 index 00000000..6a667e79 --- /dev/null +++ b/server/api/api/v1/follow_requests/[account_id]/reject.ts @@ -0,0 +1,67 @@ +import { errorResponse, jsonResponse } from "@response"; +import { getFromRequest, userRelations } from "~database/entities/User"; +import { applyConfig } from "@api"; +import { client } from "~database/datasource"; +import type { MatchedRoute } from "bun"; +import { + checkForBidirectionalRelationships, + relationshipToAPI, +} from "~database/entities/Relationship"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/follow_requests/:account_id/reject", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const { user } = await getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + const { account_id } = matchedRoute.params; + + const account = await client.user.findUnique({ + where: { + id: account_id, + }, + include: userRelations, + }); + + if (!account) return errorResponse("Account not found", 404); + + // Check if there is a relationship on both sides + await checkForBidirectionalRelationships(user, account); + + // Reject follow request + await client.relationship.updateMany({ + where: { + subjectId: user.id, + ownerId: account.id, + requested: true, + }, + data: { + requested: false, + }, + }); + + const relationship = await client.relationship.findFirst({ + where: { + subjectId: account.id, + ownerId: user.id, + }, + }); + + if (!relationship) return errorResponse("Relationship not found", 404); + + return jsonResponse(relationshipToAPI(relationship)); +}; diff --git a/server/api/api/v1/follow_requests/index.ts b/server/api/api/v1/follow_requests/index.ts new file mode 100644 index 00000000..d465c61d --- /dev/null +++ b/server/api/api/v1/follow_requests/index.ts @@ -0,0 +1,82 @@ +import { errorResponse, jsonResponse } from "@response"; +import { + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; +import { applyConfig } from "@api"; +import { client } from "~database/datasource"; +import { parseRequest } from "@request"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/follow_requests", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export default async (req: Request): Promise => { + const { user } = await getFromRequest(req); + + const { + limit = 20, + max_id, + min_id, + since_id, + } = await parseRequest<{ + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; + }>(req); + + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } + + if (!user) return errorResponse("Unauthorized", 401); + + const objects = await client.user.findMany({ + where: { + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + relationships: { + some: { + subjectId: user.id, + requested: true, + }, + }, + }, + include: userRelations, + take: limit, + orderBy: { + id: "desc", + }, + }); + + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects.at(-1)?.id}>; rel="next"`, + `<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"` + ); + } + + return jsonResponse( + objects.map(user => userToAPI(user)), + 200, + { + Link: linkHeader.join(", "), + } + ); +}; diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index ea54886c..cb1374c4 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -50,21 +50,33 @@ export default async (req: Request): Promise => { gte: since_id ?? undefined, gt: min_id ?? undefined, }, - author: { - OR: [ - { - relationships: { - some: { - subjectId: user.id, - following: true, + OR: [ + { + author: { + OR: [ + { + relationshipSubjects: { + some: { + ownerId: user.id, + following: true, + }, + }, }, + { + id: user.id, + }, + ], + }, + }, + { + // Include posts where the user is mentioned in addition to posts by followed users + mentions: { + some: { + id: user.id, }, }, - { - id: user.id, - }, - ], - }, + }, + ], }, include: statusAndUserRelations, take: limit,