diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 2b1c3db9..0952472f 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -17,6 +17,7 @@ import { Application } from "./Application"; import { Emoji } from "./Emoji"; import { RawActivity } from "./RawActivity"; import { RawObject } from "./RawObject"; +import { Instance } from "./Instance"; const config = getConfig(); @@ -91,10 +92,18 @@ export class Status extends BaseEntity { /** * The raw object that this status is a reply to, if any. */ - @ManyToOne(() => RawObject, { + @ManyToOne(() => Status, { nullable: true, }) - in_reply_to_post!: RawObject | null; + in_reply_to_post!: Status | null; + + /** + * The status' instance + */ + @ManyToOne(() => Instance, { + nullable: true, + }) + instance!: Instance | null; /** * The raw actor that this status is a reply to, if any. @@ -133,6 +142,13 @@ export class Status extends BaseEntity { @JoinTable() emojis!: Emoji[]; + /** + * The users mentioned (excluding followers and such) + */ + @ManyToMany(() => User, user => user.id) + @JoinTable() + mentions!: User[]; + /** * The activities that have liked this status. */ @@ -180,6 +196,106 @@ export class Status extends BaseEntity { return emojiObjects.filter(emoji => emoji !== null) as Emoji[]; } + /** + * Returns whether this status is viewable by a user. + * @param user The user to check. + * @returns Whether this status is viewable by the user. + */ + isViewableByUser(user: User | null) { + const relationship = user?.relationships.find( + rel => rel.id === this.account.id + ); + + if (this.visibility === "public") return true; + else if (this.visibility === "unlisted") return true; + else if (this.visibility === "private") { + return !!relationship?.following; + } else { + return user && this.mentions.includes(user); + } + } + + /** + * Return all the ancestors of this post, + */ + async getAncestors(fetcher: User | null) { + const max = fetcher ? 4096 : 40; + const ancestors = []; + + let id = this.in_reply_to_post?.id; + + while (ancestors.length < max && id) { + const currentStatus = await Status.findOne({ + where: { + id: id, + }, + relations: { + in_reply_to_post: true, + }, + }); + + if (currentStatus) { + if (currentStatus.isViewableByUser(fetcher)) { + ancestors.push(currentStatus); + } + id = currentStatus.in_reply_to_post?.id; + } else { + break; + } + } + + return ancestors; + } + + /** + * Return all the descendants of this post, + */ + async getDescendants(fetcher: User | null) { + const max = fetcher ? 4096 : 60; + // Go through all descendants in a tree-like manner + const descendants: Status[] = []; + + return await Status._getDescendants(this, fetcher, max, descendants); + } + + /** + * Return all the descendants of a post, + * @param status The status to get the descendants of. + * @param isAuthenticated Whether the user is authenticated. + * @param max The maximum number of descendants to get. + * @param descendants The descendants to add to. + * @returns A promise that resolves with the descendants. + * @private + */ + private static async _getDescendants( + status: Status, + fetcher: User | null, + max: number, + descendants: Status[] + ) { + const currentStatus = await Status.find({ + where: { + in_reply_to_post: { + id: status.id, + }, + }, + relations: { + in_reply_to_post: true, + }, + }); + + for (const status of currentStatus) { + if (status.isViewableByUser(fetcher)) { + descendants.push(status); + } + if (descendants.length < max) { + await this._getDescendants(status, fetcher, max, descendants); + } + } + + return descendants; + } + /** * Creates a new status and saves it to the database. * @param data The data for the new status. @@ -194,7 +310,7 @@ export class Status extends BaseEntity { spoiler_text: string; emojis: Emoji[]; reply?: { - object: RawObject; + status: Status; user: User; }; }) { @@ -211,11 +327,13 @@ export class Status extends BaseEntity { newStatus.announces = []; newStatus.isReblog = false; newStatus.announces = []; + newStatus.mentions = []; + newStatus.instance = data.account.instance; newStatus.object = new RawObject(); if (data.reply) { - newStatus.in_reply_to_post = data.reply.object; + newStatus.in_reply_to_post = data.reply.status; newStatus.in_reply_to_account = data.reply.user; } @@ -224,8 +342,8 @@ export class Status extends BaseEntity { type: "Note", summary: data.spoiler_text, content: data.content, - inReplyTo: data.reply?.object - ? data.reply.object.data.id + inReplyTo: data.reply?.status + ? data.reply.status.object.data.id : undefined, published: new Date().toISOString(), tag: [], @@ -265,6 +383,8 @@ export class Status extends BaseEntity { }, }); + newStatus.mentions.push(user as User); + return user?.actor.data.id; } else { const user = await User.findOne({ @@ -276,6 +396,8 @@ export class Status extends BaseEntity { }, }); + newStatus.mentions.push(user as User); + return user?.actor.data.id; } }) @@ -318,6 +440,9 @@ export class Status extends BaseEntity { * @returns A promise that resolves with the API status. */ async toAPI(): Promise { - return await this.object.toAPI(); + return { + ...(await this.object.toAPI()), + id: this.id, + }; } } diff --git a/server/api/api/v1/statuses/[id]/context.ts b/server/api/api/v1/statuses/[id]/context.ts index c6c31d80..52f63011 100644 --- a/server/api/api/v1/statuses/[id]/context.ts +++ b/server/api/api/v1/statuses/[id]/context.ts @@ -1,7 +1,6 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { RawObject } from "~database/entities/RawObject"; import { Status } from "~database/entities/Status"; import { User } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; @@ -31,9 +30,9 @@ export default async ( const { user } = await User.getFromRequest(req); - let foundStatus: RawObject | null; + let foundStatus: Status | null; try { - foundStatus = await RawObject.findOneBy({ + foundStatus = await Status.findOneBy({ id, }); } catch (e) { @@ -43,7 +42,13 @@ export default async ( if (!foundStatus) return errorResponse("Record not found", 404); // Get all ancestors - const ancestors = await foundStatus.getAncestors(); + const ancestors = await foundStatus.getAncestors(user); + const descendants = await foundStatus.getDescendants(user); - return jsonResponse({}); + return jsonResponse({ + ancestors: await Promise.all(ancestors.map(status => status.toAPI())), + descendants: await Promise.all( + descendants.map(status => status.toAPI()) + ), + }); }; diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index 07b8bf0a..46ca99da 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -1,7 +1,6 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { RawObject } from "~database/entities/RawObject"; import { Status } from "~database/entities/Status"; import { User } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; @@ -30,10 +29,13 @@ export default async ( const { user } = await User.getFromRequest(req); - let foundStatus: RawObject | null; + let foundStatus: Status | null; try { - foundStatus = await RawObject.findOneBy({ - id, + foundStatus = await Status.findOne({ + where: { + id, + }, + relations: ["account", "object"], }); } catch (e) { return errorResponse("Invalid ID", 404); @@ -42,38 +44,27 @@ export default async ( if (!foundStatus) return errorResponse("Record not found", 404); // Check if user is authorized to view this status (if it's private) - if ( - (await foundStatus.toAPI()).visibility === "private" && - (await foundStatus.toAPI()).account.id !== user?.id - ) { + if (!foundStatus.isViewableByUser(user)) { return errorResponse("Record not found", 404); } if (req.method === "GET") { return jsonResponse(await foundStatus.toAPI()); } else if (req.method === "DELETE") { - if ((await foundStatus.toAPI()).account.id !== user?.id) { + if (foundStatus.account.id !== user?.id) { return errorResponse("Unauthorized", 401); } // TODO: Implement delete and redraft functionality // Get associated Status object - const status = await Status.createQueryBuilder("status") - .leftJoinAndSelect("status.object", "object") - .where("object.id = :id", { id: foundStatus.id }) - .getOne(); - - if (!status) { - return errorResponse("Status not found", 404); - } // Delete status and all associated objects - await status.object.remove(); + await foundStatus.remove(); return jsonResponse( { - ...(await status.toAPI()), + ...(await foundStatus.toAPI()), // TODO: Add // text: Add source text // poll: Add source poll diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 68c23a3e..276bda4c 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -124,19 +124,20 @@ export default async (req: Request): Promise => { } // Get reply account and status if exists - let replyObject: RawObject | null = null; + let replyStatus: Status | null = null; let replyUser: User | null = null; if (in_reply_to_id) { - replyObject = await RawObject.findOne({ + replyStatus = await Status.findOne({ where: { id: in_reply_to_id, }, + relations: { + account: true, + }, }); - replyUser = await User.getByActorId( - (replyObject?.data.attributedTo as APActor).id ?? "" - ); + replyUser = replyStatus?.account || null; } // Check if status body doesnt match filters @@ -160,10 +161,10 @@ export default async (req: Request): Promise => { spoiler_text: spoiler_text || "", emojis: [], reply: - replyObject && replyUser + replyStatus && replyUser ? { user: replyUser, - object: replyObject, + status: replyStatus, } : undefined, }); diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index 2a7a9e01..67bcb06e 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -1,7 +1,10 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { RawObject } from "~database/entities/RawObject"; +import { FindManyOptions } from "typeorm"; +import { Status } from "~database/entities/Status"; +import { User } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -32,49 +35,83 @@ export default async (req: Request): Promise => { limit?: number; }>(req); + const { user } = await User.getFromRequest(req); + if (limit < 1 || limit > 40) { return errorResponse("Limit must be between 1 and 40", 400); } - let query = RawObject.createQueryBuilder("object") - .where("object.data->>'type' = 'Note'") - // From a user followed by the current user - .andWhere("CAST(object.data->>'to' AS jsonb) @> CAST(:to AS jsonb)", { - to: JSON.stringify([ - "https://www.w3.org/ns/activitystreams#Public", - ]), - }) - .orderBy("object.data->>'published'", "DESC") - .take(limit); + let query: FindManyOptions = { + where: { + visibility: "public", + account: [ + { + relationships: { + id: user?.id, + followed_by: true, + }, + }, + { + id: user?.id, + }, + ], + }, + order: { + created_at: "DESC", + }, + take: limit, + relations: ["object"], + }; if (max_id) { - const maxPost = await RawObject.findOneBy({ id: max_id }); + const maxPost = await Status.findOneBy({ id: max_id }); if (maxPost) { - query = query.andWhere("object.data->>'published' < :max_date", { - max_date: maxPost.data.published, - }); + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $lt: maxPost.created_at, + }, + }, + }; } } if (min_id) { - const minPost = await RawObject.findOneBy({ id: min_id }); + const minPost = await Status.findOneBy({ id: min_id }); if (minPost) { - query = query.andWhere("object.data->>'published' > :min_date", { - min_date: minPost.data.published, - }); + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gt: minPost.created_at, + }, + }, + }; } } if (since_id) { - const sincePost = await RawObject.findOneBy({ id: since_id }); + const sincePost = await Status.findOneBy({ id: since_id }); if (sincePost) { - query = query.andWhere("object.data->>'published' >= :since_date", { - since_date: sincePost.data.published, - }); + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gte: sincePost.created_at, + }, + }, + }; } } - const objects = await query.getMany(); + const objects = await Status.find(query); return jsonResponse( await Promise.all(objects.map(async object => await object.toAPI())) diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index 4474bf47..b3fbdbd9 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,7 +1,9 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { RawObject } from "~database/entities/RawObject"; +import { FindManyOptions, IsNull, Not } from "typeorm"; +import { Status } from "~database/entities/Status"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -42,60 +44,94 @@ export default async (req: Request): Promise => { return errorResponse("Limit must be between 1 and 40", 400); } - let query = RawObject.createQueryBuilder("object") - .where("object.data->>'type' = 'Note'") - .andWhere("CAST(object.data->>'to' AS jsonb) @> CAST(:to AS jsonb)", { - to: JSON.stringify([ - "https://www.w3.org/ns/activitystreams#Public", - ]), - }) - .orderBy("object.data->>'published'", "DESC") - .take(limit); + if (local && remote) { + return errorResponse("Cannot use both local and remote", 400); + } + + let query: FindManyOptions = { + where: { + visibility: "public", + }, + order: { + created_at: "DESC", + }, + take: limit, + relations: ["object"], + }; if (max_id) { - const maxPost = await RawObject.findOneBy({ id: max_id }); + const maxPost = await Status.findOneBy({ id: max_id }); if (maxPost) { - query = query.andWhere("object.data->>'published' < :max_date", { - max_date: maxPost.data.published, - }); + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $lt: maxPost.created_at, + }, + }, + }; } } if (min_id) { - const minPost = await RawObject.findOneBy({ id: min_id }); + const minPost = await Status.findOneBy({ id: min_id }); if (minPost) { - query = query.andWhere("object.data->>'published' > :min_date", { - min_date: minPost.data.published, - }); + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gt: minPost.created_at, + }, + }, + }; } } if (since_id) { - const sincePost = await RawObject.findOneBy({ id: since_id }); + const sincePost = await Status.findOneBy({ id: since_id }); if (sincePost) { - query = query.andWhere("object.data->>'published' >= :since_date", { - since_date: sincePost.data.published, - }); + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gte: sincePost.created_at, + }, + }, + }; } } if (only_media) { - query = query.andWhere("object.data->'attachment' IS NOT NULL"); + // TODO: add } if (local) { - query = query.andWhere("object.data->>'actor' LIKE :actor", { - actor: `%${new URL(req.url).hostname}%`, - }); + query = { + ...query, + where: { + ...query.where, + instance: IsNull(), + }, + }; } if (remote) { - query = query.andWhere("object.data->>'actor' NOT LIKE :actor", { - actor: `%${new URL(req.url).hostname}%`, - }); + query = { + ...query, + where: { + ...query.where, + instance: Not(IsNull()), + }, + }; } - const objects = await query.getMany(); + const objects = await Status.find(query); return jsonResponse( await Promise.all(objects.map(async object => await object.toAPI()))