diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts index 460a0ce84..8ebab5267 100644 --- a/packages/backend/src/server/api/authenticate.ts +++ b/packages/backend/src/server/api/authenticate.ts @@ -21,6 +21,7 @@ export class AuthenticationError extends Error { export default async ( authorization: string | null | undefined, bodyToken: string | null, + bypassUserCache: boolean = false ): Promise< [CacheableLocalUser | null | undefined, AccessToken | null | undefined] > => { @@ -46,11 +47,13 @@ export default async ( } if (isNativeToken(token)) { - const user = await localUserByNativeTokenCache.fetch( - token, - () => Users.findOneBy({ token }) as Promise, - true, - ); + const user = bypassUserCache + ? await Users.findOneBy({ token }) as ILocalUser | null + : await localUserByNativeTokenCache.fetch( + token, + () => Users.findOneBy({ token: token ?? undefined }) as Promise, + true, + ); if (user == null) { throw new AuthenticationError("unknown token"); @@ -77,14 +80,18 @@ export default async ( lastUsedAt: new Date(), }); - const user = await localUserByIdCache.fetch( - accessToken.userId, - () => - Users.findOneBy({ - id: accessToken.userId, - }) as Promise, - true, - ); + const user = bypassUserCache + ? await Users.findOneBy({ + id: accessToken.userId, + }) as ILocalUser + : await localUserByIdCache.fetch( + accessToken.userId, + () => + Users.findOneBy({ + id: accessToken.userId, + }) as Promise, + true, + ); if (accessToken.appId) { const app = await appCache.fetch( diff --git a/packages/backend/src/server/api/mastodon/converters/announcement.ts b/packages/backend/src/server/api/mastodon/converters/announcement.ts index 9b5445cef..b3c191f26 100644 --- a/packages/backend/src/server/api/mastodon/converters/announcement.ts +++ b/packages/backend/src/server/api/mastodon/converters/announcement.ts @@ -1,7 +1,4 @@ import { Announcement } from "@/models/entities/announcement.js"; -import { ILocalUser } from "@/models/entities/user.js"; -import { awaitAll } from "@/prelude/await-all"; -import { AnnouncementReads } from "@/models/index.js"; import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import mfm from "mfm-js"; diff --git a/packages/backend/src/server/api/mastodon/converters/auth.ts b/packages/backend/src/server/api/mastodon/converters/auth.ts new file mode 100644 index 000000000..98f98cf86 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/auth.ts @@ -0,0 +1,149 @@ +import { unique } from "@/prelude/array.js"; + +export class AuthConverter { + private static readScopes = [ + "read:account", + "read:drive", + "read:blocks", + "read:favorites", + "read:following", + "read:messaging", + "read:mutes", + "read:notifications", + "read:reactions", + ]; + + private static writeScopes = [ + "write:account", + "write:drive", + "write:blocks", + "write:favorites", + "write:following", + "write:messaging", + "write:mutes", + "write:notes", + "write:notifications", + "write:reactions", + "write:votes", + ]; + + private static followScopes = [ + "read:following", + "read:blocks", + "read:mutes", + "write:following", + "write:blocks", + "write:mutes", + ]; + + public static decode(scopes: string[]): string[] { + const res: string[] = []; + + for (const scope of scopes) { + if (scope === "read") + res.push(...this.readScopes); + else if (scope === "write") + res.push(...this.writeScopes); + else if (scope === "follow") + res.push(...this.followScopes); + else if (scope === "read:accounts") + res.push("read:account"); + else if (scope === "read:blocks") + res.push("read:blocks"); + else if (scope === "read:bookmarks") + res.push("read:favorites"); + else if (scope === "read:favourites") + res.push("read:reactions"); + else if (scope === "read:filters") + res.push("read:account") + else if (scope === "read:follows") + res.push("read:following"); + else if (scope === "read:lists") + res.push("read:account"); + else if (scope === "read:mutes") + res.push("read:mutes"); + else if (scope === "read:notifications") + res.push("read:notifications"); + else if (scope === "read:search") + res.push("read:account"); // FIXME: move this to a new scope "read:search" + else if (scope === "read:statuses") + res.push("read:messaging"); + else if (scope === "write:accounts") + res.push("write:account"); + else if (scope === "write:blocks") + res.push("write:blocks"); + else if (scope === "write:bookmarks") + res.push("write:favorites"); + else if (scope === "write:favourites") + res.push("write:reactions"); + else if (scope === "write:filters") + res.push("write:account"); + else if (scope === "write:follows") + res.push("write:following"); + else if (scope === "write:lists") + res.push("write:account"); + else if (scope === "write:media") + res.push("write:drive"); + else if (scope === "write:mutes") + res.push("write:mutes"); + else if (scope === "write:notifications") + res.push("write:notifications"); + else if (scope === "write:reports") + res.push("read:account"); // FIXME: move this to a new scope "write:reports" + else if (scope === "write:statuses") + res.push(...["write:notes", "write:messaging", "write:votes"]); + else if (scope === "write:conversations") + res.push("write:messaging"); + // ignored: "push" + } + + return unique(res); + } + + public static encode(scopes: string[]): string[] { + const res: string[] = []; + + for (const scope of scopes) { + if (scope === "read:account") + res.push(...["read:accounts", "read:filters", "read:search", "read:lists"]); + else if (scope === "read:blocks") + res.push("read:blocks"); + else if (scope === "read:favorites") + res.push("read:bookmarks"); + else if (scope === "read:reactions") + res.push("read:favourites"); + else if (scope === "read:following") + res.push("read:follows"); + else if (scope === "read:mutes") + res.push("read:mutes"); + else if (scope === "read:notifications") + res.push("read:notifications"); + else if (scope === "read:messaging") + res.push("read:statuses"); + else if (scope === "write:account") + res.push(...["write:accounts", "write:lists", "write:filters", "write:reports"]); + else if (scope === "write:blocks") + res.push("write:blocks"); + else if (scope === "write:favorites") + res.push("write:bookmarks"); + else if (scope === "write:reactions") + res.push("write:favourites"); + else if (scope === "write:following") + res.push("write:follows"); + else if (scope === "write:drive") + res.push("write:media"); + else if (scope === "write:mutes") + res.push("write:mutes"); + else if (scope === "write:notifications") + res.push("write:notifications"); + else if (scope === "write:notes") + res.push("write:statuses"); + else if (scope === "write:messaging") + res.push("write:conversations"); + else if (scope === "write:votes") + res.push("write:statuses"); + } + + return unique(res); + } +} \ No newline at end of file diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 35dddba29..0a2fa9d84 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -2,527 +2,243 @@ import Router from "@koa/router"; import { argsToBools, convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js"; import { convertId, IdType } from "../../index.js"; import { convertAccountId, convertListId, convertRelationshipId, convertStatusIds, } from "../converters.js"; -import { getUser } from "@/server/api/common/getters.js"; import { UserConverter } from "@/server/api/mastodon/converters/user.js"; -import authenticate from "@/server/api/authenticate.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { ListHelpers } from "@/server/api/mastodon/helpers/list.js"; import { Files } from "formidable"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; export function setupEndpointsAccount(router: Router): void { - router.get("/v1/accounts/verify_credentials", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const acct = await UserHelpers.verifyCredentials(user); + router.get("/v1/accounts/verify_credentials", + auth(true, ['read:accounts']), + async (ctx) => { + const acct = await UserHelpers.verifyCredentials(ctx.user); ctx.body = convertAccountId(acct); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; } - }); - router.patch("/v1/accounts/update_credentials", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + ); + router.patch("/v1/accounts/update_credentials", + auth(true, ['write:accounts']), + async (ctx) => { const files = (ctx.request as any).files as Files | undefined; - const acct = await UserHelpers.updateCredentials(user, (ctx.request as any).body as any, files); + const acct = await UserHelpers.updateCredentials(ctx.user, (ctx.request as any).body as any, files); ctx.body = convertAccountId(acct) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; } - }); - router.get("/v1/accounts/lookup", async (ctx) => { - try { + ); + router.get("/v1/accounts/lookup", + async (ctx) => { const args = normalizeUrlQuery(ctx.query); const user = await UserHelpers.getUserFromAcct(args.acct); - if (user === null) { - ctx.status = 404; - return; - } const account = await UserConverter.encode(user); ctx.body = convertAccountId(account); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; } - }); - router.get("/v1/accounts/relationships", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + ); + router.get("/v1/accounts/relationships", + auth(true, ['read:follows']), + async (ctx) => { const ids = (normalizeUrlQuery(ctx.query, ['id[]'])['id[]'] ?? []) .map((id: string) => convertId(id, IdType.IceshrimpId)); - const result = await UserHelpers.getUserRelationhipToMany(ids, user.id); + const result = await UserHelpers.getUserRelationhipToMany(ids, ctx.user.id); ctx.body = result.map(rel => convertRelationshipId(rel)); - } 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/accounts/:id", async (ctx) => { - try { + ); + router.get<{ Params: { id: string } }>("/v1/accounts/:id", + auth(false), + async (ctx) => { const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const account = await UserConverter.encode(await getUser(userId)); + const account = await UserConverter.encode(await UserHelpers.getUserOr404(userId)); ctx.body = convertAccountId(account); - } 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/accounts/:id/statuses", + auth(false, ["read:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const query = await UserHelpers.getUserCachedOr404(userId, ctx.cache); + const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query)))); + const tl = await UserHelpers.getUserStatuses(query, ctx.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, ctx.user, ctx.cache)); - const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const query = await UserHelpers.getUserCached(userId, cache); - const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query)))); - const tl = await UserHelpers.getUserStatuses(query, 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, cache)); - - ctx.body = tl.map(s => convertStatusIds(s)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = tl.map(s => convertStatusIds(s)); }, ); router.get<{ Params: { id: string } }>( "/v1/accounts/:id/featured_tags", async (ctx) => { - try { - ctx.body = []; - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; - } + ctx.body = []; }, ); router.get<{ Params: { id: string } }>( "/v1/accounts/:id/followers", + auth(false), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const query = await UserHelpers.getUserCachedOr404(userId, ctx.cache); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await UserHelpers.getUserFollowers(query, ctx.user, args.max_id, args.since_id, args.min_id, args.limit); + const followers = await UserConverter.encodeMany(res.data, ctx.cache); - const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const query = await UserHelpers.getUserCached(userId, cache); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - - const res = await UserHelpers.getUserFollowers(query, user, args.max_id, args.since_id, args.min_id, args.limit); - const followers = await UserConverter.encodeMany(res.data, cache); - - ctx.body = followers.map((account) => convertAccountId(account)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = followers.map((account) => convertAccountId(account)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); }, ); router.get<{ Params: { id: string } }>( "/v1/accounts/:id/following", + auth(false), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const query = await UserHelpers.getUserCachedOr404(userId, ctx.cache); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await UserHelpers.getUserFollowing(query, ctx.user, args.max_id, args.since_id, args.min_id, args.limit); + const following = await UserConverter.encodeMany(res.data, ctx.cache); - const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const query = await UserHelpers.getUserCached(userId, cache); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - - const res = await UserHelpers.getUserFollowing(query, user, args.max_id, args.since_id, args.min_id, args.limit); - const following = await UserConverter.encodeMany(res.data, cache); - - ctx.body = following.map((account) => convertAccountId(account)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = following.map((account) => convertAccountId(account)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); }, ); router.get<{ Params: { id: string } }>( "/v1/accounts/:id/lists", + auth(true, ["read:lists"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const member = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const results = await ListHelpers.getListsByMember(user, member); - ctx.body = results.map(p => convertListId(p)); - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; - } + const member = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const results = await ListHelpers.getListsByMember(ctx.user, member); + ctx.body = results.map(p => convertListId(p)); }, ); router.post<{ Params: { id: string } }>( "/v1/accounts/:id/follow", + auth(true, ["write:follows"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - //FIXME: Parse form data - const result = await UserHelpers.followUser(target, user, true, false); - ctx.body = convertRelationshipId(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + //FIXME: Parse form data + const result = await UserHelpers.followUser(target, ctx.user, true, false); + ctx.body = convertRelationshipId(result); }, ); router.post<{ Params: { id: string } }>( "/v1/accounts/:id/unfollow", + auth(true, ["write:follows"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.unfollowUser(target, user); - ctx.body = convertRelationshipId(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.unfollowUser(target, ctx.user); + ctx.body = convertRelationshipId(result); }, ); router.post<{ Params: { id: string } }>( "/v1/accounts/:id/block", + auth(true, ["write:blocks"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.blockUser(target, user); - ctx.body = convertRelationshipId(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.blockUser(target, ctx.user); + ctx.body = convertRelationshipId(result); }, ); router.post<{ Params: { id: string } }>( "/v1/accounts/:id/unblock", + auth(true, ["write:blocks"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.unblockUser(target, user); - ctx.body = convertRelationshipId(result) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.unblockUser(target, ctx.user); + ctx.body = convertRelationshipId(result) }, ); router.post<{ Params: { id: string } }>( "/v1/accounts/:id/mute", + auth(true, ["write:mutes"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - //FIXME: parse form data - const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, ['duration']), ['notifications'])); - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.muteUser(target, user, args.notifications, args.duration); - ctx.body = convertRelationshipId(result) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + //FIXME: parse form data + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, ['duration']), ['notifications'])); + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.muteUser(target, ctx.user, args.notifications, args.duration); + ctx.body = convertRelationshipId(result) }, ); router.post<{ Params: { id: string } }>( "/v1/accounts/:id/unmute", + auth(true, ["write:mutes"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.unmuteUser(target, user); - ctx.body = convertRelationshipId(result) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.unmuteUser(target, ctx.user); + ctx.body = convertRelationshipId(result) }, ); - router.get("/v1/featured_tags", async (ctx) => { - try { + router.get("/v1/featured_tags", + async (ctx) => { ctx.body = []; - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; } - }); - router.get("/v1/followed_tags", async (ctx) => { - try { + ); + router.get("/v1/followed_tags", + async (ctx) => { ctx.body = []; - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; } - }); - router.get("/v1/bookmarks", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); + ); + router.get("/v1/bookmarks", + auth(true, ["read:bookmarks"]), + async (ctx) => { const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await UserHelpers.getUserBookmarks(user, args.max_id, args.since_id, args.min_id, args.limit); - const bookmarks = await NoteConverter.encodeMany(res.data, user, cache); - + const res = await UserHelpers.getUserBookmarks(ctx.user, args.max_id, args.since_id, args.min_id, args.limit); + const bookmarks = await NoteConverter.encodeMany(res.data, ctx.user, ctx.cache); ctx.body = bookmarks.map(s => convertStatusIds(s)); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 20); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; } - }); - router.get("/v1/favourites", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); + ); + router.get("/v1/favourites", + auth(true, ["read:favourites"]), + async (ctx) => { const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await UserHelpers.getUserFavorites(user, args.max_id, args.since_id, args.min_id, args.limit); - const favorites = await NoteConverter.encodeMany(res.data, user, cache); - + const res = await UserHelpers.getUserFavorites(ctx.user, args.max_id, args.since_id, args.min_id, args.limit); + const favorites = await NoteConverter.encodeMany(res.data, ctx.user, ctx.cache); ctx.body = favorites.map(s => convertStatusIds(s)); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 20); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; } - }); - router.get("/v1/mutes", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); + ); + router.get("/v1/mutes", + auth(true, ["read:mutes"]), + async (ctx) => { const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await UserHelpers.getUserMutes(user, args.max_id, args.since_id, args.min_id, args.limit, cache); + const res = await UserHelpers.getUserMutes(ctx.user, args.max_id, args.since_id, args.min_id, args.limit, ctx.cache); ctx.body = res.data.map(m => convertAccountId(m)); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; } - }); - router.get("/v1/blocks", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); + ); + router.get("/v1/blocks", + auth(true, ["read:blocks"]), + async (ctx) => { const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await UserHelpers.getUserBlocks(user, args.max_id, args.since_id, args.min_id, args.limit); - const blocks = await UserConverter.encodeMany(res.data, cache); + const res = await UserHelpers.getUserBlocks(ctx.user, args.max_id, args.since_id, args.min_id, args.limit); + const blocks = await UserConverter.encodeMany(res.data, ctx.cache); ctx.body = blocks.map(b => convertAccountId(b)); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; } - }); - router.get("/v1/follow_requests", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); + ); + router.get("/v1/follow_requests", + auth(true, ["read:follows"]), + async (ctx) => { const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await UserHelpers.getUserFollowRequests(user, args.max_id, args.since_id, args.min_id, args.limit); - const requests = await UserConverter.encodeMany(res.data, cache); + const res = await UserHelpers.getUserFollowRequests(ctx.user, args.max_id, args.since_id, args.min_id, args.limit); + const requests = await UserConverter.encodeMany(res.data, ctx.cache); ctx.body = requests.map(b => convertAccountId(b)); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } 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/follow_requests/:id/authorize", + auth(true, ["write:follows"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.acceptFollowRequest(target, user); - ctx.body = convertRelationshipId(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.acceptFollowRequest(target, ctx.user); + ctx.body = convertRelationshipId(result); }, ); router.post<{ Params: { id: string } }>( "/v1/follow_requests/:id/reject", + auth(true, ["write:follows"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.rejectFollowRequest(target, user); - ctx.body = convertRelationshipId(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } + const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.rejectFollowRequest(target, ctx.user); + ctx.body = convertRelationshipId(result); }, ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index 373cc2285..c5e7589ce 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -1,72 +1,68 @@ import Router from "@koa/router"; import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; import { convertId, IdType } from "@/misc/convert-id.js"; - -const readScope = [ - "read:account", - "read:drive", - "read:blocks", - "read:favorites", - "read:following", - "read:messaging", - "read:mutes", - "read:notifications", - "read:reactions", - "read:pages", - "read:page-likes", - "read:user-groups", - "read:channels", - "read:gallery", - "read:gallery-likes", -]; -const writeScope = [ - "write:account", - "write:drive", - "write:blocks", - "write:favorites", - "write:following", - "write:messaging", - "write:mutes", - "write:notes", - "write:notifications", - "write:reactions", - "write:votes", - "write:pages", - "write:page-likes", - "write:user-groups", - "write:channels", - "write:gallery", - "write:gallery-likes", -]; +import { AuthConverter } from "@/server/api/mastodon/converters/auth.js"; +import { v4 as uuid } from "uuid"; export function setupEndpointsAuth(router: Router): void { router.post("/v1/apps", async (ctx) => { const body: any = ctx.request.body || ctx.request.query; - try { - let scope = body.scopes; - if (typeof scope === "string") scope = scope.split(" "); - const pushScope = new Set(); - for (const s of scope) { - if (s.match(/^read/)) for (const r of readScope) pushScope.add(r); - if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r); - } - const scopeArr = Array.from(pushScope); + let scope = body.scopes; + if (typeof scope === "string") scope = scope.split(" "); + const scopeArr = AuthConverter.decode(scope); + const red = body.redirect_uris; + const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']); + ctx.body = { + id: convertId(appData.id, IdType.MastodonId), + name: appData.name, + website: body.website, + redirect_uri: red, + client_id: Buffer.from(appData.url ?? "").toString("base64"), + client_secret: appData.clientSecret, + }; + }); +} - const red = body.redirect_uris; - const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']); - const returns = { - id: convertId(appData.id, IdType.MastodonId), - name: appData.name, - website: body.website, - redirect_uri: red, - client_id: Buffer.from(appData.url ?? "").toString("base64"), - client_secret: appData.clientSecret, +export function setupEndpointsAuthRoot(router: Router): void { + router.get("/oauth/authorize", async (ctx) => { + const { client_id, state, redirect_uri } = ctx.request.query; + let param = "mastodon=true"; + if (state) param += `&state=${state}`; + if (redirect_uri) param += `&redirect_uri=${redirect_uri}`; + const client = client_id ? client_id : ""; + ctx.redirect( + `${Buffer.from(client.toString(), "base64").toString()}?${param}`, + ); + }); + + router.post("/oauth/token", async (ctx) => { + const body: any = ctx.request.body || ctx.request.query; + if (body.grant_type === "client_credentials") { + ctx.body = { + access_token: uuid(), + token_type: "Bearer", + scope: "read", + created_at: Math.floor(new Date().getTime() / 1000), }; - ctx.body = returns; - } catch (e: any) { - console.error(e); + return; + } + let token = null; + if (body.code) { + token = body.code; + } + try { + const accessToken = await AuthHelpers.getAuthToken(body.client_secret, token ? token : ""); + const ret = { + access_token: accessToken, + token_type: "Bearer", + scope: body.scope || "read write follow push", + created_at: Math.floor(new Date().getTime() / 1000), + }; + ctx.body = ret; + } catch (err: any) { + console.error(err); ctx.status = 401; - ctx.body = e.response.data; + ctx.body = err.response.data; } }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index a788eb7fd..a2bc5401f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -1,11 +1,18 @@ import Router from "@koa/router"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; export function setupEndpointsFilter(router: Router): void { - router.get(["/v1/filters", "/v2/filters"], async (ctx) => { - ctx.body = []; - }); - router.post(["/v1/filters", "/v2/filters"], async (ctx) => { - ctx.status = 400; - ctx.body = { error: "Please change word mute settings in the web frontend settings." }; - }); + router.get(["/v1/filters", "/v2/filters"], + auth(true, ['read:filters']), + async (ctx) => { + ctx.body = []; + } + ); + router.post(["/v1/filters", "/v2/filters"], + auth(true, ['write:filters']), + async (ctx) => { + ctx.status = 400; + ctx.body = { error: "Please change word mute settings in the web frontend settings." }; + } + ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/list.ts b/packages/backend/src/server/api/mastodon/endpoints/list.ts index 9f1531f34..fa1e05dea 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/list.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/list.ts @@ -1,243 +1,115 @@ import Router from "@koa/router"; import { convertAccountId, convertListId, } from "../converters.js"; import { convertId, IdType } from "../../index.js"; -import authenticate from "@/server/api/authenticate.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"; import { UserLists } from "@/models/index.js"; -import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; import { getUser } from "@/server/api/common/getters.js"; import { toArray } from "@/prelude/array.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.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) + router.get("/v1/lists", + auth(true, ['read:lists']), + async (ctx, reply) => { + ctx.body = await ListHelpers.getLists(ctx.user) .then(p => p.map(list => convertListId(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", + auth(true, ['read:lists']), async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; + const id = convertId(ctx.params.id, IdType.IceshrimpId); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - - ctx.body = await ListHelpers.getList(user, id) - .then(p => convertListId(p)); - } catch (e: any) { - ctx.status = 404; - } + ctx.body = await ListHelpers.getListOr404(ctx.user, id) + .then(p => convertListId(p)); }, ); - router.post("/v1/lists", async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; + router.post("/v1/lists", + auth(true, ['write:lists']), + async (ctx, reply) => { + const body = ctx.request.body as any; + const title = (body.title ?? '').trim(); - if (!user) { - ctx.status = 401; - return; - } + ctx.body = await ListHelpers.createList(ctx.user, title) + .then(p => convertListId(p)); + } + ); + router.put<{ Params: { id: string } }>( + "/v1/lists/:id", + auth(true, ['write:lists']), + async (ctx, reply) => { + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const list = await UserLists.findOneBy({userId: ctx.user.id, id: id}); + if (!list) throw new MastoApiError(404); const body = ctx.request.body as any; const title = (body.title ?? '').trim(); - if (title.length < 1) { - ctx.body = { error: "Title must not be empty" }; - ctx.status = 400; - return - } - - ctx.body = await ListHelpers.createList(user, title) + ctx.body = await ListHelpers.updateList(ctx.user, list, title) .then(p => convertListId(p)); - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; - } - }); - router.put<{ 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); - const list = await UserLists.findOneBy({userId: user.id, id: id}); - - if (!list) { - ctx.status = 404; - return; - } - - const body = ctx.request.body as any; - const title = (body.title ?? '').trim(); - if (title.length < 1) { - ctx.body = { error: "Title must not be empty" }; - ctx.status = 400; - return - } - - ctx.body = await ListHelpers.updateList(user, list, title) - .then(p => convertListId(p)); - } 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", + auth(true, ['write:lists']), async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const list = await UserLists.findOneBy({userId: ctx.user.id, id: id}); + if (!list) throw new MastoApiError(404); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const list = await UserLists.findOneBy({userId: user.id, id: id}); - - if (!list) { - ctx.status = 404; - return; - } - - await ListHelpers.deleteList(user, list); - ctx.body = {}; - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; - } + await ListHelpers.deleteList(ctx.user, list); + ctx.body = {}; }, ); router.get<{ Params: { id: string } }>( "/v1/lists/:id/accounts", + auth(true, ['read:lists']), async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); + const res = await ListHelpers.getListUsers(ctx.user, id, args.max_id, args.since_id, args.min_id, args.limit); + const accounts = await UserConverter.encodeMany(res.data); - 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 => convertAccountId(account)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } catch (e: any) { - ctx.status = 404; - } + ctx.body = accounts.map(account => convertAccountId(account)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); }, ); router.post<{ Params: { id: string } }>( "/v1/lists/:id/accounts", + auth(true, ['write:lists']), async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const list = await UserLists.findOneBy({userId: ctx.user.id, id: id}); + if (!list) throw new MastoApiError(404); - if (!user) { - ctx.status = 401; - return; - } + const body = ctx.request.body as any; + if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field"); - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const list = await UserLists.findOneBy({userId: user.id, id: id}); - - if (!list) { - ctx.status = 404; - return; - } - - const body = ctx.request.body as any; - if (!body['account_ids']) { - ctx.status = 400; - ctx.body = { error: "Missing account_ids[] field" }; - return; - } - - const ids = toArray(body['account_ids']).map(p => convertId(p, IdType.IceshrimpId)); - const targets = await Promise.all(ids.map(p => getUser(p))); - await ListHelpers.addToList(user, list, targets); - ctx.body = {} - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; - } + const ids = toArray(body['account_ids']).map(p => convertId(p, IdType.IceshrimpId)); + const targets = await Promise.all(ids.map(p => getUser(p))); + await ListHelpers.addToList(ctx.user, list, targets); + ctx.body = {} }, ); router.delete<{ Params: { id: string } }>( "/v1/lists/:id/accounts", + auth(true, ['write:lists']), async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const list = await UserLists.findOneBy({userId: ctx.user.id, id: id}); + if (!list) throw new MastoApiError(404); - if (!user) { - ctx.status = 401; - return; - } + const body = ctx.request.body as any; + if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field"); - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const list = await UserLists.findOneBy({userId: user.id, id: id}); - - if (!list) { - ctx.status = 404; - return; - } - - const body = ctx.request.body as any; - if (!body['account_ids']) { - ctx.status = 400; - ctx.body = { error: "Missing account_ids[] field" }; - return; - } - - const ids = toArray(body['account_ids']).map(p => convertId(p, IdType.IceshrimpId)); - const targets = await Promise.all(ids.map(p => getUser(p))); - await ListHelpers.removeFromList(user, list, targets); - ctx.body = {} - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; - } + const ids = toArray(body['account_ids']).map(p => convertId(p, IdType.IceshrimpId)); + const targets = await Promise.all(ids.map(p => getUser(p))); + await ListHelpers.removeFromList(ctx.user, list, targets); + ctx.body = {} }, ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/media.ts b/packages/backend/src/server/api/mastodon/endpoints/media.ts index 099ad82be..c336d8de2 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/media.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/media.ts @@ -1,94 +1,41 @@ import Router from "@koa/router"; import { convertId, IdType } from "@/misc/convert-id.js"; import { convertAttachmentId } from "@/server/api/mastodon/converters.js"; -import authenticate from "@/server/api/authenticate.js"; import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js"; import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { Files } from "formidable"; import { toSingleLast } from "@/prelude/array.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; export function setupEndpointsMedia(router: Router): void { - router.get<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + router.get<{ Params: { id: string } }>("/v1/media/:id", + auth(true, ['write:media']), + async (ctx) => { const id = convertId(ctx.params.id, IdType.IceshrimpId); - const file = await MediaHelpers.getMediaPacked(user, id); - - if (!file) { - ctx.status = 404; - ctx.body = {error: "File not found"}; - return; - } - + const file = await MediaHelpers.getMediaPackedOr404(ctx.user, id); const attachment = FileConverter.encode(file); ctx.body = convertAttachmentId(attachment); - } catch (e: any) { - console.error(e); - ctx.status = 500; - ctx.body = e.response.data; } - }); - router.put<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + ); + router.put<{ Params: { id: string } }>("/v1/media/:id", + auth(true, ['write:media']), + async (ctx) => { const id = convertId(ctx.params.id, IdType.IceshrimpId); - const file = await MediaHelpers.getMedia(user, id); - - if (!file) { - ctx.status = 404; - ctx.body = {error: "File not found"}; - return; - } - - const result = await MediaHelpers.updateMedia(user, file, ctx.request.body) + const file = await MediaHelpers.getMediaOr404(ctx.user, id); + const result = await MediaHelpers.updateMedia(ctx.user, file, ctx.request.body) .then(p => FileConverter.encode(p)); ctx.body = convertAttachmentId(result); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; } - }); - - router.post(["/v2/media", "/v1/media"], async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + ); + router.post(["/v2/media", "/v1/media"], + auth(true, ['write:media']), + async (ctx) => { //FIXME: why do we have to cast this to any first? const files = (ctx.request as any).files as Files | undefined; const file = toSingleLast(files?.file); - if (!file) { - ctx.body = {error: "No image"}; - ctx.status = 400; - return; - } - const result = await MediaHelpers.uploadMedia(user, file, ctx.request.body) + const result = await MediaHelpers.uploadMedia(ctx.user, file, ctx.request.body) .then(p => FileConverter.encode(p)); ctx.body = convertAttachmentId(result); - } catch (e: any) { - console.error(e); - ctx.status = 500; - ctx.body = {error: e.message}; } - }); + ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/misc.ts b/packages/backend/src/server/api/mastodon/endpoints/misc.ts index 666775d74..8b205d752 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/misc.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/misc.ts @@ -1,136 +1,80 @@ import Router from "@koa/router"; import { MiscHelpers } from "@/server/api/mastodon/helpers/misc.js"; -import authenticate from "@/server/api/authenticate.js"; import { argsToBools, limitToInt } from "@/server/api/mastodon/endpoints/timeline.js"; import { Announcements } from "@/models/index.js"; import { convertAnnouncementId, convertSuggestionIds } from "@/server/api/mastodon/converters.js"; import { convertId, IdType } from "@/misc/convert-id.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; export function setupEndpointsMisc(router: Router): void { - router.get("/v1/custom_emojis", async (ctx) => { - try { + router.get("/v1/custom_emojis", + async (ctx) => { ctx.body = await MiscHelpers.getCustomEmoji(); - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; } - }); + ); - router.get("/v1/instance", async (ctx) => { - try { + router.get("/v1/instance", + async (ctx) => { ctx.body = await MiscHelpers.getInstance(); - } catch (e: any) { - console.error(e); - ctx.status = 500; - ctx.body = { error: e.message }; } - }); - - router.get("/v1/announcements", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } + ); + router.get("/v1/announcements", + auth(true), + async (ctx) => { const args = argsToBools(ctx.query, ['with_dismissed']); - ctx.body = await MiscHelpers.getAnnouncements(user, args['with_dismissed']) + ctx.body = await MiscHelpers.getAnnouncements(ctx.user, args['with_dismissed']) .then(p => p.map(x => convertAnnouncementId(x))); - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; } - }); + ); router.post<{ Params: { id: string } }>( "/v1/announcements/:id/dismiss", + auth(true, ['write:accounts']), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const announcement = await Announcements.findOneBy({id: id}); + if (!announcement) throw new MastoApiError(404); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const announcement = await Announcements.findOneBy({id: id}); - - if (!announcement) { - ctx.status = 404; - return; - } - - await MiscHelpers.dismissAnnouncement(announcement, user); - ctx.body = {}; - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; - } + await MiscHelpers.dismissAnnouncement(announcement, ctx.user); + ctx.body = {}; }, ); - router.get(["/v1/trends/tags", "/v1/trends"], async (ctx) => { - try { + router.get(["/v1/trends/tags", "/v1/trends"], + async (ctx) => { const args = limitToInt(ctx.query); ctx.body = await MiscHelpers.getTrendingHashtags(args.limit, args.offset); - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; } - }); + ); - router.get("/v1/trends/statuses", async (ctx) => { - try { + router.get("/v1/trends/statuses", + async (ctx) => { const args = limitToInt(ctx.query); ctx.body = await MiscHelpers.getTrendingStatuses(args.limit, args.offset); - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; } - }); + ); - router.get("/v1/trends/links", async (ctx) => { - ctx.body = []; - }); - - router.get("/v1/preferences", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - ctx.body = await MiscHelpers.getPreferences(user); - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; + router.get("/v1/trends/links", + async (ctx) => { + ctx.body = []; } - }); + ); - router.get("/v2/suggestions", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } + router.get("/v1/preferences", + auth(true, ['read:accounts']), + async (ctx) => { + ctx.body = await MiscHelpers.getPreferences(ctx.user); + } + ); + router.get("/v2/suggestions", + auth(true, ['read']), + async (ctx) => { const args = limitToInt(ctx.query); - ctx.body = await MiscHelpers.getFollowSuggestions(user, args.limit) + ctx.body = await MiscHelpers.getFollowSuggestions(ctx.user, args.limit) .then(p => p.map(x => convertSuggestionIds(x))); - } catch (e: any) { - ctx.status = 500; - ctx.body = { error: e.message }; } - }); + ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 7133a6817..3105456a8 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -1,118 +1,57 @@ import Router from "@koa/router"; import { convertId, IdType } from "../../index.js"; import { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js"; -import { convertConversationIds, convertNotificationIds } from "../converters.js"; -import authenticate from "@/server/api/authenticate.js"; +import { convertNotificationIds } from "../converters.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.js"; import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js"; -import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js"; -import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; export function setupEndpointsNotifications(router: Router): void { - router.get("/v1/notifications", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + router.get("/v1/notifications", + auth(true, ['read:notifications']), + async (ctx) => { const cache = UserHelpers.getFreshAccountCache(); const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)), ['types[]', 'exclude_types[]']); - const data = NotificationHelpers.getNotifications(user, args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id) - .then(p => NotificationConverter.encodeMany(p, user, cache)) + const data = NotificationHelpers.getNotifications(ctx.user, args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id) + .then(p => NotificationConverter.encodeMany(p, ctx.user, cache)) .then(p => p.map(n => convertNotificationIds(n))); ctx.body = await data; - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; } - }); + ); - router.get("/v1/notifications/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const notification = await NotificationHelpers.getNotification(convertId(ctx.params.id, IdType.IceshrimpId), user); - if (notification === null) { - ctx.status = 404; - return; - } - - ctx.body = convertNotificationIds(await NotificationConverter.encode(notification, user)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; + router.get("/v1/notifications/:id", + auth(true, ['read:notifications']), + async (ctx) => { + const notification = await NotificationHelpers.getNotificationOr404(convertId(ctx.params.id, IdType.IceshrimpId), ctx.user); + ctx.body = convertNotificationIds(await NotificationConverter.encode(notification, ctx.user)); } - }); + ); - router.post("/v1/notifications/clear", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - await NotificationHelpers.clearAllNotifications(user); + router.post("/v1/notifications/clear", + auth(true, ['write:notifications']), + async (ctx) => { + await NotificationHelpers.clearAllNotifications(ctx.user); ctx.body = {}; - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; } - }); + ); - router.post("/v1/notifications/:id/dismiss", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const notification = await NotificationHelpers.getNotification(convertId(ctx.params.id, IdType.IceshrimpId), user); - if (notification === null) { - ctx.status = 404; - return; - } - - await NotificationHelpers.dismissNotification(notification.id, user); + router.post("/v1/notifications/:id/dismiss", + auth(true, ['write:notifications']), + async (ctx) => { + const notification = await NotificationHelpers.getNotificationOr404(convertId(ctx.params.id, IdType.IceshrimpId), ctx.user); + await NotificationHelpers.dismissNotification(notification.id, ctx.user); ctx.body = {}; - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; } - }); + ); - router.post("/v1/conversations/:id/read", async (ctx, reply) => { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; + router.post("/v1/conversations/:id/read", + auth(true, ['write:conversations']), + async (ctx, reply) => { + const id = convertId(ctx.params.id, IdType.IceshrimpId); + await NotificationHelpers.markConversationAsRead(id, ctx.user); + ctx.body = {}; } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - await NotificationHelpers.markConversationAsRead(id, user); - ctx.body = {}; - }); + ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index 6f0b1fecc..4b8d31911 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -1,54 +1,26 @@ import Router from "@koa/router"; import { argsToBools, convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js"; import { convertSearchIds } from "../converters.js"; -import authenticate from "@/server/api/authenticate.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { SearchHelpers } from "@/server/api/mastodon/helpers/search.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; export function setupEndpointsSearch(router: Router): void { - router.get("/v1/search", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } - + router.get(["/v1/search", "/v2/search"], + auth(true, ['read:search']), + async (ctx) => { const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed']))); const cache = UserHelpers.getFreshAccountCache(); - const result = await SearchHelpers.search(user, args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, cache); - - ctx.body = { - ...convertSearchIds(result), - hashtags: result.hashtags.map(p => p.name), - }; - } catch (e: any) { - console.error(e); - ctx.status = 400; - ctx.body = {error: e.message}; - } - }); - router.get("/v2/search", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } - - const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed']))); - const cache = UserHelpers.getFreshAccountCache(); - const result = await SearchHelpers.search(user, args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, cache); + const result = await SearchHelpers.search(ctx.user, args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, cache); ctx.body = convertSearchIds(result); - } catch (e: any) { - console.error(e); - ctx.status = 400; - ctx.body = {error: e.message}; + + if (ctx.path === "/v1/search") { + ctx.body = { + ...ctx.body, + hashtags: result.hashtags.map(p => p.name), + }; + } } - }); + ); } \ No newline at end of file diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 3d2318af2..b0ab32886 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -2,36 +2,21 @@ import Router from "@koa/router"; import { convertId, IdType } from "../../index.js"; import { convertAccountId, convertPollId, convertStatusIds, convertStatusEditIds, convertStatusSourceId, } from "../converters.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; -import { getNote } from "@/server/api/common/getters.js"; -import authenticate from "@/server/api/authenticate.js"; import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; -import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { UserConverter } from "@/server/api/mastodon/converters/user.js"; -import { Cache } from "@/misc/cache.js"; -import AsyncLock from "async-lock"; -import { ILocalUser } from "@/models/entities/user.js"; import { PollHelpers } from "@/server/api/mastodon/helpers/poll.js"; import { toArray } from "@/prelude/array.js"; - -const postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60); -const postIdempotencyLocks = new AsyncLock(); +import { auth } from "@/server/api/mastodon/middleware/auth.js"; export function setupEndpointsStatus(router: Router): void { - router.post("/v1/statuses", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const key = getIdempotencyKey(ctx.headers, user); + router.post("/v1/statuses", + auth(true, ['write:statuses']), + async (ctx) => { + const key = NoteHelpers.getIdempotencyKey(ctx.headers, ctx.user); if (key !== null) { - const result = await getFromIdempotencyCache(key); + const result = await NoteHelpers.getFromIdempotencyCache(key); if (result) { ctx.body = result; @@ -40,645 +25,263 @@ export function setupEndpointsStatus(router: Router): void { } let request = NoteHelpers.normalizeComposeOptions(ctx.request.body); - ctx.body = await NoteHelpers.createNote(request, user) - .then(p => NoteConverter.encode(p, user)) + ctx.body = await NoteHelpers.createNote(request, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) .then(p => convertStatusIds(p)); - if (key !== null) postIdempotencyCache.set(key, {status: ctx.body}); - } catch (e: any) { - console.error(e); - ctx.status = 500; - ctx.body = {error: e.message}; + if (key !== null) NoteHelpers.postIdempotencyCache.set(key, {status: ctx.body}); } - }); - router.put("/v1/statuses/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + ); + router.put("/v1/statuses/:id", + auth(true, ['write:statuses']), + async (ctx) => { const noteId = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); - if (!note) { - if (!note) { - ctx.status = 404; - ctx.body = { - error: "Note not found" - }; - return; - } - } - + const note = await NoteHelpers.getNoteOr404(noteId, ctx.user); let request = NoteHelpers.normalizeEditOptions(ctx.request.body); - ctx.body = await NoteHelpers.editNote(request, note, user) - .then(p => NoteConverter.encode(p, user)) + ctx.body = await NoteHelpers.editNote(request, note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = ctx.status == 404 ? 404 : 401; - ctx.body = e.response.data; } - }); - router.get<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - + ); + router.get<{ Params: { id: string } }>("/v1/statuses/:id", + auth(false, ["read:statuses"]), + async (ctx) => { const noteId = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); + const note = await NoteHelpers.getNoteOr404(noteId, ctx.user); - if (!note) { - ctx.status = 404; - return; - } - - const status = await NoteConverter.encode(note, user); + const status = await NoteConverter.encode(note, ctx.user); ctx.body = convertStatusIds(status); - } catch (e: any) { - console.error(e); - ctx.status = ctx.status == 404 ? 404 : 401; - ctx.body = e.response.data; } - }); - router.delete<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - + ); + router.delete<{ Params: { id: string } }>("/v1/statuses/:id", + auth(true, ['write:statuses']), + async (ctx) => { const noteId = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); - - if (!note) { - ctx.status = 404; - ctx.body = { - error: "Note not found" - }; - return; - } - - if (user.id !== note.userId) { - ctx.status = 403; - ctx.body = { - error: "Cannot delete someone else's note" - }; - return; - } - - ctx.body = await NoteHelpers.deleteNote(note, user) + const note = await NoteHelpers.getNoteOr404(noteId, ctx.user); + ctx.body = await NoteHelpers.deleteNote(note, ctx.user) .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(`Error processing ${ctx.method} /api${ctx.path}: ${e.message}`); - ctx.status = 500; - ctx.body = { - error: e.message - } } - }); + ); router.get<{ Params: { id: string } }>( "/v1/statuses/:id/context", + auth(false, ["read:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); + const ancestors = await NoteHelpers.getNoteAncestors(note, ctx.user, ctx.user ? 4096 : 60) + .then(n => NoteConverter.encodeMany(n, ctx.user, ctx.cache)) + .then(n => n.map(s => convertStatusIds(s))); + const descendants = await NoteHelpers.getNoteDescendants(note, ctx.user, ctx.user ? 4096 : 40, ctx.user ? 4096 : 20) + .then(n => NoteConverter.encodeMany(n, ctx.user, ctx.cache)) + .then(n => n.map(s => convertStatusIds(s))); - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const note = await getNote(id, user ?? null).then(n => n).catch(() => null); - if (!note) { - if (!note) { - ctx.status = 404; - return; - } - } - - const ancestors = await NoteHelpers.getNoteAncestors(note, user, user ? 4096 : 60) - .then(n => NoteConverter.encodeMany(n, user, cache)) - .then(n => n.map(s => convertStatusIds(s))); - const descendants = await NoteHelpers.getNoteDescendants(note, user, user ? 4096 : 40, user ? 4096 : 20) - .then(n => NoteConverter.encodeMany(n, user, cache)) - .then(n => n.map(s => convertStatusIds(s))); - - ctx.body = { - ancestors, - descendants, - }; - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, + ctx.body = { + ancestors, + descendants, + }; + } ); router.get<{ Params: { id: string } }>( "/v1/statuses/:id/history", + auth(false, ["read:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const res = await NoteHelpers.getNoteEditHistory(note); - ctx.body = res.map(p => convertStatusEditIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); + const res = await NoteHelpers.getNoteEditHistory(note); + ctx.body = res.map(p => convertStatusEditIds(p)); + } ); router.get<{ Params: { id: string } }>( "/v1/statuses/:id/source", + auth(true, ["read:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const src = NoteHelpers.getNoteSource(note); - ctx.body = convertStatusSourceId(src); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); + const src = NoteHelpers.getNoteSource(note); + ctx.body = convertStatusSourceId(src); + } ); router.get<{ Params: { id: string } }>( "/v1/statuses/:id/reblogged_by", + auth(false, ["read:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await NoteHelpers.getNoteRebloggedBy(note, user, args.max_id, args.since_id, args.min_id, args.limit); - const users = await UserConverter.encodeMany(res.data, cache); - ctx.body = users.map(m => convertAccountId(m)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await NoteHelpers.getNoteRebloggedBy(note, ctx.user, args.max_id, args.since_id, args.min_id, args.limit); + const users = await UserConverter.encodeMany(res.data, ctx.cache); + ctx.body = users.map(m => convertAccountId(m)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); + } ); router.get<{ Params: { id: string } }>( "/v1/statuses/:id/favourited_by", + auth(false, ["read:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit); - const users = await UserConverter.encodeMany(res.data, cache); - ctx.body = users.map(m => convertAccountId(m)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit); + const users = await UserConverter.encodeMany(res.data, ctx.cache); + ctx.body = users.map(m => convertAccountId(m)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); + } ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/favourite", + auth(true, ["write:favourites"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); + const reaction = await NoteHelpers.getDefaultReaction(); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const reaction = await NoteHelpers.getDefaultReaction().catch(_ => null); - - if (reaction === null) { - ctx.status = 500; - return; - } - - ctx.body = await NoteHelpers.reactToNote(note, user, reaction) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 400; - ctx.body = e.response.data; - } - }, + ctx.body = await NoteHelpers.reactToNote(note, ctx.user, reaction) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); + } ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unfavourite", + auth(true, ["write:favourites"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.removeReactFromNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.removeReactFromNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/reblog", + auth(true, ["write:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.reblogNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.reblogNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unreblog", + auth(true, ["write:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.unreblogNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.unreblogNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/bookmark", + auth(true, ["write:bookmarks"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.bookmarkNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.bookmarkNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unbookmark", + auth(true, ["write:bookmarks"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.unbookmarkNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.unbookmarkNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/pin", + auth(true, ["write:accounts"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.pinNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.pinNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string } }>( "/v1/statuses/:id/unpin", + auth(true, ["write:accounts"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.unpinNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.unpinNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string; name: string } }>( "/v1/statuses/:id/react/:name", + auth(true, ["write:favourites"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.reactToNote(note, user, ctx.params.name) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.reactToNote(note, ctx.user, ctx.params.name) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); router.post<{ Params: { id: string; name: string } }>( "/v1/statuses/:id/unreact/:name", + auth(true, ["write:favourites"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.removeReactFromNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatusIds(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } + ctx.body = await NoteHelpers.removeReactFromNote(note, ctx.user) + .then(p => NoteConverter.encode(p, ctx.user)) + .then(p => convertStatusIds(p)); }, ); - router.get<{ Params: { id: string } }>("/v1/polls/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - + router.get<{ Params: { id: string } }>("/v1/polls/:id", + auth(false, ["read:statuses"]), + async (ctx) => { const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null || !note.hasPoll) { - ctx.status = 404; - return; - } - - const data = await PollHelpers.getPoll(note, user); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); + const data = await PollHelpers.getPoll(note, ctx.user); ctx.body = convertPollId(data); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } }); router.post<{ Params: { id: string } }>( "/v1/polls/:id/votes", + auth(true, ["write:statuses"]), async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await NoteHelpers.getNoteOr404(id, ctx.user); - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null || !note.hasPoll) { - ctx.status = 404; - return; - } - - const body: any = ctx.request.body; - const choices = toArray(body.choices ?? []).map(p => parseInt(p)); - if (choices.length < 1) { - ctx.status = 400; - ctx.body = {error: 'Must vote for at least one option'}; - return; - } - - const data = await PollHelpers.voteInPoll(choices, note, user); - ctx.body = convertPollId(data); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; + const body: any = ctx.request.body; + const choices = toArray(body.choices ?? []).map(p => parseInt(p)); + if (choices.length < 1) { + ctx.status = 400; + ctx.body = {error: 'Must vote for at least one option'}; + return; } + + const data = await PollHelpers.voteInPoll(choices, note, ctx.user); + ctx.body = convertPollId(data); }, ); } - -function getIdempotencyKey(headers: any, user: ILocalUser): string | null { - if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null; - return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1)! : headers["idempotency-key"]}`; -} - -async function getFromIdempotencyCache(key: string): Promise { - return postIdempotencyLocks.acquire(key, async (): Promise => { - if (await postIdempotencyCache.get(key) !== undefined) { - let i = 5; - while ((await postIdempotencyCache.get(key))?.status === undefined) { - if (++i > 5) throw new Error('Post is duplicate but unable to resolve original'); - await new Promise((resolve) => { - setTimeout(resolve, 500); - }); - } - - return (await postIdempotencyCache.get(key))?.status; - } else { - await postIdempotencyCache.set(key, {}); - return undefined; - } - }); -} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 22918748b..35e934cb6 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -2,13 +2,15 @@ import Router from "@koa/router"; import { ParsedUrlQuery } from "querystring"; import { convertConversationIds, convertStatusIds, } 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 { UserLists } from "@/models/index.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +//TODO: Move helper functions to a helper class export function limitToInt(q: ParsedUrlQuery, additional: string[] = []) { let object: any = q; if (q.limit) @@ -63,138 +65,63 @@ export function normalizeUrlQuery(q: ParsedUrlQuery, arrayKeys: string[] = []): } export function setupEndpointsTimeline(router: Router): void { - router.get("/v1/timelines/public", async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } - + router.get("/v1/timelines/public", + auth(true, ['read:statuses']), + async (ctx, reply) => { const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query)))); const cache = UserHelpers.getFreshAccountCache(); - 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, cache)); + const tl = await TimelineHelpers.getPublicTimeline(ctx.user, args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote) + .then(n => NoteConverter.encodeMany(n, ctx.user, cache)); ctx.body = tl.map(s => convertStatusIds(s)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } }); router.get<{ Params: { hashtag: string } }>( "/v1/timelines/tag/:hashtag", + auth(false, ['read:statuses']), async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } - - const tag = (ctx.params.hashtag ?? '').trim(); - if (tag.length < 1) { - ctx.status = 400; - ctx.body = { error: "tag cannot be empty" }; - return; - } - - const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))), ['any[]', 'all[]', 'none[]']); - const cache = UserHelpers.getFreshAccountCache(); - const tl = await TimelineHelpers.getTagTimeline(user, tag, args.max_id, args.since_id, args.min_id, args.limit, args['any[]'] ?? [], args['all[]'] ?? [], args['none[]'] ?? [], args.only_media, args.local, args.remote) - .then(n => NoteConverter.encodeMany(n, user, cache)); - - ctx.body = tl.map(s => convertStatusIds(s)); - } catch (e: any) { - ctx.status = 400; - ctx.body = { error: e.message }; - } - }, - ); - router.get("/v1/timelines/home", async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } - - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); + const tag = (ctx.params.hashtag ?? '').trim(); + const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))), ['any[]', 'all[]', 'none[]']); const cache = UserHelpers.getFreshAccountCache(); - const tl = await TimelineHelpers.getHomeTimeline(user, args.max_id, args.since_id, args.min_id, args.limit) - .then(n => NoteConverter.encodeMany(n, user, cache)); + const tl = await TimelineHelpers.getTagTimeline(ctx.user, tag, args.max_id, args.since_id, args.min_id, args.limit, args['any[]'] ?? [], args['all[]'] ?? [], args['none[]'] ?? [], args.only_media, args.local, args.remote) + .then(n => NoteConverter.encodeMany(n, ctx.user, cache)); + + ctx.body = tl.map(s => convertStatusIds(s)); + }, + ); + router.get("/v1/timelines/home", + auth(true, ['read:statuses']), + async (ctx, reply) => { + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); + const cache = UserHelpers.getFreshAccountCache(); + const tl = await TimelineHelpers.getHomeTimeline(ctx.user, args.max_id, args.since_id, args.min_id, args.limit) + .then(n => NoteConverter.encodeMany(n, ctx.user, cache)); ctx.body = tl.map(s => convertStatusIds(s)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } }); router.get<{ Params: { listId: string } }>( "/v1/timelines/list/:listId", + auth(true, ['read:lists']), async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } - - const listId = convertId(ctx.params.listId, IdType.IceshrimpId); - const list = await UserLists.findOneBy({userId: user.id, id: listId}); - - if (!list) { - ctx.status = 404; - return; - } - - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); - const cache = UserHelpers.getFreshAccountCache(); - const tl = await TimelineHelpers.getListTimeline(user, list, args.max_id, args.since_id, args.min_id, args.limit) - .then(n => NoteConverter.encodeMany(n, user, cache)); - - ctx.body = tl.map(s => convertStatusIds(s)); - - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get("/v1/conversations", async (ctx, reply) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? undefined; - - if (!user) { - ctx.status = 401; - return; - } + const listId = convertId(ctx.params.listId, IdType.IceshrimpId); + const list = await UserLists.findOneBy({userId: ctx.user.id, id: listId}); + if (!list) throw new MastoApiError(404); const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); - const res = await TimelineHelpers.getConversations(user, args.max_id, args.since_id, args.min_id, args.limit); + const cache = UserHelpers.getFreshAccountCache(); + const tl = await TimelineHelpers.getListTimeline(ctx.user, list, args.max_id, args.since_id, args.min_id, args.limit) + .then(n => NoteConverter.encodeMany(n, ctx.user, cache)); + + ctx.body = tl.map(s => convertStatusIds(s)); + }, + ); + router.get("/v1/conversations", + auth(true, ['read:statuses']), + async (ctx, reply) => { + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); + const res = await TimelineHelpers.getConversations(ctx.user, args.max_id, args.since_id, args.min_id, args.limit); ctx.body = res.data.map(c => convertConversationIds(c)); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 20); - } 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/helpers/list.ts b/packages/backend/src/server/api/mastodon/helpers/list.ts index 0097a0383..4949c3f7c 100644 --- a/packages/backend/src/server/api/mastodon/helpers/list.ts +++ b/packages/backend/src/server/api/mastodon/helpers/list.ts @@ -6,6 +6,7 @@ import { UserList } from "@/models/entities/user-list.js"; import { pushUserToUserList } from "@/services/user-list/push.js"; import { genId } from "@/misc/gen-id.js"; import { publishUserListStream } from "@/services/stream.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; export class ListHelpers { public static async getLists(user: ILocalUser): Promise { @@ -26,9 +27,15 @@ export class ListHelpers { }); } + public static async getListOr404(user: ILocalUser, id: string): Promise { + return this.getList(user, id).catch(_ => { + throw new MastoApiError(404); + }) + } 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 list = await UserLists.findOneBy({userId: user.id, id: id}); + if (!list) throw new MastoApiError(404); const query = PaginationHelpers.makePaginationQuery( UserListJoinings.createQueryBuilder('member'), sinceId, @@ -99,6 +106,8 @@ export class ListHelpers { } public static async createList(user: ILocalUser, title: string): Promise { + if (title.length < 1) throw new MastoApiError(400, "Title must not be empty"); + const list = await UserLists.insert({ id: genId(), createdAt: new Date(), @@ -113,7 +122,9 @@ export class ListHelpers { } public static async updateList(user: ILocalUser, list: UserList, title: string) { + if (title.length < 1) throw new MastoApiError(400, "Title must not be empty"); if (user.id != list.userId) throw new Error("List is not owned by user"); + const partial = {name: title}; const result = await UserLists.update(list.id, partial) .then(async _ => await UserLists.findOneByOrFail({id: list.id})); diff --git a/packages/backend/src/server/api/mastodon/helpers/media.ts b/packages/backend/src/server/api/mastodon/helpers/media.ts index 6411ca98e..345447ca7 100644 --- a/packages/backend/src/server/api/mastodon/helpers/media.ts +++ b/packages/backend/src/server/api/mastodon/helpers/media.ts @@ -4,9 +4,12 @@ import { DriveFiles } from "@/models/index.js"; import { Packed } from "@/misc/schema.js"; import { DriveFile } from "@/models/entities/drive-file.js"; import { File } from "formidable"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; export class MediaHelpers { - public static async uploadMedia(user: ILocalUser, file: File, body: any): Promise> { + public static async uploadMedia(user: ILocalUser, file: File | undefined, body: any): Promise> { + if (!file) throw new MastoApiError(400, "Validation failed: File content type is invalid, File is invalid"); + return addFile({ user: user, path: file.filepath, @@ -40,7 +43,21 @@ export class MediaHelpers { .then(p => p ? DriveFiles.pack(p) : null); } + public static async getMediaPackedOr404(user: ILocalUser, id: string): Promise> { + return this.getMediaPacked(user, id).then(p => { + if (p) return p; + throw new MastoApiError(404); + }); + } + public static async getMedia(user: ILocalUser, id: string): Promise { return DriveFiles.findOneBy({id: id, userId: user.id}); } + + public static async getMediaOr404(user: ILocalUser, id: string): Promise { + return this.getMedia(user, id).then(p => { + if (p) return p; + throw new MastoApiError(404); + }); + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/note.ts b/packages/backend/src/server/api/mastodon/helpers/note.ts index 737203da3..a48aae59a 100644 --- a/packages/backend/src/server/api/mastodon/helpers/note.ts +++ b/packages/backend/src/server/api/mastodon/helpers/note.ts @@ -24,13 +24,23 @@ import mfm from "mfm-js"; import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import { toArray } from "@/prelude/array.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { Cache } from "@/misc/cache.js"; +import AsyncLock from "async-lock"; export class NoteHelpers { + public static postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60); + public static postIdempotencyLocks = new AsyncLock(); + public static async getDefaultReaction(): Promise { return Metas.createQueryBuilder() .select('"defaultReaction"') .execute() - .then(p => p[0].defaultReaction); + .then(p => p[0].defaultReaction) + .then(p => { + if (p != null) return p; + throw new MastoApiError(500, "Failed to get default reaction"); + }); } public static async reactToNote(note: Note, user: ILocalUser, reaction: string): Promise { @@ -122,7 +132,7 @@ export class NoteHelpers { } public static async deleteNote(note: Note, user: ILocalUser): Promise { - if (user.id !== note.userId) throw new Error("Can't delete someone elses note"); + if (user.id !== note.userId) throw new MastoApiError(404); const status = await NoteConverter.encode(note, user); await deleteNote(user, note); status.content = undefined; @@ -376,4 +386,35 @@ export class NoteHelpers { return result; } + + public static async getNoteOr404(id: string, user: ILocalUser | null): Promise { + return getNote(id, user).then(p => { + if (p === null) throw new MastoApiError(404); + return p; + }); + } + + public static getIdempotencyKey(headers: any, user: ILocalUser): string | null { + if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null; + return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1)! : headers["idempotency-key"]}`; + } + + public static async getFromIdempotencyCache(key: string): Promise { + return this.postIdempotencyLocks.acquire(key, async (): Promise => { + if (await this.postIdempotencyCache.get(key) !== undefined) { + let i = 5; + while ((await this.postIdempotencyCache.get(key))?.status === undefined) { + if (++i > 5) throw new Error('Post is duplicate but unable to resolve original'); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + } + + return (await this.postIdempotencyCache.get(key))?.status; + } else { + await this.postIdempotencyCache.set(key, {}); + return undefined; + } + }); + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/notification.ts b/packages/backend/src/server/api/mastodon/helpers/notification.ts index 5bed3745b..1ed6c5920 100644 --- a/packages/backend/src/server/api/mastodon/helpers/notification.ts +++ b/packages/backend/src/server/api/mastodon/helpers/notification.ts @@ -2,6 +2,7 @@ import { ILocalUser } from "@/models/entities/user.js"; import { Notes, Notifications } from "@/models/index.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { Notification } from "@/models/entities/notification.js"; +import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js"; export class NotificationHelpers { public static async getNotifications(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, types: string[] | undefined, excludeTypes: string[] | undefined, accountId: string | undefined): Promise { @@ -38,6 +39,13 @@ export class NotificationHelpers { return Notifications.findOneBy({id: id, notifieeId: user.id}); } + public static async getNotificationOr404(id: string, user: ILocalUser): Promise { + return this.getNotification(id, user).then(p => { + if (p) return p; + throw new MastoApiError(404); + }); + } + public static async dismissNotification(id: string, user: ILocalUser): Promise { const result = await Notifications.update({id: id, notifieeId: user.id}, {isRead: true}); } diff --git a/packages/backend/src/server/api/mastodon/helpers/poll.ts b/packages/backend/src/server/api/mastodon/helpers/poll.ts index 87d0e3989..6f93588d2 100644 --- a/packages/backend/src/server/api/mastodon/helpers/poll.ts +++ b/packages/backend/src/server/api/mastodon/helpers/poll.ts @@ -10,6 +10,7 @@ import { deliver } from "@/queue/index.js"; import { renderActivity } from "@/remote/activitypub/renderer/index.js"; import renderVote from "@/remote/activitypub/renderer/vote.js"; import { Not } from "typeorm"; +import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js"; export class PollHelpers { public static async getPoll(note: Note, user: ILocalUser | null): Promise { @@ -17,10 +18,12 @@ export class PollHelpers { } public static async voteInPoll(choices: number[], note: Note, user: ILocalUser): Promise { + if (!note.hasPoll) throw new MastoApiError(404); + for (const choice of choices) { const createdAt = new Date(); - if (!note.hasPoll) throw new Error('Note has no poll'); + if (!note.hasPoll) throw new MastoApiError(404); // Check blocking if (note.userId !== user.id) { diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index 3b913b9e6..b2c227e01 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -17,6 +17,7 @@ import { UserConverter } from "@/server/api/mastodon/converters/user.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; import { awaitAll } from "@/prelude/await-all.js"; import { unique } from "@/prelude/array.js"; +import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js"; export class TimelineHelpers { public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise { @@ -123,6 +124,8 @@ export class TimelineHelpers { public static async getTagTimeline(user: ILocalUser, tag: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, any: string[], all: string[], none: string[], onlyMedia: boolean = false, local: boolean = false, remote: boolean = false): Promise { if (limit > 40) limit = 40; + if (tag.length < 1) throw new MastoApiError(400, "Tag cannot be empty"); + if (local && remote) { throw new Error("local and remote are mutually exclusive options"); } diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts index 25150a540..6e5590f1e 100644 --- a/packages/backend/src/server/api/mastodon/helpers/user.ts +++ b/packages/backend/src/server/api/mastodon/helpers/user.ts @@ -37,9 +37,9 @@ import { IceshrimpVisibility, VisibilityConverter } from "@/server/api/mastodon/ import { Files } from "formidable"; import { toSingleLast } from "@/prelude/array.js"; import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js"; -import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { UserProfile } from "@/models/entities/user-profile.js"; import { verifyLink } from "@/services/fetch-rel-me.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; export type AccountCache = { locks: AsyncLock; @@ -192,8 +192,7 @@ export class UserHelpers { } public static async verifyCredentials(user: ILocalUser): Promise { - // re-fetch local user because auth user possibly contains outdated info - const acct = getUser(user.id).then(u => UserConverter.encode(u)); + const acct = UserConverter.encode(user); const profile = UserProfiles.findOneByOrFail({userId: user.id}); const privacy = this.getDefaultNoteVisibility(user); const fields = profile.then(profile => profile.fields.map(field => { @@ -220,10 +219,14 @@ export class UserHelpers { }); } - public static async getUserFromAcct(acct: string): Promise { + public static async getUserFromAcct(acct: string): Promise { const split = acct.toLowerCase().split('@'); if (split.length > 2) throw new Error('Invalid acct'); - return Users.findOneBy({usernameLower: split[0], host: split[1] ?? IsNull()}); + return Users.findOneBy({usernameLower: split[0], host: split[1] ?? IsNull()}) + .then(p => { + if (p) return p; + throw new MastoApiError(404); + }); } public static async getUserMutes(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise> { @@ -514,6 +517,18 @@ export class UserHelpers { }); } + public static async getUserCachedOr404(id: string, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise { + return this.getUserCached(id, cache).catch(_ => { + throw new MastoApiError(404); + }); + } + + public static async getUserOr404(id: string): Promise { + return getUser(id).catch(_ => { + throw new MastoApiError(404); + }); + } + public static getFreshAccountCache(): AccountCache { return { locks: new AsyncLock(), diff --git a/packages/backend/src/server/api/mastodon/index.ts b/packages/backend/src/server/api/mastodon/index.ts index 7f98b4ffb..a261fe309 100644 --- a/packages/backend/src/server/api/mastodon/index.ts +++ b/packages/backend/src/server/api/mastodon/index.ts @@ -1,4 +1,5 @@ -import Router from "@koa/router"; +import { DefaultContext } from "koa"; +import Router, { RouterContext } from "@koa/router"; import { setupEndpointsAuth } from "./endpoints/auth.js"; import { setupEndpointsAccount } from "./endpoints/account.js"; import { setupEndpointsStatus } from "./endpoints/status.js"; @@ -8,29 +9,19 @@ import { setupEndpointsNotifications } from "./endpoints/notifications.js"; import { setupEndpointsSearch } from "./endpoints/search.js"; import { setupEndpointsMedia } from "@/server/api/mastodon/endpoints/media.js"; import { setupEndpointsMisc } from "@/server/api/mastodon/endpoints/misc.js"; -import { HttpMethodEnum, koaBody } from "koa-body"; import { setupEndpointsList } from "@/server/api/mastodon/endpoints/list.js"; +import { AuthMiddleware } from "@/server/api/mastodon/middleware/auth.js"; +import { CatchErrorsMiddleware } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { apiLogger } from "@/server/api/logger.js"; +import { CacheMiddleware } from "@/server/api/mastodon/middleware/cache.js"; +import { KoaBodyMiddleware } from "@/server/api/mastodon/middleware/koa-body.js"; +import { NormalizeQueryMiddleware } from "@/server/api/mastodon/middleware/normalize-query.js"; + +export const logger = apiLogger.createSubLogger("mastodon"); +export type MastoContext = RouterContext & DefaultContext; export function setupMastodonApi(router: Router): void { - router.use( - koaBody({ - multipart: true, - urlencoded: true, - parsedMethods: [HttpMethodEnum.POST, HttpMethodEnum.PUT, HttpMethodEnum.PATCH, HttpMethodEnum.DELETE] // dear god mastodon why - }), - ); - - router.use(async (ctx, next) => { - if (ctx.request.query) { - if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) { - ctx.request.body = ctx.request.query; - } else { - ctx.request.body = {...ctx.request.body, ...ctx.request.query}; - } - } - await next(); - }); - + setupMiddleware(router); setupEndpointsAuth(router); setupEndpointsAccount(router); setupEndpointsStatus(router); @@ -42,3 +33,11 @@ export function setupMastodonApi(router: Router): void { setupEndpointsList(router); setupEndpointsMisc(router); } + +function setupMiddleware(router: Router): void { + router.use(KoaBodyMiddleware()); + router.use(NormalizeQueryMiddleware); + router.use(AuthMiddleware); + router.use(CacheMiddleware); + router.use(CatchErrorsMiddleware); +} diff --git a/packages/backend/src/server/api/mastodon/middleware/auth.ts b/packages/backend/src/server/api/mastodon/middleware/auth.ts new file mode 100644 index 000000000..df5044199 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/auth.ts @@ -0,0 +1,37 @@ +import authenticate from "@/server/api/authenticate.js"; +import { ILocalUser } from "@/models/entities/user.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { AuthConverter } from "@/server/api/mastodon/converters/auth.js"; + +export async function AuthMiddleware(ctx: MastoContext, next: () => Promise) { + const auth = await authenticate(ctx.headers.authorization, null, true); + ctx.user = auth[0] ?? null as ILocalUser | null; + ctx.scopes = auth[1]?.permission ?? [] as string[]; + + await next(); +} + +export function auth(required: boolean, scopes: string[] = []) { + return async function auth(ctx: MastoContext, next: () => Promise) { + if (required && !ctx.user) { + ctx.status = 401; + ctx.body = { error: "This method requires an authenticated user" }; + return; + } + + if (!AuthConverter.decode(scopes).every(p => ctx.scopes.includes(p))) { + if (required) { + ctx.status = 403; + ctx.body = {error: "This action is outside the authorized scopes"}; + } + else { + ctx.user = null; + ctx.scopes = []; + } + } + + ctx.scopes = AuthConverter.encode(ctx.scopes); + + await next(); + }; +} \ No newline at end of file diff --git a/packages/backend/src/server/api/mastodon/middleware/cache.ts b/packages/backend/src/server/api/mastodon/middleware/cache.ts new file mode 100644 index 000000000..6c89c2e7b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/cache.ts @@ -0,0 +1,7 @@ +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; + +export async function CacheMiddleware(ctx: MastoContext, next: () => Promise) { + ctx.cache = UserHelpers.getFreshAccountCache(); + await next(); +} \ No newline at end of file diff --git a/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts b/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts new file mode 100644 index 000000000..f11306cee --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts @@ -0,0 +1,46 @@ +import { MastoContext, logger } from "@/server/api/mastodon/index.js"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; + +export class MastoApiError extends Error { + statusCode: number; + constructor(statusCode: number, message?: string) { + if (message == null) { + switch (statusCode) { + case 404: + message = 'Record not found'; + break; + default: + message = 'Unknown error occurred'; + break; + } + } + super(message); + this.statusCode = statusCode; + } +} + +export async function CatchErrorsMiddleware(ctx: MastoContext, next: () => Promise) { + try { + await next(); + } catch (e: any) { + if (e instanceof MastoApiError) { + ctx.status = e.statusCode; + } + else if (e instanceof IdentifiableError) { + ctx.status = 400; + } + else { + logger.error(`Error occured in ${ctx.method} ${ctx.path}:`); + if (e instanceof Error) { + if (e.stack) logger.error(e.stack); + else logger.error(`${e.name}: ${e.message}`); + } + else { + logger.error(e); + } + ctx.status = 500; + } + ctx.body = { error: e.message }; + return; + } +} \ No newline at end of file diff --git a/packages/backend/src/server/api/mastodon/middleware/koa-body.ts b/packages/backend/src/server/api/mastodon/middleware/koa-body.ts new file mode 100644 index 000000000..a227563a7 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/koa-body.ts @@ -0,0 +1,12 @@ +import { Middleware } from "@koa/router"; +import { HttpMethodEnum, koaBody } from "koa-body"; + +export function KoaBodyMiddleware(): Middleware { + const options = { + multipart: true, + urlencoded: true, + parsedMethods: [HttpMethodEnum.POST, HttpMethodEnum.PUT, HttpMethodEnum.PATCH, HttpMethodEnum.DELETE] // dear god mastodon why + }; + + return koaBody(options); +} diff --git a/packages/backend/src/server/api/mastodon/middleware/normalize-query.ts b/packages/backend/src/server/api/mastodon/middleware/normalize-query.ts new file mode 100644 index 000000000..85ca1a594 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/normalize-query.ts @@ -0,0 +1,12 @@ +import { MastoContext } from "@/server/api/mastodon/index.js"; + +export async function NormalizeQueryMiddleware(ctx: MastoContext, next: () => Promise) { + if (ctx.request.query) { + if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) { + ctx.request.body = ctx.request.query; + } else { + ctx.request.body = {...ctx.request.body, ...ctx.request.query}; + } + } + await next(); +} \ No newline at end of file diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts index 7f831f398..edf5ef1f4 100644 --- a/packages/backend/src/server/index.ts +++ b/packages/backend/src/server/index.ts @@ -29,11 +29,9 @@ import fileServer from "./file/index.js"; import proxyServer from "./proxy/index.js"; import webServer from "./web/index.js"; import { initializeStreamingServer } from "./api/streaming.js"; -import { koaBody } from "koa-body"; import removeTrailingSlash from "koa-remove-trailing-slashes"; -import { v4 as uuid } from "uuid"; -import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; - +import { koaBody } from "koa-body"; +import { setupEndpointsAuthRoot } from "@/server/api/mastodon/endpoints/auth.js"; export const serverLogger = new Logger("server", "gray", false); // Init app @@ -83,24 +81,6 @@ app.use(mount("/proxy", proxyServer)); const router = new Router(); const mastoRouter = new Router(); -mastoRouter.use( - koaBody({ - urlencoded: true, - multipart: true, - }), -); - -mastoRouter.use(async (ctx, next) => { - if (ctx.request.query) { - if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) { - ctx.request.body = ctx.request.query; - } else { - ctx.request.body = { ...ctx.request.body, ...ctx.request.query }; - } - } - await next(); -}); - // Routing router.use(activityPub.routes()); router.use(nodeinfo.routes()); @@ -136,55 +116,29 @@ router.get("/identicon/:x", async (ctx) => { } }); -//TODO: move these to auth.ts -mastoRouter.get("/oauth/authorize", async (ctx) => { - const { client_id, state, redirect_uri } = ctx.request.query; - console.log(ctx.request.req); - let param = "mastodon=true"; - if (state) param += `&state=${state}`; - if (redirect_uri) param += `&redirect_uri=${redirect_uri}`; - const client = client_id ? client_id : ""; - ctx.redirect( - `${Buffer.from(client.toString(), "base64").toString()}?${param}`, - ); +mastoRouter.use( + koaBody({ + urlencoded: true, + multipart: true, + }), +); + +mastoRouter.use(async (ctx, next) => { + if (ctx.request.query) { + if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) { + ctx.request.body = ctx.request.query; + } else { + ctx.request.body = { ...ctx.request.body, ...ctx.request.query }; + } + } + await next(); }); -mastoRouter.post("/oauth/token", async (ctx) => { - const body: any = ctx.request.body || ctx.request.query; - console.log("token-request", body); - console.log("token-query", ctx.request.query); - if (body.grant_type === "client_credentials") { - ctx.body = { - access_token: uuid(), - token_type: "Bearer", - scope: "read", - created_at: Math.floor(new Date().getTime() / 1000), - }; - return; - } - let token = null; - if (body.code) { - token = body.code; - } - try { - const accessToken = await AuthHelpers.getAuthToken(body.client_secret, token ? token : ""); - const ret = { - access_token: accessToken, - token_type: "Bearer", - scope: body.scope || "read write follow push", - created_at: Math.floor(new Date().getTime() / 1000), - }; - ctx.body = ret; - } catch (err: any) { - console.error(err); - ctx.status = 401; - ctx.body = err.response.data; - } -}); +setupEndpointsAuthRoot(mastoRouter); // Register router -app.use(mastoRouter.routes()); app.use(router.routes()); +app.use(mastoRouter.routes()); app.use(mount(webServer));