server/api/api/v1/accounts/search/index.ts
Jesse Wierzbinski 3d3e64edab
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 42s
Build Docker Images / lint (push) Successful in 31s
Build Docker Images / check (push) Successful in 1m3s
Build Docker Images / tests (push) Failing after 6s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / build (push) Failing after 13s
Mirror to Codeberg / Mirror (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 33m18s
feat(api): Implement rate limiting
2025-03-27 20:12:00 +01:00

127 lines
4.3 KiB
TypeScript

import { apiRoute, auth, parseUserAddress } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { eq, ilike, not, or, sql } from "drizzle-orm";
import stringComparison from "string-comparison";
import { ApiError } from "~/classes/errors/api-error";
import { rateLimit } from "~/middlewares/rate-limit";
export const route = createRoute({
method: "get",
path: "/api/v1/accounts/search",
summary: "Search for matching accounts",
description: "Search for matching accounts by username or display name.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#search",
},
tags: ["Accounts"],
middleware: [
rateLimit(5),
auth({
auth: false,
permissions: [RolePermission.Search, RolePermission.ViewAccounts],
scopes: ["read:accounts"],
}),
] as const,
request: {
query: z.object({
q: AccountSchema.shape.username
.or(AccountSchema.shape.acct)
.openapi({
description: "Search query for accounts.",
example: "username",
}),
limit: z.coerce.number().int().min(1).max(80).default(40).openapi({
description: "Maximum number of results.",
example: 40,
}),
offset: z.coerce.number().int().default(0).openapi({
description: "Skip the first n results.",
example: 0,
}),
resolve: zBoolean.default(false).openapi({
description:
"Attempt WebFinger lookup. Use this when q is an exact address.",
example: false,
}),
following: zBoolean.default(false).openapi({
description: "Limit the search to users you are following.",
example: false,
}),
}),
},
responses: {
200: {
description: "Accounts",
content: {
"application/json": {
schema: z.array(AccountSchema),
},
},
},
},
});
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const { q, limit, offset, resolve, following } =
context.req.valid("query");
const { user } = context.get("auth");
if (!user && following) {
throw new ApiError(401, "Must be authenticated to use 'following'");
}
const { username, domain } = parseUserAddress(q);
const accounts: User[] = [];
if (resolve && domain) {
const manager = await (user ?? User).getFederationRequester();
const uri = await User.webFinger(manager, username, domain);
if (uri) {
const resolvedUser = await User.resolve(uri);
if (resolvedUser) {
accounts.push(resolvedUser);
}
}
} else {
accounts.push(
...(await User.manyFromSql(
or(
ilike(Users.displayName, `%${q}%`),
ilike(Users.username, `%${q}%`),
following && user
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`
: undefined,
user ? not(eq(Users.id, user.id)) : undefined,
),
undefined,
limit,
offset,
)),
);
}
const indexOfCorrectSort = stringComparison.jaccardIndex
.sortMatch(
q,
accounts.map((acct) => acct.getAcct()),
)
.map((sort) => sort.index);
const result = indexOfCorrectSort.map((index) => accounts[index]);
return context.json(
result.map((acct) => acct.toApi()),
200,
);
}),
);