[mastodon-client] Migrate endpoints to auth middleware

This commit is contained in:
Laura Hausmann 2023-10-05 20:22:02 +02:00
parent e3186e98f8
commit 4b76d0ce6f
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
27 changed files with 978 additions and 1737 deletions

View file

@ -21,6 +21,7 @@ export class AuthenticationError extends Error {
export default async ( export default async (
authorization: string | null | undefined, authorization: string | null | undefined,
bodyToken: string | null, bodyToken: string | null,
bypassUserCache: boolean = false
): Promise< ): Promise<
[CacheableLocalUser | null | undefined, AccessToken | null | undefined] [CacheableLocalUser | null | undefined, AccessToken | null | undefined]
> => { > => {
@ -46,11 +47,13 @@ export default async (
} }
if (isNativeToken(token)) { if (isNativeToken(token)) {
const user = await localUserByNativeTokenCache.fetch( const user = bypassUserCache
token, ? await Users.findOneBy({ token }) as ILocalUser | null
() => Users.findOneBy({ token }) as Promise<ILocalUser | null>, : await localUserByNativeTokenCache.fetch(
true, token,
); () => Users.findOneBy({ token: token ?? undefined }) as Promise<ILocalUser | null>,
true,
);
if (user == null) { if (user == null) {
throw new AuthenticationError("unknown token"); throw new AuthenticationError("unknown token");
@ -77,14 +80,18 @@ export default async (
lastUsedAt: new Date(), lastUsedAt: new Date(),
}); });
const user = await localUserByIdCache.fetch( const user = bypassUserCache
accessToken.userId, ? await Users.findOneBy({
() => id: accessToken.userId,
Users.findOneBy({ }) as ILocalUser
id: accessToken.userId, : await localUserByIdCache.fetch(
}) as Promise<ILocalUser>, accessToken.userId,
true, () =>
); Users.findOneBy({
id: accessToken.userId,
}) as Promise<ILocalUser>,
true,
);
if (accessToken.appId) { if (accessToken.appId) {
const app = await appCache.fetch( const app = await appCache.fetch(

View file

@ -1,7 +1,4 @@
import { Announcement } from "@/models/entities/announcement.js"; 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 { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import mfm from "mfm-js"; import mfm from "mfm-js";

View file

@ -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);
}
}

View file

@ -2,527 +2,243 @@ import Router from "@koa/router";
import { argsToBools, convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js"; import { argsToBools, convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { convertId, IdType } from "../../index.js"; import { convertId, IdType } from "../../index.js";
import { convertAccountId, convertListId, convertRelationshipId, convertStatusIds, } from "../converters.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 { 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 { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { ListHelpers } from "@/server/api/mastodon/helpers/list.js"; import { ListHelpers } from "@/server/api/mastodon/helpers/list.js";
import { Files } from "formidable"; import { Files } from "formidable";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
export function setupEndpointsAccount(router: Router): void { export function setupEndpointsAccount(router: Router): void {
router.get("/v1/accounts/verify_credentials", async (ctx) => { router.get("/v1/accounts/verify_credentials",
try { auth(true, ['read:accounts']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null; const acct = await UserHelpers.verifyCredentials(ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const acct = await UserHelpers.verifyCredentials(user);
ctx.body = convertAccountId(acct); 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) => { router.patch("/v1/accounts/update_credentials",
try { auth(true, ['write:accounts']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const files = (ctx.request as any).files as Files | undefined; 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) 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) => { router.get("/v1/accounts/lookup",
try { async (ctx) => {
const args = normalizeUrlQuery(ctx.query); const args = normalizeUrlQuery(ctx.query);
const user = await UserHelpers.getUserFromAcct(args.acct); const user = await UserHelpers.getUserFromAcct(args.acct);
if (user === null) {
ctx.status = 404;
return;
}
const account = await UserConverter.encode(user); const account = await UserConverter.encode(user);
ctx.body = convertAccountId(account); 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) => { router.get("/v1/accounts/relationships",
try { auth(true, ['read:follows']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const ids = (normalizeUrlQuery(ctx.query, ['id[]'])['id[]'] ?? []) const ids = (normalizeUrlQuery(ctx.query, ['id[]'])['id[]'] ?? [])
.map((id: string) => convertId(id, IdType.IceshrimpId)); .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)); 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) => { router.get<{ Params: { id: string } }>("/v1/accounts/:id",
try { auth(false),
async (ctx) => {
const userId = convertId(ctx.params.id, IdType.IceshrimpId); 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); 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 } }>( router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/statuses", "/v1/accounts/:id/statuses",
auth(false, ["read:statuses"]),
async (ctx) => { async (ctx) => {
try { const userId = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const query = await UserHelpers.getUserCachedOr404(userId, ctx.cache);
const user = auth[0] ?? null; 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); ctx.body = tl.map(s => convertStatusIds(s));
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;
}
}, },
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/featured_tags", "/v1/accounts/:id/featured_tags",
async (ctx) => { async (ctx) => {
try { ctx.body = [];
ctx.body = [];
} catch (e: any) {
ctx.status = 400;
ctx.body = { error: e.message };
}
}, },
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/followers", "/v1/accounts/:id/followers",
auth(false),
async (ctx) => { async (ctx) => {
try { const userId = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const query = await UserHelpers.getUserCachedOr404(userId, ctx.cache);
const user = auth[0] ?? null; 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); ctx.body = followers.map((account) => convertAccountId(account));
const cache = UserHelpers.getFreshAccountCache(); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
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;
}
}, },
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/following", "/v1/accounts/:id/following",
auth(false),
async (ctx) => { async (ctx) => {
try { const userId = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const query = await UserHelpers.getUserCachedOr404(userId, ctx.cache);
const user = auth[0] ?? null; 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); ctx.body = following.map((account) => convertAccountId(account));
const cache = UserHelpers.getFreshAccountCache(); PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
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;
}
}, },
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/lists", "/v1/accounts/:id/lists",
auth(true, ["read:lists"]),
async (ctx) => { async (ctx) => {
try { const member = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); const results = await ListHelpers.getListsByMember(ctx.user, member);
const user = auth[0] ?? null; ctx.body = results.map(p => convertListId(p));
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 };
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/follow", "/v1/accounts/:id/follow",
auth(true, ["write:follows"]),
async (ctx) => { async (ctx) => {
try { const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); //FIXME: Parse form data
const user = auth[0] ?? null; const result = await UserHelpers.followUser(target, ctx.user, true, false);
ctx.body = convertRelationshipId(result);
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unfollow", "/v1/accounts/:id/unfollow",
auth(true, ["write:follows"]),
async (ctx) => { async (ctx) => {
try { const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); const result = await UserHelpers.unfollowUser(target, ctx.user);
const user = auth[0] ?? null; ctx.body = convertRelationshipId(result);
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/block", "/v1/accounts/:id/block",
auth(true, ["write:blocks"]),
async (ctx) => { async (ctx) => {
try { const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); const result = await UserHelpers.blockUser(target, ctx.user);
const user = auth[0] ?? null; ctx.body = convertRelationshipId(result);
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unblock", "/v1/accounts/:id/unblock",
auth(true, ["write:blocks"]),
async (ctx) => { async (ctx) => {
try { const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); const result = await UserHelpers.unblockUser(target, ctx.user);
const user = auth[0] ?? null; ctx.body = convertRelationshipId(result)
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/mute", "/v1/accounts/:id/mute",
auth(true, ["write:mutes"]),
async (ctx) => { async (ctx) => {
try { //FIXME: parse form data
const auth = await authenticate(ctx.headers.authorization, null); const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, ['duration']), ['notifications']));
const user = auth[0] ?? null; const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const result = await UserHelpers.muteUser(target, ctx.user, args.notifications, args.duration);
if (!user) { ctx.body = convertRelationshipId(result)
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unmute", "/v1/accounts/:id/unmute",
auth(true, ["write:mutes"]),
async (ctx) => { async (ctx) => {
try { const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); const result = await UserHelpers.unmuteUser(target, ctx.user);
const user = auth[0] ?? null; ctx.body = convertRelationshipId(result)
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;
}
}, },
); );
router.get("/v1/featured_tags", async (ctx) => { router.get("/v1/featured_tags",
try { async (ctx) => {
ctx.body = []; ctx.body = [];
} catch (e: any) {
ctx.status = 400;
ctx.body = { error: e.message };
} }
}); );
router.get("/v1/followed_tags", async (ctx) => { router.get("/v1/followed_tags",
try { async (ctx) => {
ctx.body = []; ctx.body = [];
} catch (e: any) {
ctx.status = 400;
ctx.body = { error: e.message };
} }
}); );
router.get("/v1/bookmarks", async (ctx) => { router.get("/v1/bookmarks",
try { auth(true, ["read:bookmarks"]),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); 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 res = await UserHelpers.getUserBookmarks(ctx.user, args.max_id, args.since_id, args.min_id, args.limit);
const bookmarks = await NoteConverter.encodeMany(res.data, user, cache); const bookmarks = await NoteConverter.encodeMany(res.data, ctx.user, ctx.cache);
ctx.body = bookmarks.map(s => convertStatusIds(s)); ctx.body = bookmarks.map(s => convertStatusIds(s));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 20); 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) => { router.get("/v1/favourites",
try { auth(true, ["read:favourites"]),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); 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 res = await UserHelpers.getUserFavorites(ctx.user, args.max_id, args.since_id, args.min_id, args.limit);
const favorites = await NoteConverter.encodeMany(res.data, user, cache); const favorites = await NoteConverter.encodeMany(res.data, ctx.user, ctx.cache);
ctx.body = favorites.map(s => convertStatusIds(s)); ctx.body = favorites.map(s => convertStatusIds(s));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 20); 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) => { router.get("/v1/mutes",
try { auth(true, ["read:mutes"]),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); 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)); ctx.body = res.data.map(m => convertAccountId(m));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); 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) => { router.get("/v1/blocks",
try { auth(true, ["read:blocks"]),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); 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 res = await UserHelpers.getUserBlocks(ctx.user, args.max_id, args.since_id, args.min_id, args.limit);
const blocks = await UserConverter.encodeMany(res.data, cache); const blocks = await UserConverter.encodeMany(res.data, ctx.cache);
ctx.body = blocks.map(b => convertAccountId(b)); ctx.body = blocks.map(b => convertAccountId(b));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); 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) => { router.get("/v1/follow_requests",
try { auth(true, ["read:follows"]),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); 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 res = await UserHelpers.getUserFollowRequests(ctx.user, args.max_id, args.since_id, args.min_id, args.limit);
const requests = await UserConverter.encodeMany(res.data, cache); const requests = await UserConverter.encodeMany(res.data, ctx.cache);
ctx.body = requests.map(b => convertAccountId(b)); ctx.body = requests.map(b => convertAccountId(b));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40); 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 } }>( router.post<{ Params: { id: string } }>(
"/v1/follow_requests/:id/authorize", "/v1/follow_requests/:id/authorize",
auth(true, ["write:follows"]),
async (ctx) => { async (ctx) => {
try { const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); const result = await UserHelpers.acceptFollowRequest(target, ctx.user);
const user = auth[0] ?? null; ctx.body = convertRelationshipId(result);
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/follow_requests/:id/reject", "/v1/follow_requests/:id/reject",
auth(true, ["write:follows"]),
async (ctx) => { async (ctx) => {
try { const target = await UserHelpers.getUserCachedOr404(convertId(ctx.params.id, IdType.IceshrimpId));
const auth = await authenticate(ctx.headers.authorization, null); const result = await UserHelpers.rejectFollowRequest(target, ctx.user);
const user = auth[0] ?? null; ctx.body = convertRelationshipId(result);
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;
}
}, },
); );
} }

View file

@ -1,72 +1,68 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js";
import { convertId, IdType } from "@/misc/convert-id.js"; import { convertId, IdType } from "@/misc/convert-id.js";
import { AuthConverter } from "@/server/api/mastodon/converters/auth.js";
const readScope = [ import { v4 as uuid } from "uuid";
"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",
];
export function setupEndpointsAuth(router: Router): void { export function setupEndpointsAuth(router: Router): void {
router.post("/v1/apps", async (ctx) => { router.post("/v1/apps", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query; const body: any = ctx.request.body || ctx.request.query;
try { let scope = body.scopes;
let scope = body.scopes; if (typeof scope === "string") scope = scope.split(" ");
if (typeof scope === "string") scope = scope.split(" "); const scopeArr = AuthConverter.decode(scope);
const pushScope = new Set<string>(); const red = body.redirect_uris;
for (const s of scope) { const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']);
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r); ctx.body = {
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r); id: convertId(appData.id, IdType.MastodonId),
} name: appData.name,
const scopeArr = Array.from(pushScope); website: body.website,
redirect_uri: red,
client_id: Buffer.from(appData.url ?? "").toString("base64"),
client_secret: appData.clientSecret,
};
});
}
const red = body.redirect_uris; export function setupEndpointsAuthRoot(router: Router): void {
const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']); router.get("/oauth/authorize", async (ctx) => {
const returns = { const { client_id, state, redirect_uri } = ctx.request.query;
id: convertId(appData.id, IdType.MastodonId), let param = "mastodon=true";
name: appData.name, if (state) param += `&state=${state}`;
website: body.website, if (redirect_uri) param += `&redirect_uri=${redirect_uri}`;
redirect_uri: red, const client = client_id ? client_id : "";
client_id: Buffer.from(appData.url ?? "").toString("base64"), ctx.redirect(
client_secret: appData.clientSecret, `${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; return;
} catch (e: any) { }
console.error(e); 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.status = 401;
ctx.body = e.response.data; ctx.body = err.response.data;
} }
}); });
} }

View file

@ -1,11 +1,18 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
export function setupEndpointsFilter(router: Router): void { export function setupEndpointsFilter(router: Router): void {
router.get(["/v1/filters", "/v2/filters"], async (ctx) => { router.get(["/v1/filters", "/v2/filters"],
ctx.body = []; auth(true, ['read:filters']),
}); async (ctx) => {
router.post(["/v1/filters", "/v2/filters"], async (ctx) => { ctx.body = [];
ctx.status = 400; }
ctx.body = { error: "Please change word mute settings in the web frontend settings." }; );
}); 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." };
}
);
} }

View file

@ -1,243 +1,115 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { convertAccountId, convertListId, } from "../converters.js"; import { convertAccountId, convertListId, } from "../converters.js";
import { convertId, IdType } from "../../index.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 { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js";
import { ListHelpers } from "@/server/api/mastodon/helpers/list.js"; import { ListHelpers } from "@/server/api/mastodon/helpers/list.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js"; import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { UserLists } from "@/models/index.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 { getUser } from "@/server/api/common/getters.js";
import { toArray } from "@/prelude/array.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 { export function setupEndpointsList(router: Router): void {
router.get("/v1/lists", async (ctx, reply) => { router.get("/v1/lists",
try { auth(true, ['read:lists']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx, reply) => {
const user = auth[0] ?? undefined; ctx.body = await ListHelpers.getLists(ctx.user)
if (!user) {
ctx.status = 401;
return;
}
ctx.body = await ListHelpers.getLists(user)
.then(p => p.map(list => convertListId(list))); .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 } }>( router.get<{ Params: { id: string } }>(
"/v1/lists/:id", "/v1/lists/:id",
auth(true, ['read:lists']),
async (ctx, reply) => { async (ctx, reply) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) { ctx.body = await ListHelpers.getListOr404(ctx.user, id)
ctx.status = 401; .then(p => convertListId(p));
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;
}
}, },
); );
router.post("/v1/lists", async (ctx, reply) => { router.post("/v1/lists",
try { auth(true, ['write:lists']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx, reply) => {
const user = auth[0] ?? undefined; const body = ctx.request.body as any;
const title = (body.title ?? '').trim();
if (!user) { ctx.body = await ListHelpers.createList(ctx.user, title)
ctx.status = 401; .then(p => convertListId(p));
return; }
} );
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 body = ctx.request.body as any;
const title = (body.title ?? '').trim(); const title = (body.title ?? '').trim();
if (title.length < 1) { ctx.body = await ListHelpers.updateList(ctx.user, list, title)
ctx.body = { error: "Title must not be empty" };
ctx.status = 400;
return
}
ctx.body = await ListHelpers.createList(user, title)
.then(p => convertListId(p)); .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 } }>( router.delete<{ Params: { id: string } }>(
"/v1/lists/:id", "/v1/lists/:id",
auth(true, ['write:lists']),
async (ctx, reply) => { async (ctx, reply) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const list = await UserLists.findOneBy({userId: ctx.user.id, id: id});
const user = auth[0] ?? undefined; if (!list) throw new MastoApiError(404);
if (!user) { await ListHelpers.deleteList(ctx.user, list);
ctx.status = 401; ctx.body = {};
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 };
}
}, },
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/lists/:id/accounts", "/v1/lists/:id/accounts",
auth(true, ['read:lists']),
async (ctx, reply) => { async (ctx, reply) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const user = auth[0] ?? undefined; 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.body = accounts.map(account => convertAccountId(account));
ctx.status = 401; PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/lists/:id/accounts", "/v1/lists/:id/accounts",
auth(true, ['write:lists']),
async (ctx, reply) => { async (ctx, reply) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const list = await UserLists.findOneBy({userId: ctx.user.id, id: id});
const user = auth[0] ?? undefined; if (!list) throw new MastoApiError(404);
if (!user) { const body = ctx.request.body as any;
ctx.status = 401; if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field");
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId); const ids = toArray(body['account_ids']).map(p => convertId(p, IdType.IceshrimpId));
const list = await UserLists.findOneBy({userId: user.id, id: id}); const targets = await Promise.all(ids.map(p => getUser(p)));
await ListHelpers.addToList(ctx.user, list, targets);
if (!list) { ctx.body = {}
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 };
}
}, },
); );
router.delete<{ Params: { id: string } }>( router.delete<{ Params: { id: string } }>(
"/v1/lists/:id/accounts", "/v1/lists/:id/accounts",
auth(true, ['write:lists']),
async (ctx, reply) => { async (ctx, reply) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const list = await UserLists.findOneBy({userId: ctx.user.id, id: id});
const user = auth[0] ?? undefined; if (!list) throw new MastoApiError(404);
if (!user) { const body = ctx.request.body as any;
ctx.status = 401; if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field");
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId); const ids = toArray(body['account_ids']).map(p => convertId(p, IdType.IceshrimpId));
const list = await UserLists.findOneBy({userId: user.id, id: id}); const targets = await Promise.all(ids.map(p => getUser(p)));
await ListHelpers.removeFromList(ctx.user, list, targets);
if (!list) { ctx.body = {}
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 };
}
}, },
); );
} }

View file

@ -1,94 +1,41 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { convertId, IdType } from "@/misc/convert-id.js"; import { convertId, IdType } from "@/misc/convert-id.js";
import { convertAttachmentId } from "@/server/api/mastodon/converters.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 { MediaHelpers } from "@/server/api/mastodon/helpers/media.js";
import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { FileConverter } from "@/server/api/mastodon/converters/file.js";
import { Files } from "formidable"; import { Files } from "formidable";
import { toSingleLast } from "@/prelude/array.js"; import { toSingleLast } from "@/prelude/array.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
export function setupEndpointsMedia(router: Router): void { export function setupEndpointsMedia(router: Router): void {
router.get<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => { router.get<{ Params: { id: string } }>("/v1/media/:id",
try { auth(true, ['write:media']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId); const id = convertId(ctx.params.id, IdType.IceshrimpId);
const file = await MediaHelpers.getMediaPacked(user, id); const file = await MediaHelpers.getMediaPackedOr404(ctx.user, id);
if (!file) {
ctx.status = 404;
ctx.body = {error: "File not found"};
return;
}
const attachment = FileConverter.encode(file); const attachment = FileConverter.encode(file);
ctx.body = convertAttachmentId(attachment); 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) => { router.put<{ Params: { id: string } }>("/v1/media/:id",
try { auth(true, ['write:media']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId); const id = convertId(ctx.params.id, IdType.IceshrimpId);
const file = await MediaHelpers.getMedia(user, id); const file = await MediaHelpers.getMediaOr404(ctx.user, id);
const result = await MediaHelpers.updateMedia(ctx.user, file, ctx.request.body)
if (!file) {
ctx.status = 404;
ctx.body = {error: "File not found"};
return;
}
const result = await MediaHelpers.updateMedia(user, file, ctx.request.body)
.then(p => FileConverter.encode(p)); .then(p => FileConverter.encode(p));
ctx.body = convertAttachmentId(result); ctx.body = convertAttachmentId(result);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
} }
}); );
router.post(["/v2/media", "/v1/media"],
router.post(["/v2/media", "/v1/media"], async (ctx) => { auth(true, ['write:media']),
try { async (ctx) => {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
//FIXME: why do we have to cast this to any first? //FIXME: why do we have to cast this to any first?
const files = (ctx.request as any).files as Files | undefined; const files = (ctx.request as any).files as Files | undefined;
const file = toSingleLast(files?.file); const file = toSingleLast(files?.file);
if (!file) { const result = await MediaHelpers.uploadMedia(ctx.user, file, ctx.request.body)
ctx.body = {error: "No image"};
ctx.status = 400;
return;
}
const result = await MediaHelpers.uploadMedia(user, file, ctx.request.body)
.then(p => FileConverter.encode(p)); .then(p => FileConverter.encode(p));
ctx.body = convertAttachmentId(result); ctx.body = convertAttachmentId(result);
} catch (e: any) {
console.error(e);
ctx.status = 500;
ctx.body = {error: e.message};
} }
}); );
} }

View file

@ -1,136 +1,80 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { MiscHelpers } from "@/server/api/mastodon/helpers/misc.js"; 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 { argsToBools, limitToInt } from "@/server/api/mastodon/endpoints/timeline.js";
import { Announcements } from "@/models/index.js"; import { Announcements } from "@/models/index.js";
import { convertAnnouncementId, convertSuggestionIds } from "@/server/api/mastodon/converters.js"; import { convertAnnouncementId, convertSuggestionIds } from "@/server/api/mastodon/converters.js";
import { convertId, IdType } from "@/misc/convert-id.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 { export function setupEndpointsMisc(router: Router): void {
router.get("/v1/custom_emojis", async (ctx) => { router.get("/v1/custom_emojis",
try { async (ctx) => {
ctx.body = await MiscHelpers.getCustomEmoji(); ctx.body = await MiscHelpers.getCustomEmoji();
} catch (e: any) {
ctx.status = 500;
ctx.body = { error: e.message };
} }
}); );
router.get("/v1/instance", async (ctx) => { router.get("/v1/instance",
try { async (ctx) => {
ctx.body = await MiscHelpers.getInstance(); 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']); 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))); .then(p => p.map(x => convertAnnouncementId(x)));
} catch (e: any) {
ctx.status = 500;
ctx.body = { error: e.message };
} }
}); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/announcements/:id/dismiss", "/v1/announcements/:id/dismiss",
auth(true, ['write:accounts']),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const announcement = await Announcements.findOneBy({id: id});
const user = auth[0] ?? null; if (!announcement) throw new MastoApiError(404);
if (!user) { await MiscHelpers.dismissAnnouncement(announcement, ctx.user);
ctx.status = 401; ctx.body = {};
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 };
}
}, },
); );
router.get(["/v1/trends/tags", "/v1/trends"], async (ctx) => { router.get(["/v1/trends/tags", "/v1/trends"],
try { async (ctx) => {
const args = limitToInt(ctx.query); const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getTrendingHashtags(args.limit, args.offset); 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) => { router.get("/v1/trends/statuses",
try { async (ctx) => {
const args = limitToInt(ctx.query); const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getTrendingStatuses(args.limit, args.offset); 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) => { router.get("/v1/trends/links",
ctx.body = []; 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("/v2/suggestions", async (ctx) => { router.get("/v1/preferences",
try { auth(true, ['read:accounts']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? undefined; ctx.body = await MiscHelpers.getPreferences(ctx.user);
}
if (!user) { );
ctx.status = 401;
return;
}
router.get("/v2/suggestions",
auth(true, ['read']),
async (ctx) => {
const args = limitToInt(ctx.query); 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))); .then(p => p.map(x => convertSuggestionIds(x)));
} catch (e: any) {
ctx.status = 500;
ctx.body = { error: e.message };
} }
}); );
} }

View file

@ -1,118 +1,57 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { convertId, IdType } from "../../index.js"; import { convertId, IdType } from "../../index.js";
import { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js"; import { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { convertConversationIds, convertNotificationIds } from "../converters.js"; import { convertNotificationIds } from "../converters.js";
import authenticate from "@/server/api/authenticate.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.js"; import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.js";
import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js"; import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js";
import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js"; import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
export function setupEndpointsNotifications(router: Router): void { export function setupEndpointsNotifications(router: Router): void {
router.get("/v1/notifications", async (ctx) => { router.get("/v1/notifications",
try { auth(true, ['read:notifications']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache(); const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)), ['types[]', 'exclude_types[]']); 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) 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, user, cache)) .then(p => NotificationConverter.encodeMany(p, ctx.user, cache))
.then(p => p.map(n => convertNotificationIds(n))); .then(p => p.map(n => convertNotificationIds(n)));
ctx.body = await data; 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) => { router.get("/v1/notifications/:id",
try { auth(true, ['read:notifications']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null; const notification = await NotificationHelpers.getNotificationOr404(convertId(ctx.params.id, IdType.IceshrimpId), ctx.user);
ctx.body = convertNotificationIds(await NotificationConverter.encode(notification, ctx.user));
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.post("/v1/notifications/clear", async (ctx) => { router.post("/v1/notifications/clear",
try { auth(true, ['write:notifications']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null; await NotificationHelpers.clearAllNotifications(ctx.user);
if (!user) {
ctx.status = 401;
return;
}
await NotificationHelpers.clearAllNotifications(user);
ctx.body = {}; ctx.body = {};
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
} }
}); );
router.post("/v1/notifications/:id/dismiss", async (ctx) => { router.post("/v1/notifications/:id/dismiss",
try { auth(true, ['write:notifications']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null; const notification = await NotificationHelpers.getNotificationOr404(convertId(ctx.params.id, IdType.IceshrimpId), ctx.user);
await NotificationHelpers.dismissNotification(notification.id, ctx.user);
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);
ctx.body = {}; 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) => { router.post("/v1/conversations/:id/read",
const auth = await authenticate(ctx.headers.authorization, null); auth(true, ['write:conversations']),
const user = auth[0] ?? undefined; async (ctx, reply) => {
const id = convertId(ctx.params.id, IdType.IceshrimpId);
if (!user) { await NotificationHelpers.markConversationAsRead(id, ctx.user);
ctx.status = 401; ctx.body = {};
return;
} }
);
const id = convertId(ctx.params.id, IdType.IceshrimpId);
await NotificationHelpers.markConversationAsRead(id, user);
ctx.body = {};
});
} }

View file

@ -1,54 +1,26 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { argsToBools, convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js"; import { argsToBools, convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { convertSearchIds } from "../converters.js"; import { convertSearchIds } from "../converters.js";
import authenticate from "@/server/api/authenticate.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { SearchHelpers } from "@/server/api/mastodon/helpers/search.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 { export function setupEndpointsSearch(router: Router): void {
router.get("/v1/search", async (ctx) => { router.get(["/v1/search", "/v2/search"],
try { auth(true, ['read:search']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed']))); const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed'])));
const cache = UserHelpers.getFreshAccountCache(); 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),
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);
ctx.body = convertSearchIds(result); ctx.body = convertSearchIds(result);
} catch (e: any) {
console.error(e); if (ctx.path === "/v1/search") {
ctx.status = 400; ctx.body = {
ctx.body = {error: e.message}; ...ctx.body,
hashtags: result.hashtags.map(p => p.name),
};
}
} }
}); );
} }

View file

@ -2,36 +2,21 @@ import Router from "@koa/router";
import { convertId, IdType } from "../../index.js"; import { convertId, IdType } from "../../index.js";
import { convertAccountId, convertPollId, convertStatusIds, convertStatusEditIds, convertStatusSourceId, } from "../converters.js"; import { convertAccountId, convertPollId, convertStatusIds, convertStatusEditIds, convertStatusSourceId, } from "../converters.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.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 { 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 { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.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 { PollHelpers } from "@/server/api/mastodon/helpers/poll.js";
import { toArray } from "@/prelude/array.js"; import { toArray } from "@/prelude/array.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
const postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60);
const postIdempotencyLocks = new AsyncLock();
export function setupEndpointsStatus(router: Router): void { export function setupEndpointsStatus(router: Router): void {
router.post("/v1/statuses", async (ctx) => { router.post("/v1/statuses",
try { auth(true, ['write:statuses']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null; const key = NoteHelpers.getIdempotencyKey(ctx.headers, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const key = getIdempotencyKey(ctx.headers, user);
if (key !== null) { if (key !== null) {
const result = await getFromIdempotencyCache(key); const result = await NoteHelpers.getFromIdempotencyCache(key);
if (result) { if (result) {
ctx.body = result; ctx.body = result;
@ -40,645 +25,263 @@ export function setupEndpointsStatus(router: Router): void {
} }
let request = NoteHelpers.normalizeComposeOptions(ctx.request.body); let request = NoteHelpers.normalizeComposeOptions(ctx.request.body);
ctx.body = await NoteHelpers.createNote(request, user) ctx.body = await NoteHelpers.createNote(request, ctx.user)
.then(p => NoteConverter.encode(p, user)) .then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p)); .then(p => convertStatusIds(p));
if (key !== null) postIdempotencyCache.set(key, {status: ctx.body}); if (key !== null) NoteHelpers.postIdempotencyCache.set(key, {status: ctx.body});
} catch (e: any) {
console.error(e);
ctx.status = 500;
ctx.body = {error: e.message};
} }
}); );
router.put("/v1/statuses/:id", async (ctx) => { router.put("/v1/statuses/:id",
try { auth(true, ['write:statuses']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const noteId = convertId(ctx.params.id, IdType.IceshrimpId); 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) {
if (!note) {
ctx.status = 404;
ctx.body = {
error: "Note not found"
};
return;
}
}
let request = NoteHelpers.normalizeEditOptions(ctx.request.body); let request = NoteHelpers.normalizeEditOptions(ctx.request.body);
ctx.body = await NoteHelpers.editNote(request, note, user) ctx.body = await NoteHelpers.editNote(request, note, ctx.user)
.then(p => NoteConverter.encode(p, user)) .then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p)); .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) => { router.get<{ Params: { id: string } }>("/v1/statuses/:id",
try { auth(false, ["read:statuses"]),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
const noteId = convertId(ctx.params.id, IdType.IceshrimpId); 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) { const status = await NoteConverter.encode(note, ctx.user);
ctx.status = 404;
return;
}
const status = await NoteConverter.encode(note, user);
ctx.body = convertStatusIds(status); 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) => { router.delete<{ Params: { id: string } }>("/v1/statuses/:id",
try { auth(true, ['write:statuses']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const noteId = convertId(ctx.params.id, IdType.IceshrimpId); 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);
ctx.body = await NoteHelpers.deleteNote(note, ctx.user)
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)
.then(p => convertStatusIds(p)); .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 } }>( router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/context", "/v1/statuses/:id/context",
auth(false, ["read:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null; 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); ctx.body = {
const cache = UserHelpers.getFreshAccountCache(); ancestors,
const note = await getNote(id, user ?? null).then(n => n).catch(() => null); descendants,
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;
}
},
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/history", "/v1/statuses/:id/history",
auth(false, ["read:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null; const res = await NoteHelpers.getNoteEditHistory(note);
ctx.body = res.map(p => convertStatusEditIds(p));
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;
}
},
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/source", "/v1/statuses/:id/source",
auth(true, ["read:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null; const src = NoteHelpers.getNoteSource(note);
ctx.body = convertStatusSourceId(src);
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;
}
},
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/reblogged_by", "/v1/statuses/:id/reblogged_by",
auth(false, ["read:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null; 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 id = convertId(ctx.params.id, IdType.IceshrimpId); const users = await UserConverter.encodeMany(res.data, ctx.cache);
const note = await getNote(id, user).catch(_ => null); ctx.body = users.map(m => convertAccountId(m));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
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;
}
},
); );
router.get<{ Params: { id: string } }>( router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/favourited_by", "/v1/statuses/:id/favourited_by",
auth(false, ["read:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null; 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 id = convertId(ctx.params.id, IdType.IceshrimpId); const users = await UserConverter.encodeMany(res.data, ctx.cache);
const note = await getNote(id, user).catch(_ => null); ctx.body = users.map(m => convertAccountId(m));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
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;
}
},
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/favourite", "/v1/statuses/:id/favourite",
auth(true, ["write:favourites"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null; const reaction = await NoteHelpers.getDefaultReaction();
if (!user) { ctx.body = await NoteHelpers.reactToNote(note, ctx.user, reaction)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
} }
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;
}
},
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unfavourite", "/v1/statuses/:id/unfavourite",
auth(true, ["write:favourites"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.removeReactFromNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/reblog", "/v1/statuses/:id/reblog",
auth(true, ["write:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.reblogNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unreblog", "/v1/statuses/:id/unreblog",
auth(true, ["write:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.unreblogNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/bookmark", "/v1/statuses/:id/bookmark",
auth(true, ["write:bookmarks"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.bookmarkNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unbookmark", "/v1/statuses/:id/unbookmark",
auth(true, ["write:bookmarks"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.unbookmarkNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/pin", "/v1/statuses/:id/pin",
auth(true, ["write:accounts"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.pinNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unpin", "/v1/statuses/:id/unpin",
auth(true, ["write:accounts"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.unpinNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string; name: string } }>( router.post<{ Params: { id: string; name: string } }>(
"/v1/statuses/:id/react/:name", "/v1/statuses/:id/react/:name",
auth(true, ["write:favourites"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.reactToNote(note, ctx.user, ctx.params.name)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.post<{ Params: { id: string; name: string } }>( router.post<{ Params: { id: string; name: string } }>(
"/v1/statuses/:id/unreact/:name", "/v1/statuses/:id/unreact/:name",
auth(true, ["write:favourites"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { ctx.body = await NoteHelpers.removeReactFromNote(note, ctx.user)
ctx.status = 401; .then(p => NoteConverter.encode(p, ctx.user))
return; .then(p => convertStatusIds(p));
}
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;
}
}, },
); );
router.get<{ Params: { id: string } }>("/v1/polls/:id", async (ctx) => { router.get<{ Params: { id: string } }>("/v1/polls/:id",
try { auth(false, ["read:statuses"]),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx) => {
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId); const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const data = await PollHelpers.getPoll(note, ctx.user);
if (note === null || !note.hasPoll) {
ctx.status = 404;
return;
}
const data = await PollHelpers.getPoll(note, user);
ctx.body = convertPollId(data); ctx.body = convertPollId(data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
}); });
router.post<{ Params: { id: string } }>( router.post<{ Params: { id: string } }>(
"/v1/polls/:id/votes", "/v1/polls/:id/votes",
auth(true, ["write:statuses"]),
async (ctx) => { async (ctx) => {
try { const id = convertId(ctx.params.id, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const user = auth[0] ?? null;
if (!user) { const body: any = ctx.request.body;
ctx.status = 401; const choices = toArray(body.choices ?? []).map(p => parseInt(p));
return; if (choices.length < 1) {
} ctx.status = 400;
ctx.body = {error: 'Must vote for at least one option'};
const id = convertId(ctx.params.id, IdType.IceshrimpId); return;
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 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<MastodonEntity.Status | undefined> {
return postIdempotencyLocks.acquire(key, async (): Promise<MastodonEntity.Status | undefined> => {
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;
}
});
}

View file

@ -2,13 +2,15 @@ import Router from "@koa/router";
import { ParsedUrlQuery } from "querystring"; import { ParsedUrlQuery } from "querystring";
import { convertConversationIds, convertStatusIds, } from "../converters.js"; import { convertConversationIds, convertStatusIds, } from "../converters.js";
import { convertId, IdType } from "../../index.js"; import { convertId, IdType } from "../../index.js";
import authenticate from "@/server/api/authenticate.js";
import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js"; import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { UserLists } from "@/models/index.js"; import { UserLists } from "@/models/index.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.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[] = []) { export function limitToInt(q: ParsedUrlQuery, additional: string[] = []) {
let object: any = q; let object: any = q;
if (q.limit) if (q.limit)
@ -63,138 +65,63 @@ export function normalizeUrlQuery(q: ParsedUrlQuery, arrayKeys: string[] = []):
} }
export function setupEndpointsTimeline(router: Router): void { export function setupEndpointsTimeline(router: Router): void {
router.get("/v1/timelines/public", async (ctx, reply) => { router.get("/v1/timelines/public",
try { auth(true, ['read:statuses']),
const auth = await authenticate(ctx.headers.authorization, null); async (ctx, reply) => {
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query)))); const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))));
const cache = UserHelpers.getFreshAccountCache(); 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) 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, user, cache)); .then(n => NoteConverter.encodeMany(n, ctx.user, cache));
ctx.body = tl.map(s => convertStatusIds(s)); 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 } }>( router.get<{ Params: { hashtag: string } }>(
"/v1/timelines/tag/:hashtag", "/v1/timelines/tag/:hashtag",
auth(false, ['read:statuses']),
async (ctx, reply) => { async (ctx, reply) => {
try { const tag = (ctx.params.hashtag ?? '').trim();
const auth = await authenticate(ctx.headers.authorization, null); const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))), ['any[]', 'all[]', 'none[]']);
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 cache = UserHelpers.getFreshAccountCache(); const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getHomeTimeline(user, args.max_id, args.since_id, args.min_id, args.limit) 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, user, cache)); .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)); 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 } }>( router.get<{ Params: { listId: string } }>(
"/v1/timelines/list/:listId", "/v1/timelines/list/:listId",
auth(true, ['read:lists']),
async (ctx, reply) => { async (ctx, reply) => {
try { const listId = convertId(ctx.params.listId, IdType.IceshrimpId);
const auth = await authenticate(ctx.headers.authorization, null); const list = await UserLists.findOneBy({userId: ctx.user.id, id: listId});
const user = auth[0] ?? undefined; if (!list) throw new MastoApiError(404);
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 args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query))); 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)); ctx.body = res.data.map(c => convertConversationIds(c));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 20); 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;
} }
}); );
} }

View file

@ -6,6 +6,7 @@ import { UserList } from "@/models/entities/user-list.js";
import { pushUserToUserList } from "@/services/user-list/push.js"; import { pushUserToUserList } from "@/services/user-list/push.js";
import { genId } from "@/misc/gen-id.js"; import { genId } from "@/misc/gen-id.js";
import { publishUserListStream } from "@/services/stream.js"; import { publishUserListStream } from "@/services/stream.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
export class ListHelpers { export class ListHelpers {
public static async getLists(user: ILocalUser): Promise<MastodonEntity.List[]> { public static async getLists(user: ILocalUser): Promise<MastodonEntity.List[]> {
@ -26,9 +27,15 @@ export class ListHelpers {
}); });
} }
public static async getListOr404(user: ILocalUser, id: string): Promise<MastodonEntity.List> {
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<LinkPaginationObject<User[]>> { public static async getListUsers(user: ILocalUser, id: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80; 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( const query = PaginationHelpers.makePaginationQuery(
UserListJoinings.createQueryBuilder('member'), UserListJoinings.createQueryBuilder('member'),
sinceId, sinceId,
@ -99,6 +106,8 @@ export class ListHelpers {
} }
public static async createList(user: ILocalUser, title: string): Promise<MastodonEntity.List> { public static async createList(user: ILocalUser, title: string): Promise<MastodonEntity.List> {
if (title.length < 1) throw new MastoApiError(400, "Title must not be empty");
const list = await UserLists.insert({ const list = await UserLists.insert({
id: genId(), id: genId(),
createdAt: new Date(), createdAt: new Date(),
@ -113,7 +122,9 @@ export class ListHelpers {
} }
public static async updateList(user: ILocalUser, list: UserList, title: string) { 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"); if (user.id != list.userId) throw new Error("List is not owned by user");
const partial = {name: title}; const partial = {name: title};
const result = await UserLists.update(list.id, partial) const result = await UserLists.update(list.id, partial)
.then(async _ => await UserLists.findOneByOrFail({id: list.id})); .then(async _ => await UserLists.findOneByOrFail({id: list.id}));

View file

@ -4,9 +4,12 @@ import { DriveFiles } from "@/models/index.js";
import { Packed } from "@/misc/schema.js"; import { Packed } from "@/misc/schema.js";
import { DriveFile } from "@/models/entities/drive-file.js"; import { DriveFile } from "@/models/entities/drive-file.js";
import { File } from "formidable"; import { File } from "formidable";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
export class MediaHelpers { export class MediaHelpers {
public static async uploadMedia(user: ILocalUser, file: File, body: any): Promise<Packed<"DriveFile">> { public static async uploadMedia(user: ILocalUser, file: File | undefined, body: any): Promise<Packed<"DriveFile">> {
if (!file) throw new MastoApiError(400, "Validation failed: File content type is invalid, File is invalid");
return addFile({ return addFile({
user: user, user: user,
path: file.filepath, path: file.filepath,
@ -40,7 +43,21 @@ export class MediaHelpers {
.then(p => p ? DriveFiles.pack(p) : null); .then(p => p ? DriveFiles.pack(p) : null);
} }
public static async getMediaPackedOr404(user: ILocalUser, id: string): Promise<Packed<"DriveFile">> {
return this.getMediaPacked(user, id).then(p => {
if (p) return p;
throw new MastoApiError(404);
});
}
public static async getMedia(user: ILocalUser, id: string): Promise<DriveFile | null> { public static async getMedia(user: ILocalUser, id: string): Promise<DriveFile | null> {
return DriveFiles.findOneBy({id: id, userId: user.id}); return DriveFiles.findOneBy({id: id, userId: user.id});
} }
public static async getMediaOr404(user: ILocalUser, id: string): Promise<DriveFile> {
return this.getMedia(user, id).then(p => {
if (p) return p;
throw new MastoApiError(404);
});
}
} }

View file

@ -24,13 +24,23 @@ import mfm from "mfm-js";
import { FileConverter } from "@/server/api/mastodon/converters/file.js"; import { FileConverter } from "@/server/api/mastodon/converters/file.js";
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import { toArray } from "@/prelude/array.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 { export class NoteHelpers {
public static postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60);
public static postIdempotencyLocks = new AsyncLock();
public static async getDefaultReaction(): Promise<string> { public static async getDefaultReaction(): Promise<string> {
return Metas.createQueryBuilder() return Metas.createQueryBuilder()
.select('"defaultReaction"') .select('"defaultReaction"')
.execute() .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<Note> { public static async reactToNote(note: Note, user: ILocalUser, reaction: string): Promise<Note> {
@ -122,7 +132,7 @@ export class NoteHelpers {
} }
public static async deleteNote(note: Note, user: ILocalUser): Promise<MastodonEntity.Status> { public static async deleteNote(note: Note, user: ILocalUser): Promise<MastodonEntity.Status> {
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); const status = await NoteConverter.encode(note, user);
await deleteNote(user, note); await deleteNote(user, note);
status.content = undefined; status.content = undefined;
@ -376,4 +386,35 @@ export class NoteHelpers {
return result; return result;
} }
public static async getNoteOr404(id: string, user: ILocalUser | null): Promise<Note> {
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<MastodonEntity.Status | undefined> {
return this.postIdempotencyLocks.acquire(key, async (): Promise<MastodonEntity.Status | undefined> => {
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;
}
});
}
} }

View file

@ -2,6 +2,7 @@ import { ILocalUser } from "@/models/entities/user.js";
import { Notes, Notifications } from "@/models/index.js"; import { Notes, Notifications } from "@/models/index.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { Notification } from "@/models/entities/notification.js"; import { Notification } from "@/models/entities/notification.js";
import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js";
export class NotificationHelpers { 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<Notification[]> { 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<Notification[]> {
@ -38,6 +39,13 @@ export class NotificationHelpers {
return Notifications.findOneBy({id: id, notifieeId: user.id}); return Notifications.findOneBy({id: id, notifieeId: user.id});
} }
public static async getNotificationOr404(id: string, user: ILocalUser): Promise<Notification> {
return this.getNotification(id, user).then(p => {
if (p) return p;
throw new MastoApiError(404);
});
}
public static async dismissNotification(id: string, user: ILocalUser): Promise<void> { public static async dismissNotification(id: string, user: ILocalUser): Promise<void> {
const result = await Notifications.update({id: id, notifieeId: user.id}, {isRead: true}); const result = await Notifications.update({id: id, notifieeId: user.id}, {isRead: true});
} }

View file

@ -10,6 +10,7 @@ import { deliver } from "@/queue/index.js";
import { renderActivity } from "@/remote/activitypub/renderer/index.js"; import { renderActivity } from "@/remote/activitypub/renderer/index.js";
import renderVote from "@/remote/activitypub/renderer/vote.js"; import renderVote from "@/remote/activitypub/renderer/vote.js";
import { Not } from "typeorm"; import { Not } from "typeorm";
import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js";
export class PollHelpers { export class PollHelpers {
public static async getPoll(note: Note, user: ILocalUser | null): Promise<MastodonEntity.Poll> { public static async getPoll(note: Note, user: ILocalUser | null): Promise<MastodonEntity.Poll> {
@ -17,10 +18,12 @@ export class PollHelpers {
} }
public static async voteInPoll(choices: number[], note: Note, user: ILocalUser): Promise<MastodonEntity.Poll> { public static async voteInPoll(choices: number[], note: Note, user: ILocalUser): Promise<MastodonEntity.Poll> {
if (!note.hasPoll) throw new MastoApiError(404);
for (const choice of choices) { for (const choice of choices) {
const createdAt = new Date(); const createdAt = new Date();
if (!note.hasPoll) throw new Error('Note has no poll'); if (!note.hasPoll) throw new MastoApiError(404);
// Check blocking // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {

View file

@ -17,6 +17,7 @@ import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { awaitAll } from "@/prelude/await-all.js"; import { awaitAll } from "@/prelude/await-all.js";
import { unique } from "@/prelude/array.js"; import { unique } from "@/prelude/array.js";
import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js";
export class TimelineHelpers { export class TimelineHelpers {
public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<Note[]> { public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<Note[]> {
@ -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<Note[]> { 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<Note[]> {
if (limit > 40) limit = 40; if (limit > 40) limit = 40;
if (tag.length < 1) throw new MastoApiError(400, "Tag cannot be empty");
if (local && remote) { if (local && remote) {
throw new Error("local and remote are mutually exclusive options"); throw new Error("local and remote are mutually exclusive options");
} }

View file

@ -37,9 +37,9 @@ import { IceshrimpVisibility, VisibilityConverter } from "@/server/api/mastodon/
import { Files } from "formidable"; import { Files } from "formidable";
import { toSingleLast } from "@/prelude/array.js"; import { toSingleLast } from "@/prelude/array.js";
import { MediaHelpers } from "@/server/api/mastodon/helpers/media.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 { UserProfile } from "@/models/entities/user-profile.js";
import { verifyLink } from "@/services/fetch-rel-me.js"; import { verifyLink } from "@/services/fetch-rel-me.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
export type AccountCache = { export type AccountCache = {
locks: AsyncLock; locks: AsyncLock;
@ -192,8 +192,7 @@ export class UserHelpers {
} }
public static async verifyCredentials(user: ILocalUser): Promise<MastodonEntity.Account> { public static async verifyCredentials(user: ILocalUser): Promise<MastodonEntity.Account> {
// re-fetch local user because auth user possibly contains outdated info const acct = UserConverter.encode(user);
const acct = getUser(user.id).then(u => UserConverter.encode(u));
const profile = UserProfiles.findOneByOrFail({userId: user.id}); const profile = UserProfiles.findOneByOrFail({userId: user.id});
const privacy = this.getDefaultNoteVisibility(user); const privacy = this.getDefaultNoteVisibility(user);
const fields = profile.then(profile => profile.fields.map(field => { const fields = profile.then(profile => profile.fields.map(field => {
@ -220,10 +219,14 @@ export class UserHelpers {
}); });
} }
public static async getUserFromAcct(acct: string): Promise<User | null> { public static async getUserFromAcct(acct: string): Promise<User> {
const split = acct.toLowerCase().split('@'); const split = acct.toLowerCase().split('@');
if (split.length > 2) throw new Error('Invalid acct'); 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<LinkPaginationObject<MastodonEntity.MutedAccount[]>> { public static async getUserMutes(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<LinkPaginationObject<MastodonEntity.MutedAccount[]>> {
@ -514,6 +517,18 @@ export class UserHelpers {
}); });
} }
public static async getUserCachedOr404(id: string, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<User> {
return this.getUserCached(id, cache).catch(_ => {
throw new MastoApiError(404);
});
}
public static async getUserOr404(id: string): Promise<User> {
return getUser(id).catch(_ => {
throw new MastoApiError(404);
});
}
public static getFreshAccountCache(): AccountCache { public static getFreshAccountCache(): AccountCache {
return { return {
locks: new AsyncLock(), locks: new AsyncLock(),

View file

@ -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 { setupEndpointsAuth } from "./endpoints/auth.js";
import { setupEndpointsAccount } from "./endpoints/account.js"; import { setupEndpointsAccount } from "./endpoints/account.js";
import { setupEndpointsStatus } from "./endpoints/status.js"; import { setupEndpointsStatus } from "./endpoints/status.js";
@ -8,29 +9,19 @@ import { setupEndpointsNotifications } from "./endpoints/notifications.js";
import { setupEndpointsSearch } from "./endpoints/search.js"; import { setupEndpointsSearch } from "./endpoints/search.js";
import { setupEndpointsMedia } from "@/server/api/mastodon/endpoints/media.js"; import { setupEndpointsMedia } from "@/server/api/mastodon/endpoints/media.js";
import { setupEndpointsMisc } from "@/server/api/mastodon/endpoints/misc.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 { 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 { export function setupMastodonApi(router: Router): void {
router.use( setupMiddleware(router);
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();
});
setupEndpointsAuth(router); setupEndpointsAuth(router);
setupEndpointsAccount(router); setupEndpointsAccount(router);
setupEndpointsStatus(router); setupEndpointsStatus(router);
@ -42,3 +33,11 @@ export function setupMastodonApi(router: Router): void {
setupEndpointsList(router); setupEndpointsList(router);
setupEndpointsMisc(router); setupEndpointsMisc(router);
} }
function setupMiddleware(router: Router): void {
router.use(KoaBodyMiddleware());
router.use(NormalizeQueryMiddleware);
router.use(AuthMiddleware);
router.use(CacheMiddleware);
router.use(CatchErrorsMiddleware);
}

View file

@ -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<any>) {
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<any>) {
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();
};
}

View file

@ -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<any>) {
ctx.cache = UserHelpers.getFreshAccountCache();
await next();
}

View file

@ -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<any>) {
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;
}
}

View file

@ -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);
}

View file

@ -0,0 +1,12 @@
import { MastoContext } from "@/server/api/mastodon/index.js";
export async function NormalizeQueryMiddleware(ctx: MastoContext, next: () => Promise<any>) {
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();
}

View file

@ -29,11 +29,9 @@ import fileServer from "./file/index.js";
import proxyServer from "./proxy/index.js"; import proxyServer from "./proxy/index.js";
import webServer from "./web/index.js"; import webServer from "./web/index.js";
import { initializeStreamingServer } from "./api/streaming.js"; import { initializeStreamingServer } from "./api/streaming.js";
import { koaBody } from "koa-body";
import removeTrailingSlash from "koa-remove-trailing-slashes"; import removeTrailingSlash from "koa-remove-trailing-slashes";
import { v4 as uuid } from "uuid"; import { koaBody } from "koa-body";
import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; import { setupEndpointsAuthRoot } from "@/server/api/mastodon/endpoints/auth.js";
export const serverLogger = new Logger("server", "gray", false); export const serverLogger = new Logger("server", "gray", false);
// Init app // Init app
@ -83,24 +81,6 @@ app.use(mount("/proxy", proxyServer));
const router = new Router(); const router = new Router();
const mastoRouter = 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 // Routing
router.use(activityPub.routes()); router.use(activityPub.routes());
router.use(nodeinfo.routes()); router.use(nodeinfo.routes());
@ -136,55 +116,29 @@ router.get("/identicon/:x", async (ctx) => {
} }
}); });
//TODO: move these to auth.ts mastoRouter.use(
mastoRouter.get("/oauth/authorize", async (ctx) => { koaBody({
const { client_id, state, redirect_uri } = ctx.request.query; urlencoded: true,
console.log(ctx.request.req); multipart: true,
let param = "mastodon=true"; }),
if (state) param += `&state=${state}`; );
if (redirect_uri) param += `&redirect_uri=${redirect_uri}`;
const client = client_id ? client_id : ""; mastoRouter.use(async (ctx, next) => {
ctx.redirect( if (ctx.request.query) {
`${Buffer.from(client.toString(), "base64").toString()}?${param}`, 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) => { setupEndpointsAuthRoot(mastoRouter);
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;
}
});
// Register router // Register router
app.use(mastoRouter.routes());
app.use(router.routes()); app.use(router.routes());
app.use(mastoRouter.routes());
app.use(mount(webServer)); app.use(mount(webServer));