Clean up timeline code, add new Context API endpoint

This commit is contained in:
Jesse Wierzbinski 2023-10-22 15:32:01 -10:00
parent ace9f97275
commit 932fc3e4f5
No known key found for this signature in database
GPG key ID: F9A1E418934E40B0
6 changed files with 287 additions and 92 deletions

View file

@ -17,6 +17,7 @@ import { Application } from "./Application";
import { Emoji } from "./Emoji"; import { Emoji } from "./Emoji";
import { RawActivity } from "./RawActivity"; import { RawActivity } from "./RawActivity";
import { RawObject } from "./RawObject"; import { RawObject } from "./RawObject";
import { Instance } from "./Instance";
const config = getConfig(); const config = getConfig();
@ -91,10 +92,18 @@ export class Status extends BaseEntity {
/** /**
* The raw object that this status is a reply to, if any. * The raw object that this status is a reply to, if any.
*/ */
@ManyToOne(() => RawObject, { @ManyToOne(() => Status, {
nullable: true, 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. * The raw actor that this status is a reply to, if any.
@ -133,6 +142,13 @@ export class Status extends BaseEntity {
@JoinTable() @JoinTable()
emojis!: Emoji[]; emojis!: Emoji[];
/**
* The users mentioned (excluding followers and such)
*/
@ManyToMany(() => User, user => user.id)
@JoinTable()
mentions!: User[];
/** /**
* The activities that have liked this status. * The activities that have liked this status.
*/ */
@ -180,6 +196,106 @@ export class Status extends BaseEntity {
return emojiObjects.filter(emoji => emoji !== null) as Emoji[]; 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. * Creates a new status and saves it to the database.
* @param data The data for the new status. * @param data The data for the new status.
@ -194,7 +310,7 @@ export class Status extends BaseEntity {
spoiler_text: string; spoiler_text: string;
emojis: Emoji[]; emojis: Emoji[];
reply?: { reply?: {
object: RawObject; status: Status;
user: User; user: User;
}; };
}) { }) {
@ -211,11 +327,13 @@ export class Status extends BaseEntity {
newStatus.announces = []; newStatus.announces = [];
newStatus.isReblog = false; newStatus.isReblog = false;
newStatus.announces = []; newStatus.announces = [];
newStatus.mentions = [];
newStatus.instance = data.account.instance;
newStatus.object = new RawObject(); newStatus.object = new RawObject();
if (data.reply) { 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; newStatus.in_reply_to_account = data.reply.user;
} }
@ -224,8 +342,8 @@ export class Status extends BaseEntity {
type: "Note", type: "Note",
summary: data.spoiler_text, summary: data.spoiler_text,
content: data.content, content: data.content,
inReplyTo: data.reply?.object inReplyTo: data.reply?.status
? data.reply.object.data.id ? data.reply.status.object.data.id
: undefined, : undefined,
published: new Date().toISOString(), published: new Date().toISOString(),
tag: [], tag: [],
@ -265,6 +383,8 @@ export class Status extends BaseEntity {
}, },
}); });
newStatus.mentions.push(user as User);
return user?.actor.data.id; return user?.actor.data.id;
} else { } else {
const user = await User.findOne({ 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; return user?.actor.data.id;
} }
}) })
@ -318,6 +440,9 @@ export class Status extends BaseEntity {
* @returns A promise that resolves with the API status. * @returns A promise that resolves with the API status.
*/ */
async toAPI(): Promise<APIStatus> { async toAPI(): Promise<APIStatus> {
return await this.object.toAPI(); return {
...(await this.object.toAPI()),
id: this.id,
};
} }
} }

View file

@ -1,7 +1,6 @@
import { applyConfig } from "@api"; import { applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun"; import { MatchedRoute } from "bun";
import { RawObject } from "~database/entities/RawObject";
import { Status } from "~database/entities/Status"; import { Status } from "~database/entities/Status";
import { User } from "~database/entities/User"; import { User } from "~database/entities/User";
import { APIRouteMeta } from "~types/api"; import { APIRouteMeta } from "~types/api";
@ -31,9 +30,9 @@ export default async (
const { user } = await User.getFromRequest(req); const { user } = await User.getFromRequest(req);
let foundStatus: RawObject | null; let foundStatus: Status | null;
try { try {
foundStatus = await RawObject.findOneBy({ foundStatus = await Status.findOneBy({
id, id,
}); });
} catch (e) { } catch (e) {
@ -43,7 +42,13 @@ export default async (
if (!foundStatus) return errorResponse("Record not found", 404); if (!foundStatus) return errorResponse("Record not found", 404);
// Get all ancestors // 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())
),
});
}; };

