diff --git a/packages/backend/src/server/api/mastodon/converters/user.ts b/packages/backend/src/server/api/mastodon/converters/user.ts index 1a49b7eff..112f27087 100644 --- a/packages/backend/src/server/api/mastodon/converters/user.ts +++ b/packages/backend/src/server/api/mastodon/converters/user.ts @@ -1,4 +1,4 @@ -import { User } from "@/models/entities/user.js"; +import { ILocalUser, User } from "@/models/entities/user.js"; import config from "@/config/index.js"; import { DriveFiles, UserProfiles, Users } from "@/models/index.js"; import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js"; @@ -8,6 +8,7 @@ import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js"; import mfm from "mfm-js"; import { awaitAll } from "@/prelude/await-all.js"; import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { Note } from "@/models/entities/note.js"; type Field = { name: string; @@ -65,6 +66,11 @@ export class UserConverter { }); } + public static async encodeMany(users: User[], cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise { + const encoded = users.map(u => this.encode(u, cache)); + return Promise.all(encoded); + } + private static encodeField(f: Field): MastodonEntity.Field { return { name: f.name, @@ -72,5 +78,4 @@ export class UserConverter { verified_at: f.verified ? (new Date()).toISOString() : null, } } - } diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 8d0fb1fc4..0c75e81a0 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -198,15 +198,19 @@ export function apiAccountMastodon(router: Router): void { router.get<{ Params: { id: string } }>( "/v1/accounts/:id/followers", 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.getAccountFollowers( - convertId(ctx.params.id, IdType.IceshrimpId), - convertTimelinesArgsId(limitToInt(ctx.query as any)), - ); - ctx.body = data.data.map((account) => convertAccount(account)); + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const cache = UserHelpers.getFreshAccountCache(); + const query = await UserHelpers.getUserCached(userId, cache); + const args = normalizeUrlQuery(convertTimelinesArgsId(limitToInt(ctx.query as any))); + + const followers = await UserHelpers.getUserFollowers(query, user, args.max_id, args.since_id, args.min_id, args.limit) + .then(f => UserConverter.encodeMany(f, cache)); + + ctx.body = followers.map((account) => convertAccount(account)); } 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 ff5560e89..6f4738bcd 100644 --- a/packages/backend/src/server/api/mastodon/helpers/note.ts +++ b/packages/backend/src/server/api/mastodon/helpers/note.ts @@ -46,37 +46,6 @@ export class NoteHelpers { return notes; } - public static makePaginationQuery( - q: SelectQueryBuilder, - sinceId?: string, - maxId?: string, - minId?: string - ) { - if (sinceId && minId) throw new Error("Can't user both sinceId and minId params"); - - if (sinceId && maxId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.andWhere(`${q.alias}.id < :maxId`, { maxId: maxId }); - q.orderBy(`${q.alias}.id`, "DESC"); - } if (minId && maxId) { - q.andWhere(`${q.alias}.id > :minId`, { minId: minId }); - q.andWhere(`${q.alias}.id < :maxId`, { maxId: maxId }); - q.orderBy(`${q.alias}.id`, "ASC"); - } else if (sinceId) { - q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); - q.orderBy(`${q.alias}.id`, "DESC"); - } else if (minId) { - q.andWhere(`${q.alias}.id > :minId`, { minId: minId }); - q.orderBy(`${q.alias}.id`, "ASC"); - } else if (maxId) { - q.andWhere(`${q.alias}.id < :maxId`, { maxId: maxId }); - q.orderBy(`${q.alias}.id`, "DESC"); - } else { - q.orderBy(`${q.alias}.id`, "DESC"); - } - return q; - } - /** * * @param query diff --git a/packages/backend/src/server/api/mastodon/helpers/pagination.ts b/packages/backend/src/server/api/mastodon/helpers/pagination.ts new file mode 100644 index 000000000..9fc239e1b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/pagination.ts @@ -0,0 +1,34 @@ +import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; + +export class PaginationHelpers { + public static makePaginationQuery( + q: SelectQueryBuilder, + sinceId?: string, + maxId?: string, + minId?: string + ) { + if (sinceId && minId) throw new Error("Can't user both sinceId and minId params"); + + if (sinceId && maxId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.id < :maxId`, { maxId: maxId }); + q.orderBy(`${q.alias}.id`, "DESC"); + } if (minId && maxId) { + q.andWhere(`${q.alias}.id > :minId`, { minId: minId }); + q.andWhere(`${q.alias}.id < :maxId`, { maxId: maxId }); + q.orderBy(`${q.alias}.id`, "ASC"); + } else if (sinceId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.id`, "DESC"); + } else if (minId) { + q.andWhere(`${q.alias}.id > :minId`, { minId: minId }); + q.orderBy(`${q.alias}.id`, "ASC"); + } else if (maxId) { + q.andWhere(`${q.alias}.id < :maxId`, { maxId: maxId }); + q.orderBy(`${q.alias}.id`, "DESC"); + } else { + q.orderBy(`${q.alias}.id`, "DESC"); + } + return q; + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index 9291df6e9..c28ab59ca 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -14,6 +14,7 @@ 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"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; export class TimelineHelpers { public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise { @@ -31,7 +32,7 @@ export class TimelineHelpers { .select("following.followeeId") .where("following.followerId = :followerId", {followerId: user.id}); - const query = NoteHelpers.makePaginationQuery( + const query = PaginationHelpers.makePaginationQuery( Notes.createQueryBuilder("note"), sinceId, maxId, @@ -84,7 +85,7 @@ export class TimelineHelpers { throw new Error("local and remote are mutually exclusive options"); } - const query = NoteHelpers.makePaginationQuery( + const query = PaginationHelpers.makePaginationQuery( Notes.createQueryBuilder("note"), sinceId, maxId, diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts index 59beee885..4fc22e200 100644 --- a/packages/backend/src/server/api/mastodon/helpers/user.ts +++ b/packages/backend/src/server/api/mastodon/helpers/user.ts @@ -1,6 +1,6 @@ import { Note } from "@/models/entities/note.js"; import { ILocalUser, User } from "@/models/entities/user.js"; -import { Notes } from "@/models/index.js"; +import { Followings, Notes, UserProfiles } from "@/models/index.js"; import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js"; import { generateRepliesQuery } from "@/server/api/common/generate-replies-query.js"; import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js"; @@ -10,6 +10,7 @@ import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; import Entity from "megalodon/src/entity.js"; import AsyncLock from "async-lock"; import { getUser } from "@/server/api/common/getters.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; export type AccountCache = { locks: AsyncLock; @@ -31,7 +32,7 @@ export class UserHelpers { return []; } - const query = NoteHelpers.makePaginationQuery( + const query = PaginationHelpers.makePaginationQuery( Notes.createQueryBuilder("note"), sinceId, maxId, @@ -69,6 +70,36 @@ export class UserHelpers { return NoteHelpers.execQuery(query, limit, minId !== undefined); } + public static async getUserFollowers(user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise { + if (limit > 80) limit = 80; + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + if (profile.ffVisibility === "private") { + if (!localUser || user.id != localUser.id) return []; + } + else if (profile.ffVisibility === "followers") { + if (!localUser) return []; + const isFollowed = await Followings.exist({ + where: { + followeeId: user.id, + followerId: localUser.id, + }, + }); + if (!isFollowed) return []; + } + + const query = PaginationHelpers.makePaginationQuery( + Followings.createQueryBuilder("following"), + sinceId, + maxId, + minId + ) + .andWhere("following.followeeId = :userId", { userId: user.id }) + .innerJoinAndSelect("following.follower", "follower"); + + return query.take(limit).getMany().then(p => p.map(p => p.follower).filter(p => p) as User[]); + } + public static async getUserCached(id: string, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise { return cache.locks.acquire(id, async () => { const cacheHit = cache.users.find(p => p.id == id);