diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 750ad1a7e..30f48c5ec 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -176,7 +176,7 @@ export function apiStatusMastodon(router: Router): void { ); ctx.body = data.data; } catch (e: any) { - console.error(e.response.data, request.params.id); + console.error(e.response.data, ctx.params.id); ctx.status = 401; ctx.body = e.response.data; } @@ -192,7 +192,6 @@ export function apiStatusMastodon(router: Router): void { router.get<{ Params: { id: string } }>( "/v1/statuses/:id/context", async (ctx) => { - const accessTokens = ctx.headers.authorization; try { const auth = await authenticate(ctx.headers.authorization, null); const user = auth[0] ?? null; diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index cf23fc263..d11f3e0e8 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -8,6 +8,10 @@ import { convertStatus, } from "../converters.js"; import { convertId, IdType } from "../../index.js"; +import authenticate from "@/server/api/authenticate.js"; +import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js"; +import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; export function limitToInt(q: ParsedUrlQuery) { let object: any = q; @@ -54,6 +58,16 @@ export function convertTimelinesArgsId(q: ParsedUrlQuery) { return q; } +export function normalizeUrlQuery(q: ParsedUrlQuery): any { + const dict: any = {}; + + for (const k in Object.keys(q)) { + dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k]; + } + + return dict; +} + export function apiTimelineMastodon(router: Router): void { router.get("/v1/timelines/public", async (ctx, reply) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; @@ -98,14 +112,20 @@ export function apiTimelineMastodon(router: Router): void { }, ); router.get("/v1/timelines/home", async (ctx, reply) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getHomeTimeline( - convertTimelinesArgsId(limitToInt(ctx.query)), - ); - ctx.body = data.data.map((status) => convertStatus(status)); + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? undefined; + + if (!user) { + ctx.status = 401; + return; + } + + const args = normalizeUrlQuery(convertTimelinesArgsId(limitToInt(ctx.query))); + const tl = await TimelineHelpers.getHomeTimeline(user, args.max_id, args.since_id, args.min_id, args.limit) + .then(n => NoteConverter.encodeMany(n, user)); + + ctx.body = tl.map(s => convertStatus(s)); } catch (e: any) { console.error(e); console.error(e.response.data); diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts new file mode 100644 index 000000000..9eaf151b4 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -0,0 +1,87 @@ +import { Note } from "@/models/entities/note.js"; +import { ILocalUser } from "@/models/entities/user.js"; +import { Followings, Notes } from "@/models/index.js"; +import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js"; +import { Brackets } from "typeorm"; +import { generateChannelQuery } from "@/server/api/common/generate-channel-query.js"; +import { generateRepliesQuery } from "@/server/api/common/generate-replies-query.js"; +import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js"; +import { generateMutedNoteQuery } from "@/server/api/common/generate-muted-note-query.js"; +import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; +export class TimelineHelpers { + public static async getHomeTimeline(user: ILocalUser, maxId?: string, sinceId?: string, minId?: string, limit: number = 20): Promise { + if (limit > 40) limit = 40; + + const hasFollowing = + (await Followings.count({ + where: { + followerId: user.id, + }, + take: 1, + })) !== 0; + + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: user.id }); + + //FIXME respect minId + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId ?? minId, + maxId, + ) + .andWhere( + new Brackets((qb) => { + qb.where("note.userId = :meId", { meId: user.id }); + if (hasFollowing) + qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`); + }), + ) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("user.avatar", "avatar") + .leftJoinAndSelect("user.banner", "banner") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("replyUser.avatar", "replyUserAvatar") + .leftJoinAndSelect("replyUser.banner", "replyUserBanner") + .leftJoinAndSelect("renote.user", "renoteUser") + .leftJoinAndSelect("renoteUser.avatar", "renoteUserAvatar") + .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") + .setParameters(followingQuery.getParameters()); + + generateChannelQuery(query, user); + generateRepliesQuery(query, true, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + + query.andWhere("note.visibility != 'hidden'"); + + // We fetch more than requested because some may be filtered out, and if there's less than + // requested, the pagination stops. + const found = []; + const take = Math.floor(limit * 1.5); + let skip = 0; + try { + while (found.length < limit) { + const notes = await query.take(take).skip(skip).getMany(); + found.push(...notes); + skip += take; + if (notes.length < take) break; + } + } catch (error) { + return []; + } + + if (found.length > limit) { + found.length = limit; + } + + return found; + } +}