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

View file

@ -1,7 +1,4 @@
import { Announcement } from "@/models/entities/announcement.js";
import { ILocalUser } from "@/models/entities/user.js";
import { awaitAll } from "@/prelude/await-all";
import { AnnouncementReads } from "@/models/index.js";
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import mfm from "mfm-js";

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

View file

@ -1,72 +1,68 @@
import Router from "@koa/router";
import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js";
import { convertId, IdType } from "@/misc/convert-id.js";
const readScope = [
"read:account",
"read:drive",
"read:blocks",
"read:favorites",
"read:following",
"read:messaging",
"read:mutes",
"read:notifications",
"read:reactions",
"read:pages",
"read:page-likes",
"read:user-groups",
"read:channels",
"read:gallery",
"read:gallery-likes",
];
const writeScope = [
"write:account",
"write:drive",
"write:blocks",
"write:favorites",
"write:following",
"write:messaging",
"write:mutes",
"write:notes",
"write:notifications",
"write:reactions",
"write:votes",
"write:pages",
"write:page-likes",
"write:user-groups",
"write:channels",
"write:gallery",
"write:gallery-likes",
];
import { AuthConverter } from "@/server/api/mastodon/converters/auth.js";
import { v4 as uuid } from "uuid";
export function setupEndpointsAuth(router: Router): void {
router.post("/v1/apps", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query;
try {
let scope = body.scopes;
if (typeof scope === "string") scope = scope.split(" ");
const pushScope = new Set<string>();
for (const s of scope) {
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r);
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r);
}
const scopeArr = Array.from(pushScope);
let scope = body.scopes;
if (typeof scope === "string") scope = scope.split(" ");
const scopeArr = AuthConverter.decode(scope);
const red = body.redirect_uris;
const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']);
ctx.body = {
id: convertId(appData.id, IdType.MastodonId),
name: appData.name,
website: body.website,
redirect_uri: red,
client_id: Buffer.from(appData.url ?? "").toString("base64"),
client_secret: appData.clientSecret,
};
});
}
const red = body.redirect_uris;
const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']);
const returns = {
id: convertId(appData.id, IdType.MastodonId),
name: appData.name,
website: body.website,
redirect_uri: red,
client_id: Buffer.from(appData.url ?? "").toString("base64"),
client_secret: appData.clientSecret,
export function setupEndpointsAuthRoot(router: Router): void {
router.get("/oauth/authorize", async (ctx) => {
const { client_id, state, redirect_uri } = ctx.request.query;
let param = "mastodon=true";
if (state) param += `&state=${state}`;
if (redirect_uri) param += `&redirect_uri=${redirect_uri}`;
const client = client_id ? client_id : "";
ctx.redirect(
`${Buffer.from(client.toString(), "base64").toString()}?${param}`,
);
});
router.post("/oauth/token", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query;
if (body.grant_type === "client_credentials") {
ctx.body = {
access_token: uuid(),
token_type: "Bearer",
scope: "read",
created_at: Math.floor(new Date().getTime() / 1000),
};
ctx.body = returns;
} catch (e: any) {
console.error(e);
return;
}
let token = null;
if (body.code) {
token = body.code;
}
try {
const accessToken = await AuthHelpers.getAuthToken(body.client_secret, token ? token : "");
const ret = {
access_token: accessToken,
token_type: "Bearer",
scope: body.scope || "read write follow push",
created_at: Math.floor(new Date().getTime() / 1000),
};
ctx.body = ret;
} catch (err: any) {
console.error(err);
ctx.status = 401;
ctx.body = e.response.data;
ctx.body = err.response.data;
}
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,36 +2,21 @@ import Router from "@koa/router";
import { convertId, IdType } from "../../index.js";
import { convertAccountId, convertPollId, convertStatusIds, convertStatusEditIds, convertStatusSourceId, } from "../converters.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { getNote } from "@/server/api/common/getters.js";
import authenticate from "@/server/api/authenticate.js";
import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { Cache } from "@/misc/cache.js";
import AsyncLock from "async-lock";
import { ILocalUser } from "@/models/entities/user.js";
import { PollHelpers } from "@/server/api/mastodon/helpers/poll.js";
import { toArray } from "@/prelude/array.js";
const postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60);
const postIdempotencyLocks = new AsyncLock();
import { auth } from "@/server/api/mastodon/middleware/auth.js";
export function setupEndpointsStatus(router: Router): void {
router.post("/v1/statuses", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
const key = getIdempotencyKey(ctx.headers, user);
router.post("/v1/statuses",
auth(true, ['write:statuses']),
async (ctx) => {
const key = NoteHelpers.getIdempotencyKey(ctx.headers, ctx.user);
if (key !== null) {
const result = await getFromIdempotencyCache(key);
const result = await NoteHelpers.getFromIdempotencyCache(key);
if (result) {
ctx.body = result;
@ -40,645 +25,263 @@ export function setupEndpointsStatus(router: Router): void {
}
let request = NoteHelpers.normalizeComposeOptions(ctx.request.body);
ctx.body = await NoteHelpers.createNote(request, user)
.then(p => NoteConverter.encode(p, user))
ctx.body = await NoteHelpers.createNote(request, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
if (key !== null) postIdempotencyCache.set(key, {status: ctx.body});
} catch (e: any) {
console.error(e);
ctx.status = 500;
ctx.body = {error: e.message};
if (key !== null) NoteHelpers.postIdempotencyCache.set(key, {status: ctx.body});
}
});
router.put("/v1/statuses/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
);
router.put("/v1/statuses/:id",
auth(true, ['write:statuses']),
async (ctx) => {
const noteId = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null);
if (!note) {
if (!note) {
ctx.status = 404;
ctx.body = {
error: "Note not found"
};
return;
}
}
const note = await NoteHelpers.getNoteOr404(noteId, ctx.user);
let request = NoteHelpers.normalizeEditOptions(ctx.request.body);
ctx.body = await NoteHelpers.editNote(request, note, user)
.then(p => NoteConverter.encode(p, user))
ctx.body = await NoteHelpers.editNote(request, note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = ctx.status == 404 ? 404 : 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
);
router.get<{ Params: { id: string } }>("/v1/statuses/:id",
auth(false, ["read:statuses"]),
async (ctx) => {
const noteId = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null);
const note = await NoteHelpers.getNoteOr404(noteId, ctx.user);
if (!note) {
ctx.status = 404;
return;
}
const status = await NoteConverter.encode(note, user);
const status = await NoteConverter.encode(note, ctx.user);
ctx.body = convertStatusIds(status);
} catch (e: any) {
console.error(e);
ctx.status = ctx.status == 404 ? 404 : 401;
ctx.body = e.response.data;
}
});
router.delete<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
);
router.delete<{ Params: { id: string } }>("/v1/statuses/:id",
auth(true, ['write:statuses']),
async (ctx) => {
const noteId = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null);
if (!note) {
ctx.status = 404;
ctx.body = {
error: "Note not found"
};
return;
}
if (user.id !== note.userId) {
ctx.status = 403;
ctx.body = {
error: "Cannot delete someone else's note"
};
return;
}
ctx.body = await NoteHelpers.deleteNote(note, user)
const note = await NoteHelpers.getNoteOr404(noteId, ctx.user);
ctx.body = await NoteHelpers.deleteNote(note, ctx.user)
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(`Error processing ${ctx.method} /api${ctx.path}: ${e.message}`);
ctx.status = 500;
ctx.body = {
error: e.message
}
}
});
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/context",
auth(false, ["read:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const ancestors = await NoteHelpers.getNoteAncestors(note, ctx.user, ctx.user ? 4096 : 60)
.then(n => NoteConverter.encodeMany(n, ctx.user, ctx.cache))
.then(n => n.map(s => convertStatusIds(s)));
const descendants = await NoteHelpers.getNoteDescendants(note, ctx.user, ctx.user ? 4096 : 40, ctx.user ? 4096 : 20)
.then(n => NoteConverter.encodeMany(n, ctx.user, ctx.cache))
.then(n => n.map(s => convertStatusIds(s)));
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const cache = UserHelpers.getFreshAccountCache();
const note = await getNote(id, user ?? null).then(n => n).catch(() => null);
if (!note) {
if (!note) {
ctx.status = 404;
return;
}
}
const ancestors = await NoteHelpers.getNoteAncestors(note, user, user ? 4096 : 60)
.then(n => NoteConverter.encodeMany(n, user, cache))
.then(n => n.map(s => convertStatusIds(s)));
const descendants = await NoteHelpers.getNoteDescendants(note, user, user ? 4096 : 40, user ? 4096 : 20)
.then(n => NoteConverter.encodeMany(n, user, cache))
.then(n => n.map(s => convertStatusIds(s)));
ctx.body = {
ancestors,
descendants,
};
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
ctx.body = {
ancestors,
descendants,
};
}
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/history",
auth(false, ["read:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
const res = await NoteHelpers.getNoteEditHistory(note);
ctx.body = res.map(p => convertStatusEditIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const res = await NoteHelpers.getNoteEditHistory(note);
ctx.body = res.map(p => convertStatusEditIds(p));
}
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/source",
auth(true, ["read:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
const src = NoteHelpers.getNoteSource(note);
ctx.body = convertStatusSourceId(src);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const src = NoteHelpers.getNoteSource(note);
ctx.body = convertStatusSourceId(src);
}
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/reblogged_by",
auth(false, ["read:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any)));
const res = await NoteHelpers.getNoteRebloggedBy(note, user, args.max_id, args.since_id, args.min_id, args.limit);
const users = await UserConverter.encodeMany(res.data, cache);
ctx.body = users.map(m => convertAccountId(m));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any)));
const res = await NoteHelpers.getNoteRebloggedBy(note, ctx.user, args.max_id, args.since_id, args.min_id, args.limit);
const users = await UserConverter.encodeMany(res.data, ctx.cache);
ctx.body = users.map(m => convertAccountId(m));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
}
);
router.get<{ Params: { id: string } }>(
"/v1/statuses/:id/favourited_by",
auth(false, ["read:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any)));
const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit);
const users = await UserConverter.encodeMany(res.data, cache);
ctx.body = users.map(m => convertAccountId(m));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any)));
const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit);
const users = await UserConverter.encodeMany(res.data, ctx.cache);
ctx.body = users.map(m => convertAccountId(m));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 40);
}
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/favourite",
auth(true, ["write:favourites"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const reaction = await NoteHelpers.getDefaultReaction();
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
const reaction = await NoteHelpers.getDefaultReaction().catch(_ => null);
if (reaction === null) {
ctx.status = 500;
return;
}
ctx.body = await NoteHelpers.reactToNote(note, user, reaction)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 400;
ctx.body = e.response.data;
}
},
ctx.body = await NoteHelpers.reactToNote(note, ctx.user, reaction)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
}
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unfavourite",
auth(true, ["write:favourites"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.removeReactFromNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/reblog",
auth(true, ["write:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.reblogNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.reblogNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unreblog",
auth(true, ["write:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.unreblogNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.unreblogNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/bookmark",
auth(true, ["write:bookmarks"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.bookmarkNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.bookmarkNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unbookmark",
auth(true, ["write:bookmarks"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.unbookmarkNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.unbookmarkNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/pin",
auth(true, ["write:accounts"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.pinNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.pinNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string } }>(
"/v1/statuses/:id/unpin",
auth(true, ["write:accounts"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.unpinNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.unpinNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string; name: string } }>(
"/v1/statuses/:id/react/:name",
auth(true, ["write:favourites"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.reactToNote(note, user, ctx.params.name)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.reactToNote(note, ctx.user, ctx.params.name)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.post<{ Params: { id: string; name: string } }>(
"/v1/statuses/:id/unreact/:name",
auth(true, ["write:favourites"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null) {
ctx.status = 404;
return;
}
ctx.body = await NoteHelpers.removeReactFromNote(note, user)
.then(p => NoteConverter.encode(p, user))
.then(p => convertStatusIds(p));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx.user)
.then(p => NoteConverter.encode(p, ctx.user))
.then(p => convertStatusIds(p));
},
);
router.get<{ Params: { id: string } }>("/v1/polls/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
router.get<{ Params: { id: string } }>("/v1/polls/:id",
auth(false, ["read:statuses"]),
async (ctx) => {
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null || !note.hasPoll) {
ctx.status = 404;
return;
}
const data = await PollHelpers.getPoll(note, user);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
const data = await PollHelpers.getPoll(note, ctx.user);
ctx.body = convertPollId(data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>(
"/v1/polls/:id/votes",
auth(true, ["write:statuses"]),
async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await NoteHelpers.getNoteOr404(id, ctx.user);
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const note = await getNote(id, user).catch(_ => null);
if (note === null || !note.hasPoll) {
ctx.status = 404;
return;
}
const body: any = ctx.request.body;
const choices = toArray(body.choices ?? []).map(p => parseInt(p));
if (choices.length < 1) {
ctx.status = 400;
ctx.body = {error: 'Must vote for at least one option'};
return;
}
const data = await PollHelpers.voteInPoll(choices, note, user);
ctx.body = convertPollId(data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
const body: any = ctx.request.body;
const choices = toArray(body.choices ?? []).map(p => parseInt(p));
if (choices.length < 1) {
ctx.status = 400;
ctx.body = {error: 'Must vote for at least one option'};
return;
}
const data = await PollHelpers.voteInPoll(choices, note, ctx.user);
ctx.body = convertPollId(data);
},
);
}
function getIdempotencyKey(headers: any, user: ILocalUser): string | null {
if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null;
return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1)! : headers["idempotency-key"]}`;
}
async function getFromIdempotencyCache(key: string): Promise<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 { convertConversationIds, convertStatusIds, } from "../converters.js";
import { convertId, IdType } from "../../index.js";
import authenticate from "@/server/api/authenticate.js";
import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { UserLists } from "@/models/index.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { auth } from "@/server/api/mastodon/middleware/auth.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
//TODO: Move helper functions to a helper class
export function limitToInt(q: ParsedUrlQuery, additional: string[] = []) {
let object: any = q;
if (q.limit)
@ -63,138 +65,63 @@ export function normalizeUrlQuery(q: ParsedUrlQuery, arrayKeys: string[] = []):
}
export function setupEndpointsTimeline(router: Router): void {
router.get("/v1/timelines/public", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
router.get("/v1/timelines/public",
auth(true, ['read:statuses']),
async (ctx, reply) => {
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))));
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getPublicTimeline(user, args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote)
.then(n => NoteConverter.encodeMany(n, user, cache));
const tl = await TimelineHelpers.getPublicTimeline(ctx.user, args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote)
.then(n => NoteConverter.encodeMany(n, ctx.user, cache));
ctx.body = tl.map(s => convertStatusIds(s));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { hashtag: string } }>(
"/v1/timelines/tag/:hashtag",
auth(false, ['read:statuses']),
async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
const tag = (ctx.params.hashtag ?? '').trim();
if (tag.length < 1) {
ctx.status = 400;
ctx.body = { error: "tag cannot be empty" };
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))), ['any[]', 'all[]', 'none[]']);
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getTagTimeline(user, tag, args.max_id, args.since_id, args.min_id, args.limit, args['any[]'] ?? [], args['all[]'] ?? [], args['none[]'] ?? [], args.only_media, args.local, args.remote)
.then(n => NoteConverter.encodeMany(n, user, cache));
ctx.body = tl.map(s => convertStatusIds(s));
} catch (e: any) {
ctx.status = 400;
ctx.body = { error: e.message };
}
},
);
router.get("/v1/timelines/home", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const tag = (ctx.params.hashtag ?? '').trim();
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))), ['any[]', 'all[]', 'none[]']);
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getHomeTimeline(user, args.max_id, args.since_id, args.min_id, args.limit)
.then(n => NoteConverter.encodeMany(n, user, cache));
const tl = await TimelineHelpers.getTagTimeline(ctx.user, tag, args.max_id, args.since_id, args.min_id, args.limit, args['any[]'] ?? [], args['all[]'] ?? [], args['none[]'] ?? [], args.only_media, args.local, args.remote)
.then(n => NoteConverter.encodeMany(n, ctx.user, cache));
ctx.body = tl.map(s => convertStatusIds(s));
},
);
router.get("/v1/timelines/home",
auth(true, ['read:statuses']),
async (ctx, reply) => {
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getHomeTimeline(ctx.user, args.max_id, args.since_id, args.min_id, args.limit)
.then(n => NoteConverter.encodeMany(n, ctx.user, cache));
ctx.body = tl.map(s => convertStatusIds(s));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { listId: string } }>(
"/v1/timelines/list/:listId",
auth(true, ['read:lists']),
async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
const listId = convertId(ctx.params.listId, IdType.IceshrimpId);
const list = await UserLists.findOneBy({userId: user.id, id: listId});
if (!list) {
ctx.status = 404;
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getListTimeline(user, list, args.max_id, args.since_id, args.min_id, args.limit)
.then(n => NoteConverter.encodeMany(n, user, cache));
ctx.body = tl.map(s => convertStatusIds(s));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/conversations", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
const listId = convertId(ctx.params.listId, IdType.IceshrimpId);
const list = await UserLists.findOneBy({userId: ctx.user.id, id: listId});
if (!list) throw new MastoApiError(404);
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const res = await TimelineHelpers.getConversations(user, args.max_id, args.since_id, args.min_id, args.limit);
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getListTimeline(ctx.user, list, args.max_id, args.since_id, args.min_id, args.limit)
.then(n => NoteConverter.encodeMany(n, ctx.user, cache));
ctx.body = tl.map(s => convertStatusIds(s));
},
);
router.get("/v1/conversations",
auth(true, ['read:statuses']),
async (ctx, reply) => {
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const res = await TimelineHelpers.getConversations(ctx.user, args.max_id, args.since_id, args.min_id, args.limit);
ctx.body = res.data.map(c => convertConversationIds(c));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res, 20);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
);
}

View file

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

View file

@ -4,9 +4,12 @@ import { DriveFiles } from "@/models/index.js";
import { Packed } from "@/misc/schema.js";
import { DriveFile } from "@/models/entities/drive-file.js";
import { File } from "formidable";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
export class MediaHelpers {
public static async uploadMedia(user: ILocalUser, file: File, body: any): Promise<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({
user: user,
path: file.filepath,
@ -40,7 +43,21 @@ export class MediaHelpers {
.then(p => p ? DriveFiles.pack(p) : null);
}
public static async getMediaPackedOr404(user: ILocalUser, id: string): Promise<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> {
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 { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
import { toArray } from "@/prelude/array.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
import { Cache } from "@/misc/cache.js";
import AsyncLock from "async-lock";
export class NoteHelpers {
public static postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60);
public static postIdempotencyLocks = new AsyncLock();
public static async getDefaultReaction(): Promise<string> {
return Metas.createQueryBuilder()
.select('"defaultReaction"')
.execute()
.then(p => p[0].defaultReaction);
.then(p => p[0].defaultReaction)
.then(p => {
if (p != null) return p;
throw new MastoApiError(500, "Failed to get default reaction");
});
}
public static async reactToNote(note: Note, user: ILocalUser, reaction: string): Promise<Note> {
@ -122,7 +132,7 @@ export class NoteHelpers {
}
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);
await deleteNote(user, note);
status.content = undefined;
@ -376,4 +386,35 @@ export class NoteHelpers {
return result;
}
public static async getNoteOr404(id: string, user: ILocalUser | null): Promise<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 { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { Notification } from "@/models/entities/notification.js";
import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js";
export class NotificationHelpers {
public static async getNotifications(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, types: string[] | undefined, excludeTypes: string[] | undefined, accountId: string | undefined): Promise<Notification[]> {
@ -38,6 +39,13 @@ export class NotificationHelpers {
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> {
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 renderVote from "@/remote/activitypub/renderer/vote.js";
import { Not } from "typeorm";
import {MastoApiError} from "@/server/api/mastodon/middleware/catch-errors.js";
export class PollHelpers {
public static async getPoll(note: Note, user: ILocalUser | null): Promise<MastodonEntity.Poll> {
@ -17,10 +18,12 @@ export class PollHelpers {
}
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) {
const createdAt = new Date();
if (!note.hasPoll) throw new Error('Note has no poll');
if (!note.hasPoll) throw new MastoApiError(404);
// Check blocking
if (note.userId !== user.id) {

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

View file

@ -37,9 +37,9 @@ import { IceshrimpVisibility, VisibilityConverter } from "@/server/api/mastodon/
import { Files } from "formidable";
import { toSingleLast } from "@/prelude/array.js";
import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js";
import { FileConverter } from "@/server/api/mastodon/converters/file.js";
import { UserProfile } from "@/models/entities/user-profile.js";
import { verifyLink } from "@/services/fetch-rel-me.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
export type AccountCache = {
locks: AsyncLock;
@ -192,8 +192,7 @@ export class UserHelpers {
}
public static async verifyCredentials(user: ILocalUser): Promise<MastodonEntity.Account> {
// re-fetch local user because auth user possibly contains outdated info
const acct = getUser(user.id).then(u => UserConverter.encode(u));
const acct = UserConverter.encode(user);
const profile = UserProfiles.findOneByOrFail({userId: user.id});
const privacy = this.getDefaultNoteVisibility(user);
const fields = profile.then(profile => profile.fields.map(field => {
@ -220,10 +219,14 @@ export class UserHelpers {
});
}
public static async getUserFromAcct(acct: string): Promise<User | null> {
public static async getUserFromAcct(acct: string): Promise<User> {
const split = acct.toLowerCase().split('@');
if (split.length > 2) throw new Error('Invalid acct');
return Users.findOneBy({usernameLower: split[0], host: split[1] ?? IsNull()});
return Users.findOneBy({usernameLower: split[0], host: split[1] ?? IsNull()})
.then(p => {
if (p) return p;
throw new MastoApiError(404);
});
}
public static async getUserMutes(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<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 {
return {
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 { setupEndpointsAccount } from "./endpoints/account.js";
import { setupEndpointsStatus } from "./endpoints/status.js";
@ -8,29 +9,19 @@ import { setupEndpointsNotifications } from "./endpoints/notifications.js";
import { setupEndpointsSearch } from "./endpoints/search.js";
import { setupEndpointsMedia } from "@/server/api/mastodon/endpoints/media.js";
import { setupEndpointsMisc } from "@/server/api/mastodon/endpoints/misc.js";
import { HttpMethodEnum, koaBody } from "koa-body";
import { setupEndpointsList } from "@/server/api/mastodon/endpoints/list.js";
import { AuthMiddleware } from "@/server/api/mastodon/middleware/auth.js";
import { CatchErrorsMiddleware } from "@/server/api/mastodon/middleware/catch-errors.js";
import { apiLogger } from "@/server/api/logger.js";
import { CacheMiddleware } from "@/server/api/mastodon/middleware/cache.js";
import { KoaBodyMiddleware } from "@/server/api/mastodon/middleware/koa-body.js";
import { NormalizeQueryMiddleware } from "@/server/api/mastodon/middleware/normalize-query.js";
export const logger = apiLogger.createSubLogger("mastodon");
export type MastoContext = RouterContext & DefaultContext;
export function setupMastodonApi(router: Router): void {
router.use(
koaBody({
multipart: true,
urlencoded: true,
parsedMethods: [HttpMethodEnum.POST, HttpMethodEnum.PUT, HttpMethodEnum.PATCH, HttpMethodEnum.DELETE] // dear god mastodon why
}),
);
router.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = {...ctx.request.body, ...ctx.request.query};
}
}
await next();
});
setupMiddleware(router);
setupEndpointsAuth(router);
setupEndpointsAccount(router);
setupEndpointsStatus(router);
@ -42,3 +33,11 @@ export function setupMastodonApi(router: Router): void {
setupEndpointsList(router);
setupEndpointsMisc(router);
}
function setupMiddleware(router: Router): void {
router.use(KoaBodyMiddleware());
router.use(NormalizeQueryMiddleware);
router.use(AuthMiddleware);
router.use(CacheMiddleware);
router.use(CatchErrorsMiddleware);
}

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 webServer from "./web/index.js";
import { initializeStreamingServer } from "./api/streaming.js";
import { koaBody } from "koa-body";
import removeTrailingSlash from "koa-remove-trailing-slashes";
import { v4 as uuid } from "uuid";
import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js";
import { koaBody } from "koa-body";
import { setupEndpointsAuthRoot } from "@/server/api/mastodon/endpoints/auth.js";
export const serverLogger = new Logger("server", "gray", false);
// Init app
@ -83,24 +81,6 @@ app.use(mount("/proxy", proxyServer));
const router = new Router();
const mastoRouter = new Router();
mastoRouter.use(
koaBody({
urlencoded: true,
multipart: true,
}),
);
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
// Routing
router.use(activityPub.routes());
router.use(nodeinfo.routes());
@ -136,55 +116,29 @@ router.get("/identicon/:x", async (ctx) => {
}
});
//TODO: move these to auth.ts
mastoRouter.get("/oauth/authorize", async (ctx) => {
const { client_id, state, redirect_uri } = ctx.request.query;
console.log(ctx.request.req);
let param = "mastodon=true";
if (state) param += `&state=${state}`;
if (redirect_uri) param += `&redirect_uri=${redirect_uri}`;
const client = client_id ? client_id : "";
ctx.redirect(
`${Buffer.from(client.toString(), "base64").toString()}?${param}`,
);
mastoRouter.use(
koaBody({
urlencoded: true,
multipart: true,
}),
);
mastoRouter.use(async (ctx, next) => {
if (ctx.request.query) {
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
ctx.request.body = ctx.request.query;
} else {
ctx.request.body = { ...ctx.request.body, ...ctx.request.query };
}
}
await next();
});
mastoRouter.post("/oauth/token", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query;
console.log("token-request", body);
console.log("token-query", ctx.request.query);
if (body.grant_type === "client_credentials") {
ctx.body = {
access_token: uuid(),
token_type: "Bearer",
scope: "read",
created_at: Math.floor(new Date().getTime() / 1000),
};
return;
}
let token = null;
if (body.code) {
token = body.code;
}
try {
const accessToken = await AuthHelpers.getAuthToken(body.client_secret, token ? token : "");
const ret = {
access_token: accessToken,
token_type: "Bearer",
scope: body.scope || "read write follow push",
created_at: Math.floor(new Date().getTime() / 1000),
};
ctx.body = ret;
} catch (err: any) {
console.error(err);
ctx.status = 401;
ctx.body = err.response.data;
}
});
setupEndpointsAuthRoot(mastoRouter);
// Register router
app.use(mastoRouter.routes());
app.use(router.routes());
app.use(mastoRouter.routes());
app.use(mount(webServer));