diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index d16c9e491..4ec22ca6f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -45,6 +45,8 @@ export function argsToBools(q: ParsedUrlQuery) { if (typeof q.pinned === "string") object.pinned = toBoolean(q.pinned); if (q.local) if (typeof q.local === "string") object.local = toBoolean(q.local); + if (q.remote) + if (typeof q.local === "string") object.local = toBoolean(q.local); return q; } @@ -70,20 +72,20 @@ export function normalizeUrlQuery(q: ParsedUrlQuery): any { export function apiTimelineMastodon(router: Router): void { router.get("/v1/timelines/public", async (ctx, reply) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const query: any = ctx.query; - const data = - query.local === "true" - ? await client.getLocalTimeline( - convertTimelinesArgsId(argsToBools(limitToInt(query))), - ) - : await client.getPublicTimeline( - convertTimelinesArgsId(argsToBools(limitToInt(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(argsToBools(limitToInt(ctx.query)))); + const tl = await TimelineHelpers.getPublicTimeline(user, args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote) + .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 index 9eaf151b4..c12efe01d 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -2,7 +2,7 @@ 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 { Brackets, SelectQueryBuilder } 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"; @@ -10,58 +10,12 @@ import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user- 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"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { ApiError } from "@/server/api/error.js"; +import { meta } from "@/server/api/endpoints/notes/global-timeline.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'"); - + private static async execQuery(query: SelectQueryBuilder, limit: number): Promise { // We fetch more than requested because some may be filtered out, and if there's less than // requested, the pagination stops. const found = []; @@ -84,4 +38,112 @@ export class TimelineHelpers { return found; } + + 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'"); + + return this.execQuery(query, limit); + } + + public static async getPublicTimeline(user: ILocalUser, maxId?: string, sinceId?: string, minId?: string, limit: number = 20, onlyMedia: boolean = false, local: boolean = false, remote: boolean = false): Promise { + if (limit > 40) limit = 40; + + const m = await fetchMeta(); + if (m.disableGlobalTimeline) { + if (user == null || !(user.isAdmin || user.isModerator)) { + throw new ApiError(meta.errors.gtlDisabled); + } + } + + if (local && remote) { + throw new Error("local and remote are mutually exclusive options"); + } + + //FIXME respect minId + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId ?? minId, + maxId, + ) + .andWhere("note.visibility = 'public'"); + + if (remote) query.andWhere("note.userHost IS NOT NULL"); + if (local) query.andWhere("note.userHost IS NULL"); + if (!local) query.andWhere("note.channelId IS NULL"); + + query + .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"); + + generateRepliesQuery(query, true, user); + if (user) { + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + } + + if (onlyMedia) query.andWhere("note.fileIds != '{}'"); + + query.andWhere("note.visibility != 'hidden'"); + + return this.execQuery(query, limit); + } }