View file

@ -1,7 +1,6 @@
import { applyConfig } from "@api"; import { applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun"; import { MatchedRoute } from "bun";
import { RawObject } from "~database/entities/RawObject";
import { Status } from "~database/entities/Status"; import { Status } from "~database/entities/Status";
import { User } from "~database/entities/User"; import { User } from "~database/entities/User";
import { APIRouteMeta } from "~types/api"; import { APIRouteMeta } from "~types/api";
@ -30,10 +29,13 @@ export default async (
const { user } = await User.getFromRequest(req); const { user } = await User.getFromRequest(req);
let foundStatus: RawObject | null; let foundStatus: Status | null;
try { try {
foundStatus = await RawObject.findOneBy({ foundStatus = await Status.findOne({
where: {
id, id,
},
relations: ["account", "object"],
}); });
} catch (e) { } catch (e) {
return errorResponse("Invalid ID", 404); return errorResponse("Invalid ID", 404);
@ -42,38 +44,27 @@ export default async (
if (!foundStatus) return errorResponse("Record not found", 404); if (!foundStatus) return errorResponse("Record not found", 404);
// Check if user is authorized to view this status (if it's private) // Check if user is authorized to view this status (if it's private)
if ( if (!foundStatus.isViewableByUser(user)) {
(await foundStatus.toAPI()).visibility === "private" &&
(await foundStatus.toAPI()).account.id !== user?.id
) {
return errorResponse("Record not found", 404); return errorResponse("Record not found", 404);
} }
if (req.method === "GET") { if (req.method === "GET") {
return jsonResponse(await foundStatus.toAPI()); return jsonResponse(await foundStatus.toAPI());
} else if (req.method === "DELETE") { } else if (req.method === "DELETE") {
if ((await foundStatus.toAPI()).account.id !== user?.id) { if (foundStatus.account.id !== user?.id) {
return errorResponse("Unauthorized", 401); return errorResponse("Unauthorized", 401);
} }
// TODO: Implement delete and redraft functionality // TODO: Implement delete and redraft functionality
// Get associated Status object // 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 // Delete status and all associated objects
await status.object.remove(); await foundStatus.remove();
return jsonResponse( return jsonResponse(
{ {
...(await status.toAPI()), ...(await foundStatus.toAPI()),
// TODO: Add // TODO: Add
// text: Add source text // text: Add source text
// poll: Add source poll // poll: Add source poll

View file

@ -124,19 +124,20 @@ export default async (req: Request): Promise<Response> => {
} }
// Get reply account and status if exists // Get reply account and status if exists
let replyObject: RawObject | null = null; let replyStatus: Status | null = null;
let replyUser: User | null = null; let replyUser: User | null = null;
if (in_reply_to_id) { if (in_reply_to_id) {
replyObject = await RawObject.findOne({ replyStatus = await Status.findOne({
where: { where: {
id: in_reply_to_id, id: in_reply_to_id,
}, },
relations: {
account: true,
},
}); });
replyUser = await User.getByActorId( replyUser = replyStatus?.account || null;
(replyObject?.data.attributedTo as APActor).id ?? ""
);
} }
// Check if status body doesnt match filters // Check if status body doesnt match filters
@ -160,10 +161,10 @@ export default async (req: Request): Promise<Response> => {
spoiler_text: spoiler_text || "", spoiler_text: spoiler_text || "",
emojis: [], emojis: [],
reply: reply:
replyObject && replyUser replyStatus && replyUser
? { ? {
user: replyUser, user: replyUser,
object: replyObject, status: replyStatus,
} }
: undefined, : undefined,
}); });

View file

@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { applyConfig } from "@api"; import { applyConfig } from "@api";
import { parseRequest } from "@request"; import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; 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"; import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta: APIRouteMeta = applyConfig({
@ -32,49 +35,83 @@ export default async (req: Request): Promise<Response> => {
limit?: number; limit?: number;
}>(req); }>(req);
const { user } = await User.getFromRequest(req);
if (limit < 1 || limit > 40) { if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400); return errorResponse("Limit must be between 1 and 40", 400);
} }
let query = RawObject.createQueryBuilder("object") let query: FindManyOptions<Status> = {
.where("object.data->>'type' = 'Note'") where: {
// From a user followed by the current user visibility: "public",
.andWhere("CAST(object.data->>'to' AS jsonb) @> CAST(:to AS jsonb)", { account: [
to: JSON.stringify([ {
"https://www.w3.org/ns/activitystreams#Public", relationships: {
]), id: user?.id,
}) followed_by: true,
.orderBy("object.data->>'published'", "DESC") },
.take(limit); },
{
id: user?.id,
},
],
},
order: {
created_at: "DESC",
},
take: limit,
relations: ["object"],
};
if (max_id) { if (max_id) {
const maxPost = await RawObject.findOneBy({ id: max_id }); const maxPost = await Status.findOneBy({ id: max_id });
if (maxPost) { if (maxPost) {
query = query.andWhere("object.data->>'published' < :max_date", { query = {
max_date: maxPost.data.published, ...query,
}); where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxPost.created_at,
},
},
};
} }
} }
if (min_id) { if (min_id) {
const minPost = await RawObject.findOneBy({ id: min_id }); const minPost = await Status.findOneBy({ id: min_id });
if (minPost) { if (minPost) {
query = query.andWhere("object.data->>'published' > :min_date", { query = {
min_date: minPost.data.published, ...query,
}); where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: minPost.created_at,
},
},
};
} }
} }
if (since_id) { if (since_id) {
const sincePost = await RawObject.findOneBy({ id: since_id }); const sincePost = await Status.findOneBy({ id: since_id });
if (sincePost) { if (sincePost) {
query = query.andWhere("object.data->>'published' >= :since_date", { query = {
since_date: sincePost.data.published, ...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( return jsonResponse(
await Promise.all(objects.map(async object => await object.toAPI())) await Promise.all(objects.map(async object => await object.toAPI()))

View file

@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { applyConfig } from "@api"; import { applyConfig } from "@api";
import { parseRequest } from "@request"; import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response"; 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"; import { APIRouteMeta } from "~types/api";
export const meta: APIRouteMeta = applyConfig({ export const meta: APIRouteMeta = applyConfig({
@ -42,60 +44,94 @@ export default async (req: Request): Promise<Response> => {
return errorResponse("Limit must be between 1 and 40", 400); return errorResponse("Limit must be between 1 and 40", 400);
} }
let query = RawObject.createQueryBuilder("object") if (local && remote) {
.where("object.data->>'type' = 'Note'") return errorResponse("Cannot use both local and remote", 400);
.andWhere("CAST(object.data->>'to' AS jsonb) @> CAST(:to AS jsonb)", { }
to: JSON.stringify([
"https://www.w3.org/ns/activitystreams#Public", let query: FindManyOptions<Status> = {
]), where: {
}) visibility: "public",
.orderBy("object.data->>'published'", "DESC") },
.take(limit); order: {
created_at: "DESC",
},
take: limit,
relations: ["object"],
};
if (max_id) { if (max_id) {
const maxPost = await RawObject.findOneBy({ id: max_id }); const maxPost = await Status.findOneBy({ id: max_id });
if (maxPost) { if (maxPost) {
query = query.andWhere("object.data->>'published' < :max_date", { query = {
max_date: maxPost.data.published, ...query,
}); where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$lt: maxPost.created_at,
},
},
};
} }
} }
if (min_id) { if (min_id) {
const minPost = await RawObject.findOneBy({ id: min_id }); const minPost = await Status.findOneBy({ id: min_id });
if (minPost) { if (minPost) {
query = query.andWhere("object.data->>'published' > :min_date", { query = {
min_date: minPost.data.published, ...query,
}); where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gt: minPost.created_at,
},
},
};
} }
} }
if (since_id) { if (since_id) {
const sincePost = await RawObject.findOneBy({ id: since_id }); const sincePost = await Status.findOneBy({ id: since_id });
if (sincePost) { if (sincePost) {
query = query.andWhere("object.data->>'published' >= :since_date", { query = {
since_date: sincePost.data.published, ...query,
}); where: {
...query.where,
created_at: {
...(query.where as any)?.created_at,
$gte: sincePost.created_at,
},
},
};
} }
} }
if (only_media) { if (only_media) {
query = query.andWhere("object.data->'attachment' IS NOT NULL"); // TODO: add
} }
if (local) { if (local) {
query = query.andWhere("object.data->>'actor' LIKE :actor", { query = {
actor: `%${new URL(req.url).hostname}%`, ...query,
}); where: {
...query.where,
instance: IsNull(),
},
};
} }
if (remote) { if (remote) {
query = query.andWhere("object.data->>'actor' NOT LIKE :actor", { query = {
actor: `%${new URL(req.url).hostname}%`, ...query,
}); where: {
...query.where,
instance: Not(IsNull()),
},
};
} }
const objects = await query.getMany(); const objects = await Status.find(query);
return jsonResponse( return jsonResponse(
await Promise.all(objects.map(async object => await object.toAPI())) await Promise.all(objects.map(async object => await object.toAPI()))