diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 931801381..f0ca882d0 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -3,7 +3,7 @@ import { resolveUser } from "@/remote/resolve-user.js"; import Router from "@koa/router"; import { FindOptionsWhere, IsNull } from "typeorm"; import { getClient } from "../ApiMastodonCompatibleService.js"; -import { argsToBools, convertTimelinesArgsId, limitToInt } from "./timeline.js"; +import { argsToBools, convertTimelinesArgsId, limitToInt, normalizeUrlQuery } from "./timeline.js"; import { convertId, IdType } from "../../index.js"; import { convertAccount, @@ -14,6 +14,10 @@ import { } from "../converters.js"; import { getNote, getUser } from "@/server/api/common/getters.js"; import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import authenticate from "@/server/api/authenticate.js"; +import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; const relationshipModel = { id: "", @@ -147,15 +151,21 @@ export function apiAccountMastodon(router: Router): void { router.get<{ Params: { id: string } }>( "/v1/accounts/:id/statuses", async (ctx) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); try { - const data = await client.getAccountStatuses( - convertId(ctx.params.id, IdType.IceshrimpId), - convertTimelinesArgsId(argsToBools(limitToInt(ctx.query as any))), - ); - 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 userId = convertId(ctx.params.id, IdType.IceshrimpId); + const args = normalizeUrlQuery(convertTimelinesArgsId(argsToBools(limitToInt(ctx.query)))); + const tl = await UserHelpers.getUserStatuses(userId, user, args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.exclude_replies, args.exclude_reblogs, args.pinned, args.tagged) + .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/note.ts b/packages/backend/src/server/api/mastodon/helpers/note.ts index 0ee6d9c75..a8524d976 100644 --- a/packages/backend/src/server/api/mastodon/helpers/note.ts +++ b/packages/backend/src/server/api/mastodon/helpers/note.ts @@ -7,6 +7,7 @@ import { Note } from "@/models/entities/note.js"; import { ILocalUser } from "@/models/entities/user.js"; import querystring from "node:querystring"; import { getNote } from "@/server/api/common/getters.js"; +import { SelectQueryBuilder } from "typeorm"; export class NoteHelpers { public static async getNoteDescendants(note: Note | string, user: ILocalUser | null, limit: number = 10, depth: number = 2): Promise { @@ -44,4 +45,28 @@ export class NoteHelpers { return notes; } + + public 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 = []; + 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; + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index c12efe01d..538eec4aa 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -13,33 +13,10 @@ import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/gener import { fetchMeta } from "@/misc/fetch-meta.js"; import { ApiError } from "@/server/api/error.js"; import { meta } from "@/server/api/endpoints/notes/global-timeline.js"; +import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; export class TimelineHelpers { - 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 = []; - 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; - } - - public static async getHomeTimeline(user: ILocalUser, maxId?: string, sinceId?: string, minId?: string, limit: number = 20): Promise { + public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise { if (limit > 40) limit = 40; const hasFollowing = @@ -90,10 +67,10 @@ export class TimelineHelpers { query.andWhere("note.visibility != 'hidden'"); - return this.execQuery(query, limit); + return NoteHelpers.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 { + public static async getPublicTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, local: boolean = false, remote: boolean = false): Promise { if (limit > 40) limit = 40; const m = await fetchMeta(); @@ -144,6 +121,6 @@ export class TimelineHelpers { query.andWhere("note.visibility != 'hidden'"); - return this.execQuery(query, limit); + return NoteHelpers.execQuery(query, limit); } } diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts new file mode 100644 index 000000000..6269338b3 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/user.ts @@ -0,0 +1,70 @@ +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, 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"; +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"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { ApiError } from "@/server/api/error.js"; +import { meta } from "@/server/api/endpoints/notes/global-timeline.js"; +import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; + +export class UserHelpers { + public static async getUserStatuses(userId: string, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, excludeReplies: boolean = false, excludeReblogs: boolean = false, pinned: boolean = false, tagged: string | undefined): Promise { + if (limit > 40) limit = 40; + + if (pinned) { + //FIXME respect pinned + return []; + } + + if (tagged !== undefined) { + //FIXME respect tagged + return []; + } + + //FIXME respect minId + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId ?? minId, + maxId, + ) + .andWhere("note.userId = :userId", { userId }); + + if (excludeReblogs) query.andWhere("(note.renoteId IS NOT NULL) OR (note.text IS NOT 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, !excludeReplies, localUser); + generateVisibilityQuery(query, localUser); + if (localUser) { + generateMutedUserQuery(query, localUser); + generateMutedNoteQuery(query, localUser); + generateBlockedUserQuery(query, localUser); + generateMutedUserRenotesQueryForNotes(query, localUser); + } + + if (onlyMedia) query.andWhere("note.fileIds != '{}'"); + + query.andWhere("note.visibility != 'hidden'"); + + return NoteHelpers.execQuery(query, limit); + } +}