diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index 5f8c73a2e..ee86344a5 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -20,7 +20,7 @@ export function convertAttachment(attachment: Entity.Attachment) { export function convertFilter(filter: Entity.Filter) { return simpleConvert(filter); } -export function convertList(list: Entity.List) { +export function convertList(list: MastodonEntity.List) { return simpleConvert(list); } export function convertFeaturedTag(tag: Entity.FeaturedTag) { diff --git a/packages/backend/src/server/api/mastodon/endpoints/list.ts b/packages/backend/src/server/api/mastodon/endpoints/list.ts new file mode 100644 index 000000000..52b83c18e --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/list.ts @@ -0,0 +1,181 @@ +import Router from "@koa/router"; +import { getClient } from "../index.js"; +import { ParsedUrlQuery } from "querystring"; +import { + convertAccount, + convertConversation, + convertList, + 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 { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js"; +import { ListHelpers } from "@/server/api/mastodon/helpers/list.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; + +export function setupEndpointsList(router: Router): void { + router.get("/v1/lists", async (ctx, reply) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? undefined; + + if (!user) { + ctx.status = 401; + return; + } + + ctx.body = await ListHelpers.getLists(user) + .then(p => p.map(list => convertList(list))); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get<{ Params: { id: string } }>( + "/v1/lists/:id", + async (ctx, reply) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? undefined; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + + ctx.body = await ListHelpers.getList(user, id) + .then(p => convertList(p)); + } catch (e: any) { + ctx.status = 404; + } + }, + ); + router.post("/v1/lists", 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.createList((ctx.request.body as any).title); + ctx.body = convertList(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.put<{ Params: { id: string } }>( + "/v1/lists/:id", + 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.updateList( + convertId(ctx.params.id, IdType.IceshrimpId), + (ctx.request.body as any).title, + ); + ctx.body = convertList(data.data); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.delete<{ Params: { id: string } }>( + "/v1/lists/:id", + 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.deleteList( + convertId(ctx.params.id, IdType.IceshrimpId), + ); + ctx.body = data.data; + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/lists/:id/accounts", + async (ctx, reply) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? undefined; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); + const res = await ListHelpers.getListUsers(user, id, args.max_id, args.since_id, args.min_id, args.limit); + const accounts = await UserConverter.encodeMany(res.data); + ctx.body = accounts.map(account => convertAccount(account)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + ctx.status = 404; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/lists/:id/accounts", + 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.addAccountsToList( + convertId(ctx.params.id, IdType.IceshrimpId), + (ctx.query.account_ids as string[]).map((id) => + convertId(id, IdType.IceshrimpId), + ), + ); + ctx.body = data.data; + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.delete<{ Params: { id: string } }>( + "/v1/lists/:id/accounts", + 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.deleteAccountsFromList( + convertId(ctx.params.id, IdType.IceshrimpId), + (ctx.query.account_ids as string[]).map((id) => + convertId(id, IdType.IceshrimpId), + ), + ); + ctx.body = data.data; + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index d102e6534..a986bb5a1 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -171,173 +171,4 @@ export function setupEndpointsTimeline(router: Router): void { ctx.body = e.response.data; } }); - router.get("/v1/lists", 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.getLists(); - ctx.body = data.data.map((list) => convertList(list)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get<{ Params: { id: string } }>( - "/v1/lists/:id", - 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.getList( - convertId(ctx.params.id, IdType.IceshrimpId), - ); - ctx.body = convertList(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post("/v1/lists", 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.createList((ctx.request.body as any).title); - ctx.body = convertList(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.put<{ Params: { id: string } }>( - "/v1/lists/:id", - 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.updateList( - convertId(ctx.params.id, IdType.IceshrimpId), - (ctx.request.body as any).title, - ); - ctx.body = convertList(data.data); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.delete<{ Params: { id: string } }>( - "/v1/lists/:id", - 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.deleteList( - convertId(ctx.params.id, IdType.IceshrimpId), - ); - ctx.body = data.data; - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get<{ Params: { id: string } }>( - "/v1/lists/:id/accounts", - 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.getAccountsInList( - convertId(ctx.params.id, IdType.IceshrimpId), - convertPaginationArgsIds(ctx.query as any), - ); - ctx.body = data.data.map((account) => convertAccount(account)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/lists/:id/accounts", - 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.addAccountsToList( - convertId(ctx.params.id, IdType.IceshrimpId), - (ctx.query.account_ids as string[]).map((id) => - convertId(id, IdType.IceshrimpId), - ), - ); - ctx.body = data.data; - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.delete<{ Params: { id: string } }>( - "/v1/lists/:id/accounts", - 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.deleteAccountsFromList( - convertId(ctx.params.id, IdType.IceshrimpId), - (ctx.query.account_ids as string[]).map((id) => - convertId(id, IdType.IceshrimpId), - ), - ); - ctx.body = data.data; - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); -} -function escapeHTML(str: string) { - if (!str) { - return ""; - } - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} -function nl2br(str: string) { - if (!str) { - return ""; - } - str = str.replace(/\r\n/g, "
"); - str = str.replace(/(\n|\r)/g, "
"); - return str; } diff --git a/packages/backend/src/server/api/mastodon/helpers/list.ts b/packages/backend/src/server/api/mastodon/helpers/list.ts new file mode 100644 index 000000000..cb5bff39b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/list.ts @@ -0,0 +1,50 @@ +import { ILocalUser, User } from "@/models/entities/user.js"; +import { UserListJoinings, UserLists } from "@/models/index.js"; +import { LinkPaginationObject } from "@/server/api/mastodon/helpers/user.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; + +export class ListHelpers { + public static async getLists(user: ILocalUser): Promise { + return UserLists.findBy({userId: user.id}).then(p => p.map(list => { + return { + id: list.id, + title: list.name + } + })); + } + + public static async getList(user: ILocalUser, id: string): Promise { + return UserLists.findOneByOrFail({userId: user.id, id: id}).then(list => { + return { + id: list.id, + title: list.name + } + }); + } + + public static async getListUsers(user: ILocalUser, id: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise> { + if (limit > 80) limit = 80; + const list = await UserLists.findOneByOrFail({userId: user.id, id: id}); + const query = PaginationHelpers.makePaginationQuery( + UserListJoinings.createQueryBuilder('member'), + sinceId, + maxId, + minId + ) + .andWhere("member.userListId = :listId", {listId: id}) + .innerJoinAndSelect("member.user", "user"); + + return query.take(limit).getMany().then(async p => { + if (minId !== undefined) p = p.reverse(); + const users = p + .map(p => p.user) + .filter(p => p) as User[]; + + return { + data: users, + maxId: p.map(p => p.id).at(-1), + minId: p.map(p => p.id)[0], + }; + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/index.ts b/packages/backend/src/server/api/mastodon/index.ts index 069fbf9e4..2929c4948 100644 --- a/packages/backend/src/server/api/mastodon/index.ts +++ b/packages/backend/src/server/api/mastodon/index.ts @@ -11,6 +11,7 @@ import { setupEndpointsMedia } from "@/server/api/mastodon/endpoints/media.js"; import { setupEndpointsMisc } from "@/server/api/mastodon/endpoints/misc.js"; import { koaBody } from "koa-body"; import multer from "@koa/multer"; +import { setupEndpointsList } from "@/server/api/mastodon/endpoints/list.js"; export function getClient( BASE_URL: string, @@ -50,5 +51,6 @@ export function setupMastodonApi(router: Router, fileRouter: Router, upload: mul setupEndpointsNotifications(router); setupEndpointsSearch(router); setupEndpointsMedia(router, fileRouter, upload); + setupEndpointsList(router); setupEndpointsMisc(router); }