Fixed 267U.pre2

This commit is contained in:
2026-07-26 18:25:37 +09:00
parent 50bfaeafdf
commit 317d00a284
1286 changed files with 80222 additions and 1 deletions
@@ -0,0 +1,172 @@
import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { UserConverter } from "../converters/user.js";
import { NoteConverter } from "../converters/note.js";
import { UserHelpers } from "../helpers/user.js";
import { ListHelpers } from "../helpers/list.js";
import { auth } from "../middleware/auth.js";
import { SearchHelpers } from "../helpers/search.js";
import { filterContext } from "../middleware/filter-context.js";
export function setupEndpointsAccount(router) {
router.get("/v1/accounts/verify_credentials", auth(true, [
'read:accounts'
]), async (ctx)=>{
ctx.body = await UserHelpers.verifyCredentials(ctx);
});
router.patch("/v1/accounts/update_credentials", auth(true, [
'write:accounts'
]), async (ctx)=>{
ctx.body = await UserHelpers.updateCredentials(ctx);
});
router.get("/v1/accounts/lookup", async (ctx)=>{
const args = normalizeUrlQuery(ctx.query);
const user = await UserHelpers.getUserFromAcct(args.acct);
ctx.body = await UserConverter.encode(user, ctx);
});
router.get("/v1/accounts/relationships", auth(true, [
'read:follows'
]), async (ctx)=>{
const ids = normalizeUrlQuery(ctx.query, [
'id[]'
])['id[]'] ?? normalizeUrlQuery(ctx.query, [
'id'
])['id'] ?? [];
ctx.body = await UserHelpers.getUserRelationhipToMany(ids, ctx.user.id);
});
// This must come before /accounts/:id, otherwise that will take precedence
router.get("/v1/accounts/search", auth(true, [
'read:accounts'
]), async (ctx)=>{
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query), [
'resolve',
'following'
]));
ctx.body = await SearchHelpers.search(args.q, 'accounts', args.resolve, args.following, undefined, false, undefined, undefined, args.limit, args.offset, ctx).then((p)=>p.accounts);
});
router.get("/v1/accounts/:id", auth(false), async (ctx)=>{
ctx.body = await UserConverter.encode(await UserHelpers.getUserOr404(ctx.params.id), ctx);
});
router.get("/v1/accounts/:id/statuses", auth(false, [
"read:statuses"
]), filterContext('account'), async (ctx)=>{
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)));
const res = await UserHelpers.getUserStatuses(query, args.max_id, args.since_id, args.min_id, args.limit, args['only_media'], args['exclude_replies'], args['exclude_reblogs'], args.pinned, args.tagged, ctx);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/accounts/:id/featured_tags", async (ctx)=>{
ctx.body = [];
});
router.get("/v1/accounts/:id/followers", auth(false), async (ctx)=>{
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await UserHelpers.getUserFollowers(query, args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.get("/v1/accounts/:id/following", auth(false), async (ctx)=>{
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await UserHelpers.getUserFollowing(query, args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.get("/v1/accounts/:id/lists", auth(true, [
"read:lists"
]), async (ctx)=>{
const member = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await ListHelpers.getListsByMember(member, ctx);
});
router.post("/v1/accounts/:id/follow", auth(true, [
"write:follows"
]), async (ctx)=>{
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
//FIXME: Parse form data
ctx.body = await UserHelpers.followUser(target, true, false, ctx);
});
router.post("/v1/accounts/:id/unfollow", auth(true, [
"write:follows"
]), async (ctx)=>{
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.unfollowUser(target, ctx);
});
router.post("/v1/accounts/:id/block", auth(true, [
"write:blocks"
]), async (ctx)=>{
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.blockUser(target, ctx);
});
router.post("/v1/accounts/:id/unblock", auth(true, [
"write:blocks"
]), async (ctx)=>{
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.unblockUser(target, ctx);
});
router.post("/v1/accounts/:id/mute", auth(true, [
"write:mutes"
]), async (ctx)=>{
//FIXME: parse form data
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, [
'duration'
]), [
'notifications'
]));
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.muteUser(target, args.notifications, args.duration, ctx);
});
router.post("/v1/accounts/:id/unmute", auth(true, [
"write:mutes"
]), async (ctx)=>{
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.unmuteUser(target, ctx);
});
router.get("/v1/featured_tags", async (ctx)=>{
ctx.body = [];
});
router.get("/v1/followed_tags", async (ctx)=>{
ctx.body = [];
});
router.get("/v1/bookmarks", auth(true, [
"read:bookmarks"
]), async (ctx)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await UserHelpers.getUserBookmarks(args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/favourites", auth(true, [
"read:favourites"
]), async (ctx)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await UserHelpers.getUserFavorites(args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/mutes", auth(true, [
"read:mutes"
]), async (ctx)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
ctx.body = await UserHelpers.getUserMutes(args.max_id, args.since_id, args.min_id, args.limit, ctx);
});
router.get("/v1/blocks", auth(true, [
"read:blocks"
]), async (ctx)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await UserHelpers.getUserBlocks(args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.get("/v1/follow_requests", auth(true, [
"read:follows"
]), async (ctx)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await UserHelpers.getUserFollowRequests(args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.post("/v1/follow_requests/:id/authorize", auth(true, [
"write:follows"
]), async (ctx)=>{
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.acceptFollowRequest(target, ctx);
});
router.post("/v1/follow_requests/:id/reject", auth(true, [
"write:follows"
]), async (ctx)=>{
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
ctx.body = await UserHelpers.rejectFollowRequest(target, ctx);
});
}
@@ -0,0 +1,24 @@
import { AuthHelpers } from "../helpers/auth.js";
import { MiAuth } from "../middleware/auth.js";
export function setupEndpointsAuth(router) {
router.post("/v1/apps", async (ctx)=>{
ctx.body = await AuthHelpers.registerApp(ctx);
});
router.get("/v1/apps/verify_credentials", async (ctx)=>{
ctx.body = await AuthHelpers.verifyAppCredentials(ctx);
});
router.post("/v1/iceshrimp/apps/info", MiAuth(true), async (ctx)=>{
ctx.body = await AuthHelpers.getAppInfo(ctx);
});
router.post("/v1/iceshrimp/auth/code", MiAuth(true), async (ctx)=>{
ctx.body = await AuthHelpers.getAuthCode(ctx);
});
}
export function setupEndpointsAuthRoot(router) {
router.post("/oauth/token", async (ctx)=>{
ctx.body = await AuthHelpers.getAuthToken(ctx);
});
router.post("/oauth/revoke", async (ctx)=>{
ctx.body = await AuthHelpers.revokeAuthToken(ctx);
});
}
@@ -0,0 +1,20 @@
import { auth } from "../middleware/auth.js";
import { MastoApiError } from "../middleware/catch-errors.js";
export function setupEndpointsFilter(router) {
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)=>{
throw new MastoApiError(400, "Please change word mute settings in the web frontend settings.");
});
}
@@ -0,0 +1,88 @@
import { limitToInt, normalizeUrlQuery } from "./timeline.js";
import { ListHelpers } from "../helpers/list.js";
import { UserConverter } from "../converters/user.js";
import { UserLists } from "../../../../models/index.js";
import { getUser } from "../../common/getters.js";
import { toArray } from "../../../../prelude/array.js";
import { auth } from "../middleware/auth.js";
import { MastoApiError } from "../middleware/catch-errors.js";
export function setupEndpointsList(router) {
router.get("/v1/lists", auth(true, [
'read:lists'
]), async (ctx, reply)=>{
ctx.body = await ListHelpers.getLists(ctx);
});
router.get("/v1/lists/:id", auth(true, [
'read:lists'
]), async (ctx, reply)=>{
ctx.body = await ListHelpers.getListOr404(ctx.params.id, ctx);
});
router.post("/v1/lists", auth(true, [
'write:lists'
]), async (ctx, reply)=>{
const body = ctx.request.body;
const title = (body.title ?? '').trim();
ctx.body = await ListHelpers.createList(title, ctx);
});
router.put("/v1/lists/:id", auth(true, [
'write:lists'
]), async (ctx, reply)=>{
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id
});
if (!list) throw new MastoApiError(404);
const body = ctx.request.body;
const title = (body.title ?? '').trim();
const exclusive = body.exclusive ?? undefined;
ctx.body = await ListHelpers.updateList(list, title, exclusive, ctx);
});
router.delete("/v1/lists/:id", auth(true, [
'write:lists'
]), async (ctx, reply)=>{
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id
});
if (!list) throw new MastoApiError(404);
await ListHelpers.deleteList(list, ctx);
ctx.body = {};
});
router.get("/v1/lists/:id/accounts", auth(true, [
'read:lists'
]), async (ctx, reply)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await ListHelpers.getListUsers(ctx.params.id, args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.post("/v1/lists/:id/accounts", auth(true, [
'write:lists'
]), async (ctx, reply)=>{
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id
});
if (!list) throw new MastoApiError(404);
const body = ctx.request.body;
if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field");
const ids = toArray(body['account_ids']);
const targets = await Promise.all(ids.map((p)=>getUser(p)));
await ListHelpers.addToList(list, targets, ctx);
ctx.body = {};
});
router.delete("/v1/lists/:id/accounts", auth(true, [
'write:lists'
]), async (ctx, reply)=>{
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.id
});
if (!list) throw new MastoApiError(404);
const body = ctx.request.body;
if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field");
const ids = toArray(body['account_ids']);
const targets = await Promise.all(ids.map((p)=>getUser(p)));
await ListHelpers.removeFromList(list, targets, ctx);
ctx.body = {};
});
}
@@ -0,0 +1,25 @@
import { MediaHelpers } from "../helpers/media.js";
import { FileConverter } from "../converters/file.js";
import { auth } from "../middleware/auth.js";
export function setupEndpointsMedia(router) {
router.get("/v1/media/:id", auth(true, [
'write:media'
]), async (ctx)=>{
const file = await MediaHelpers.getMediaPackedOr404(ctx.params.id, ctx);
ctx.body = FileConverter.encode(file);
});
router.put("/v1/media/:id", auth(true, [
'write:media'
]), async (ctx)=>{
const file = await MediaHelpers.getMediaOr404(ctx.params.id, ctx);
ctx.body = await MediaHelpers.updateMedia(file, ctx).then((p)=>FileConverter.encode(p));
});
router.post([
"/v2/media",
"/v1/media"
], auth(true, [
'write:media'
]), async (ctx)=>{
ctx.body = await MediaHelpers.uploadMedia(ctx).then((p)=>FileConverter.encode(p));
});
}
@@ -0,0 +1,57 @@
import { MiscHelpers } from "../helpers/misc.js";
import { argsToBools, limitToInt } from "./timeline.js";
import { Announcements } from "../../../../models/index.js";
import { auth } from "../middleware/auth.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { filterContext } from "../middleware/filter-context.js";
export function setupEndpointsMisc(router) {
router.get("/v1/custom_emojis", async (ctx)=>{
ctx.body = await MiscHelpers.getCustomEmoji();
});
router.get("/v1/instance", async (ctx)=>{
ctx.body = await MiscHelpers.getInstance(ctx);
});
router.get("/v1/announcements", auth(true), async (ctx)=>{
const args = argsToBools(ctx.query, [
'with_dismissed'
]);
ctx.body = await MiscHelpers.getAnnouncements(args['with_dismissed'], ctx);
});
router.post("/v1/announcements/:id/dismiss", auth(true, [
'write:accounts'
]), async (ctx)=>{
const announcement = await Announcements.findOneBy({
id: ctx.params.id
});
if (!announcement) throw new MastoApiError(404);
await MiscHelpers.dismissAnnouncement(announcement, ctx);
ctx.body = {};
});
//FIXME: add link pagination to trends (ref: https://mastodon.social/api/v1/trends/tags?offset=10&limit=1)
router.get([
"/v1/trends/tags",
"/v1/trends"
], async (ctx)=>{
const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getTrendingHashtags(args.limit, args.offset);
//FIXME: convert ids
});
router.get("/v1/trends/statuses", filterContext('public'), async (ctx)=>{
const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getTrendingStatuses(args.limit, args.offset, ctx);
});
router.get("/v1/trends/links", async (ctx)=>{
ctx.body = [];
});
router.get("/v1/preferences", auth(true, [
'read:accounts'
]), async (ctx)=>{
ctx.body = await MiscHelpers.getPreferences(ctx);
});
router.get("/v2/suggestions", auth(true, [
'read:accounts'
]), async (ctx)=>{
const args = limitToInt(ctx.query);
ctx.body = await MiscHelpers.getFollowSuggestions(args.limit, ctx);
});
}
@@ -0,0 +1,42 @@
import { limitToInt, normalizeUrlQuery } from "./timeline.js";
import { NotificationHelpers } from "../helpers/notification.js";
import { NotificationConverter } from "../converters/notification.js";
import { auth } from "../middleware/auth.js";
import { filterContext } from "../middleware/filter-context.js";
export function setupEndpointsNotifications(router) {
router.get("/v1/notifications", auth(true, [
'read:notifications'
]), filterContext('notifications'), async (ctx)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query), [
'types[]',
'exclude_types[]'
]);
const res = await NotificationHelpers.getNotifications(args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id, ctx);
ctx.body = await NotificationConverter.encodeMany(res, ctx);
});
router.get("/v1/notifications/:id", auth(true, [
'read:notifications'
]), filterContext('notifications'), async (ctx)=>{
const notification = await NotificationHelpers.getNotificationOr404(ctx.params.id, ctx);
ctx.body = await NotificationConverter.encode(notification, ctx);
});
router.post("/v1/notifications/clear", auth(true, [
'write:notifications'
]), async (ctx)=>{
await NotificationHelpers.clearAllNotifications(ctx);
ctx.body = {};
});
router.post("/v1/notifications/:id/dismiss", auth(true, [
'write:notifications'
]), async (ctx)=>{
const notification = await NotificationHelpers.getNotificationOr404(ctx.params.id, ctx);
await NotificationHelpers.dismissNotification(notification.id, ctx);
ctx.body = {};
});
router.post("/v1/conversations/:id/read", auth(true, [
'write:conversations'
]), async (ctx, reply)=>{
await NotificationHelpers.markConversationAsRead(ctx.params.id, ctx);
ctx.body = {};
});
}
@@ -0,0 +1,24 @@
import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { SearchHelpers } from "../helpers/search.js";
import { auth } from "../middleware/auth.js";
export function setupEndpointsSearch(router) {
router.get([
"/v1/search",
"/v2/search"
], auth(true, [
'read:search'
]), async (ctx)=>{
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query), [
'resolve',
'following',
'exclude_unreviewed'
]));
ctx.body = await SearchHelpers.search(args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, ctx);
if (ctx.path === "/v1/search") {
ctx.body = {
...ctx.body,
hashtags: ctx.body.hashtags.map((p)=>p.name)
};
}
});
}
@@ -0,0 +1,163 @@
import { NoteConverter } from "../converters/note.js";
import { NoteHelpers } from "../helpers/note.js";
import { limitToInt, normalizeUrlQuery } from "./timeline.js";
import { UserConverter } from "../converters/user.js";
import { PollHelpers } from "../helpers/poll.js";
import { toArray } from "../../../../prelude/array.js";
import { auth } from "../middleware/auth.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { filterContext } from "../middleware/filter-context.js";
export function setupEndpointsStatus(router) {
router.post("/v1/statuses", auth(true, [
'write:statuses'
]), async (ctx)=>{
const key = NoteHelpers.getIdempotencyKey(ctx);
if (key !== null) {
const result = await NoteHelpers.getFromIdempotencyCache(key);
if (result) {
ctx.body = result;
return;
}
}
let request = NoteHelpers.normalizeComposeOptions(ctx.request.body);
ctx.body = await NoteHelpers.createNote(request, ctx).then((p)=>NoteConverter.encode(p, ctx));
if (key !== null) NoteHelpers.postIdempotencyCache.set(key, {
status: ctx.body
});
});
router.put("/v1/statuses/:id", auth(true, [
'write:statuses'
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
let request = NoteHelpers.normalizeEditOptions(ctx.request.body);
ctx.body = await NoteHelpers.editNote(request, note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.get("/v1/statuses/:id", auth(false, [
"read:statuses"
]), filterContext('thread'), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteConverter.encode(note, ctx);
});
router.delete("/v1/statuses/:id", auth(true, [
'write:statuses'
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.deleteNote(note, ctx);
});
router.get("/v1/statuses/:id/context", auth(false, [
"read:statuses"
]), filterContext('thread'), async (ctx)=>{
//FIXME: determine final limits within helper functions instead of here
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const ancestors = await NoteHelpers.getNoteAncestors(note, ctx.user ? 4096 : 60, ctx).then((n)=>NoteConverter.encodeMany(n, ctx));
const descendants = await NoteHelpers.getNoteDescendants(note, ctx.user ? 4096 : 40, ctx.user ? 4096 : 20, ctx).then((n)=>NoteConverter.encodeMany(n, ctx));
ctx.body = {
ancestors,
descendants
};
});
router.get("/v1/statuses/:id/history", auth(false, [
"read:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.getNoteEditHistory(note, ctx);
});
router.get("/v1/statuses/:id/source", auth(true, [
"read:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = NoteHelpers.getNoteSource(note);
});
router.get("/v1/statuses/:id/reblogged_by", auth(false, [
"read:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await NoteHelpers.getNoteRebloggedBy(note, args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.get("/v1/statuses/:id/favourited_by", auth(false, [
"read:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await UserConverter.encodeMany(res, ctx);
});
router.post("/v1/statuses/:id/favourite", auth(true, [
"write:favourites"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const reaction = await NoteHelpers.getDefaultReaction();
ctx.body = await NoteHelpers.reactToNote(note, reaction, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/unfavourite", auth(true, [
"write:favourites"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/reblog", auth(true, [
"write:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.reblogNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/unreblog", auth(true, [
"write:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.unreblogNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/bookmark", auth(true, [
"write:bookmarks"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.bookmarkNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/unbookmark", auth(true, [
"write:bookmarks"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.unbookmarkNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/pin", auth(true, [
"write:accounts"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.pinNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/unpin", auth(true, [
"write:accounts"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.unpinNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/react/:name", auth(true, [
"write:favourites"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.reactToNote(note, ctx.params.name, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.post("/v1/statuses/:id/unreact/:name", auth(true, [
"write:favourites"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
});
router.get("/v1/polls/:id", auth(false, [
"read:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
ctx.body = await PollHelpers.getPoll(note, ctx);
});
router.post("/v1/polls/:id/votes", auth(true, [
"write:statuses"
]), async (ctx)=>{
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
const body = ctx.request.body;
const choices = toArray(body.choices ?? []).map((p)=>parseInt(p));
if (choices.length < 1) throw new MastoApiError(400, "Must vote for at least one option");
ctx.body = await PollHelpers.voteInPoll(choices, note, ctx);
});
}
@@ -0,0 +1,5 @@
export function setupEndpointsStreaming(router) {
router.get("/v1/streaming/health", async (ctx)=>{
ctx.body = "OK";
});
}
@@ -0,0 +1,101 @@
import { TimelineHelpers } from "../helpers/timeline.js";
import { NoteConverter } from "../converters/note.js";
import { UserLists } from "../../../../models/index.js";
import { auth } from "../middleware/auth.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { filterContext } from "../middleware/filter-context.js";
//TODO: Move helper functions to a helper class
export function limitToInt(q, additional = []) {
let object = q;
if (q.limit) {
if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10);
}
if (q.offset) {
if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10);
}
for (const key of additional)if (typeof q[key] === "string") object[key] = parseInt(q[key], 10);
return object;
}
export function argsToBools(q, additional = []) {
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
const toBoolean = (value)=>![
"0",
"f",
"F",
"false",
"FALSE",
"off",
"OFF"
].includes(value);
// Keys taken from:
// - https://docs.joinmastodon.org/methods/accounts/#statuses
// - https://docs.joinmastodon.org/methods/timelines/#public
// - https://docs.joinmastodon.org/methods/timelines/#tag
let keys = [
'only_media',
'exclude_replies',
'exclude_reblogs',
'pinned',
'local',
'remote'
].concat(additional);
let object = q;
for (const key of keys)if (q[key] && typeof q[key] === "string") object[key] = toBoolean(q[key]);
return object;
}
export function normalizeUrlQuery(q, arrayKeys = []) {
const dict = {};
for(const k in q){
if (arrayKeys.includes(k)) dict[k] = Array.isArray(q[k]) ? q[k] : [
q[k]
];
else dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k];
}
return dict;
}
export function setupEndpointsTimeline(router) {
router.get("/v1/timelines/public", auth(true, [
'read:statuses'
]), filterContext('public'), async (ctx, reply)=>{
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)));
const res = await TimelineHelpers.getPublicTimeline(args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote, ctx);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/timelines/tag/:hashtag", auth(false, [
'read:statuses'
]), filterContext('public'), async (ctx, reply)=>{
const tag = (ctx.params.hashtag ?? '').trim().toLowerCase();
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)), [
'any[]',
'all[]',
'none[]'
]);
const res = await TimelineHelpers.getTagTimeline(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, ctx);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/timelines/home", auth(true, [
'read:statuses'
]), filterContext('home'), async (ctx, reply)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await TimelineHelpers.getHomeTimeline(args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/timelines/list/:listId", auth(true, [
'read:lists'
]), filterContext('home'), async (ctx, reply)=>{
const list = await UserLists.findOneBy({
userId: ctx.user.id,
id: ctx.params.listId
});
if (!list) throw new MastoApiError(404);
const args = normalizeUrlQuery(limitToInt(ctx.query));
const res = await TimelineHelpers.getListTimeline(list, args.max_id, args.since_id, args.min_id, args.limit, ctx);
ctx.body = await NoteConverter.encodeMany(res, ctx);
});
router.get("/v1/conversations", auth(true, [
'read:statuses'
]), async (ctx, reply)=>{
const args = normalizeUrlQuery(limitToInt(ctx.query));
ctx.body = await TimelineHelpers.getConversations(args.max_id, args.since_id, args.min_id, args.limit, ctx);
});
}